infrasynth-backend-kit/infrasynth/security/permissions.py
jcv-dev 21731b9887 feat(gates): composable per-endpoint gating extension API
Make access gating a first-class, pip-consumable extension point so a
consuming app can gate any of its own views behind 2FA / ALTCHA /
entitlement / feature flag / permission, or gate nothing, without editing
the kit.

- infrasynth.gates: Gate, GateResult, GatePermission, @gated and built-ins
  TwoFactorGate, AltchaGate, EntitlementGate, FeatureGate, PermissionGate;
  denials raise the correct namespaced error/status (per-endpoint, opt-in,
  default is no gating)
- mint a `2fa` JWT claim only after verification (preserved across workspace
  selection) so TwoFactorGate is meaningful for API/multi-workspace clients
- GatePermission added to DEFAULT_PERMISSION_CLASSES; HybridPermission
  evaluates declared gates so kit permissions gate automatically
- document the extension surface and stable import paths in README
2026-09-24 10:49:44 -05:00

90 lines
3.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 at least one of the view's
``required_permissions`` (``HybridPermission``) or all of them
(``require_permission``).
* A view with no ``required_permissions`` only needs authentication.
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 .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):
"""Allows when the user is an owner or holds any ``required_permissions``."""
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:
return True
return AuthorizationService().has_any_permission(user, required)
class IsAuthenticatedAndPermitted(HybridPermission):
"""The idiom for kit views: authenticated, then permission-checked."""
def has_permission(self, request, view):
if not getattr(getattr(request, "user", None), "is_authenticated", False):
return False
return super().has_permission(request, view)
def require_permission(*codenames: str):
"""View (or view-decorator) requiring *all* listed permissions."""
class PermissionRequired(IsAuthenticatedAndPermitted):
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
user = request.user
if getattr(user, "is_superuser", False) or is_tenant_owner(user):
return True
return AuthorizationService().has_all_permissions(user, list(codenames))
return PermissionRequired