- 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)
170 lines
6.2 KiB
Python
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)
|