infrasynth-backend-kit/infrasynth/security/permissions.py
jcv-dev a2930426b4 feat: tenant configs, live signals, and automatic permission management
- infrasynth.configs: typed multi-tenant config store (registry, service,
  secrets, cache) + public config_changed/config_reset signals and API
- emit the declared-but-dead signals (features flags/overrides, scheduler
  task_completed/task_failed, tenancy tenant_updated, audit model_changed)
  and per-model audit field exclusions
- security: permission catalog (security_permission), Django-style
  model-derived AutoPermission, PermissionRegistry, RoleAssignment,
  global-or-tenant Grant/Revoke, catalog API
- consolidate the permission surface: PermissionRegistry only (drop the
  settings dict), IsAuthenticatedAndPermitted aliases HybridPermission,
  require_permission replaced by required_permissions + require_all
- packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
2026-09-29 17:06:54 -05:00

170 lines
6.2 KiB
Python

"""DRF permission classes built on :class:`AuthorizationService`.
Enforcement model
-----------------
* A superuser is always allowed.
* A **tenant owner** (``TenantMembership.is_owner`` for the resolved tenant) is
allowed — ownership is a capability, not a permission row.
* Otherwise the request user must hold **any** of the view's
``required_permissions`` (set ``require_all = True`` to demand all of them).
* A view with no ``required_permissions`` falls back to the model-derived
codename (see :class:`AutoPermission`).
* Declared gates (``infrasynth_gates``) run first for everyone, including owners
and superusers; use :class:`infrasynth.gates.PermissionGate` when even the
owner must hold a codename.
The underlying :class:`AuthorizationService` is deliberately strict (owners are
not implicitly granted every codename) so it stays a pure permission resolver;
ownership is handled at the HTTP boundary here.
"""
from __future__ import annotations
from typing import Any
from rest_framework.permissions import BasePermission
from infrasynth.gates import evaluate_gates
from infrasynth.shared.settings_utils import get_setting
from .services import AuthorizationService
def is_tenant_owner(user: Any) -> bool:
"""True when ``user`` owns the currently bound tenant."""
if not user or not getattr(user, "is_authenticated", False):
return False
# System users (API keys) are not database-backed memberships.
if not hasattr(user, "_meta") or getattr(user, "pk", None) is None:
return False
from infrasynth.tenancy.context import get_current_tenant
from infrasynth.tenancy.models import TenantMembership
tenant = get_current_tenant()
if tenant is None:
return False
return TenantMembership.objects.filter(
tenant=tenant,
user=user,
is_active=True,
is_owner=True,
).exists()
class HybridPermission(BasePermission):
"""Authenticated, then permission-checked, with an owner/superuser bypass.
A view declares ``required_permissions`` (any-of by default; set
``require_all = True`` for all-of). With none declared, the model-derived
codename is enforced via :func:`evaluate_auto_permission`.
"""
def has_permission(self, request, view):
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return False
# Declared gates apply to everyone, including owners and superusers.
evaluate_gates(request, view)
if getattr(user, "is_superuser", False):
return True
if is_tenant_owner(user):
return True
required = getattr(view, "required_permissions", []) or []
if not required:
# No explicit permission: fall back to the model-derived codename.
return evaluate_auto_permission(request, view)
authz = AuthorizationService()
if getattr(view, "require_all", False):
return authz.has_all_permissions(user, list(required))
return authz.has_any_permission(user, list(required))
class AutoPermission(BasePermission):
"""Derives and enforces ``{app}.{action}_{model}`` permissions automatically.
Only applies to model-backed DRF viewsets (and only when ``AUTO_PERMISSIONS``
is enabled); it abstains on plain APIViews so it is safe in
``DEFAULT_PERMISSION_CLASSES``. Tenant owners and superusers bypass, matching
``HybridPermission``.
"""
message = "You do not have permission to perform this action."
def has_permission(self, request, view):
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False):
return True
return evaluate_auto_permission(request, view)
# Backwards-compatible alias: ``IsAuthenticatedAndPermitted`` is the documented
# idiom name for kit views but is exactly ``HybridPermission``.
IsAuthenticatedAndPermitted = HybridPermission
def resolve_view_model(view) -> Any:
"""Best-effort concrete model behind a DRF view, or ``None``."""
model = getattr(getattr(view, "queryset", None), "model", None)
if model is not None:
return model
get_queryset = getattr(view, "get_queryset", None)
if callable(get_queryset):
try:
return getattr(get_queryset(), "model", None)
except Exception: # noqa: BLE001 - not every queryset is safe to build
return None
return None
def automatic_permissions(view) -> list[str]:
"""The codenames a view requires, explicitly declared or auto-derived.
Priority: ``required_permissions`` (explicit) → ``action_permissions`` for
the current action → derived ``{app}.{verb}_{model}`` → ``[]``.
"""
explicit = getattr(view, "required_permissions", None)
if explicit:
return list(explicit)
action = getattr(view, "action", None)
if not action:
return []
action_map = getattr(view, "action_permissions", None) or {}
if action in action_map:
return [action_map[action]]
model = resolve_view_model(view)
if model is None:
return []
from .catalog import permission_for
return [permission_for(model, action)]
def auto_permissions_enabled(view) -> bool:
"""Whether model-derived enforcement applies to ``view``."""
mode = get_setting("INFRASYNTH_SECURITY", "AUTO_PERMISSIONS", "global")
if mode in (False, None, "off", "disabled"):
return False
if getattr(view, "auto_permissions", None) is False:
return False
if mode == "opt_in":
return bool(getattr(view, "auto_permissions", False))
return True
def evaluate_auto_permission(request, view) -> bool:
"""Runs the auto-permission check, abstaining when nothing is derivable."""
if not auto_permissions_enabled(view):
return True
codenames = automatic_permissions(view)
if not codenames:
return True
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False):
return True
if is_tenant_owner(user):
return True
return AuthorizationService().has_any_permission(user, codenames)