"""Composable, per-endpoint access gates. This is the kit's **extension surface** for access control. A consuming app (installed from PyPI) declares what a view needs — and nothing else — without editing the kit:: from infrasynth.gates import ( GatePermission, gated, TwoFactorGate, AltchaGate, EntitlementGate, FeatureGate, PermissionGate, ) class PublicSignupView(APIView): permission_classes = [AllowAny, GatePermission] infrasynth_gates = [AltchaGate()] # proven human, no login class PayoutView(APIView): permission_classes = [IsAuthenticated, GatePermission] infrasynth_gates = [ TwoFactorGate(), # step-up second factor EntitlementGate("billing", feature="payouts"), PermissionGate("billing.payout"), ] class TicketViewSet(ModelViewSet): permission_classes = [IsAuthenticated, GatePermission] infrasynth_gates = [FeatureGate("ticketing")] @gated(TwoFactorGate()) @action(detail=True, methods=["post"]) def close(self, request, pk=None): ... A view with no gates is ungated — the default is "gate nothing". Gates are evaluated in order and the first denial raises the matching namespaced :class:`~infrasynth.shared.exceptions.AppError`, so the response carries the correct code and HTTP status (``AUTH_*``/``ENTITLEMENT_*``/``VALIDATION_*``). The kit's own permission classes (:class:`infrasynth.security.permissions. HybridPermission`) call :func:`evaluate_gates` too, so declaring gates on a view that already uses kit permissions is enough. """ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass, field from typing import Any, TypeVar from rest_framework.permissions import BasePermission __all__ = [ "Gate", "GateResult", "GatePermission", "evaluate_gates", "gated", "TwoFactorGate", "AltchaGate", "EntitlementGate", "FeatureGate", "PermissionGate", ] _F = TypeVar("_F", bound=Callable[..., Any]) @dataclass(frozen=True) class GateResult: """Outcome of a single gate check.""" allowed: bool code: str = "" message: str = "Access denied." details: list[dict[str, Any]] = field(default_factory=list) status: int = 403 @classmethod def allow(cls) -> GateResult: return cls(allowed=True) @classmethod def deny( cls, code: str, message: str, *, status: int = 403, details: list[dict[str, Any]] | None = None, ) -> GateResult: return cls(allowed=False, code=code, message=message, status=status, details=details or []) class Gate: """Base class for a single access condition.""" def check(self, request: Any, view: Any) -> GateResult: # pragma: no cover - interface raise NotImplementedError # --- built-in gates --------------------------------------------------------- class TwoFactorGate(Gate): """Requires the current session/token to have passed the second factor. * A user with **no** configured 2FA passes by default (there is nothing to enforce). Set ``require_configured=True`` to instead demand setup. * A user **with** 2FA must present proof: a JWT carrying the ``2fa`` claim (minted only after verification, including across workspace selection) or a verified session. """ def __init__(self, *, require_configured: bool = False) -> None: self.require_configured = require_configured def check(self, request: Any, view: Any) -> GateResult: user = getattr(request, "user", None) if not user or not getattr(user, "is_authenticated", False): # Authentication is the auth class's job, not the gate's. return GateResult.allow() from infrasynth.security.models import TwoFactorConfig config = TwoFactorConfig.objects.filter(user=user).first() configured = bool(config and config.is_enabled and config.is_configured) if not configured: if self.require_configured: return GateResult.deny( "AUTH_2FA_SETUP_REQUIRED", "Second-factor authentication must be configured for this action.", details=[{"field": "2fa", "issue": "setup_required"}], ) return GateResult.allow() if _token_has_second_factor(request): return GateResult.allow() session = getattr(request, "session", None) if session is not None and session.get("_2fa_verified"): return GateResult.allow() return GateResult.deny( "AUTH_2FA_REQUIRED", "Second-factor verification is required for this action.", details=[{"field": "2fa", "issue": "verification_required"}], ) class AltchaGate(Gate): """Requires a valid ALTCHA proof-of-work solution on the request. The client fetches a challenge from ``/api/v1/auth/altcha/challenge/`` and submits the solution in any of these places: * JSON body: ``{"altcha": {"challenge_id", "solution", "number"}}`` * JSON body: flat ``altcha_challenge_id`` / ``altcha_solution`` / ``altcha_number`` * header ``X-Altcha: ::`` * query string: the three flat names No login is required — that is the point (signup, contact, public forms). """ def __init__(self, *, required: bool = True) -> None: self.required = required def check(self, request: Any, view: Any) -> GateResult: challenge_id, solution, number = _extract_altcha(request) if not challenge_id or not solution or number is None: if not self.required: return GateResult.allow() return GateResult.deny( "VALIDATION_ALTCHA_REQUIRED", "An ALTCHA proof-of-work solution is required.", status=400, details=[{"field": "altcha", "issue": "required"}], ) from infrasynth.security.altcha.services import ALTCHAService if ALTCHAService().verify(challenge_id, solution, number): return GateResult.allow() return GateResult.deny( "VALIDATION_ALTCHA_INVALID", "The ALTCHA proof-of-work solution is missing, expired, or invalid.", status=400, details=[{"field": "altcha", "issue": "invalid"}], ) class EntitlementGate(Gate): """Requires the current tenant to be commercially entitled (``ENTITLEMENT_*``).""" def __init__(self, app: str, *, feature: str | None = None) -> None: self.app = app self.feature = feature def check(self, request: Any, view: Any) -> GateResult: from infrasynth.billing.entitlements import EntitlementService from infrasynth.tenancy.context import get_current_tenant tenant = get_current_tenant() service = EntitlementService() if service.is_entitled(tenant, self.app, feature=self.feature): return GateResult.allow() if tenant is not None and service.get(tenant, self.app) is not None and self.feature: code = "ENTITLEMENT_PLAN_UPGRADE_REQUIRED" message = f"The current plan does not include '{self.feature}'." else: code = "ENTITLEMENT_APP_NOT_OWNED" message = f"This workspace is not entitled to '{self.app}'." return GateResult.deny( code, message, status=402, details=[{"app": self.app, "feature": self.feature} if self.feature else {"app": self.app}], ) class FeatureGate(Gate): """Requires an operational feature flag to be enabled (``404`` when off).""" def __init__(self, slug: str, *, default: bool | None = None) -> None: self.slug = slug self.default = default def check(self, request: Any, view: Any) -> GateResult: from infrasynth.features.services import FeatureService from infrasynth.tenancy.context import get_current_tenant tenant = get_current_tenant() enabled = FeatureService().is_enabled( self.slug, user=getattr(request, "user", None), tenant_id=tenant.pk if tenant is not None else None, default=self.default, ) if enabled: return GateResult.allow() # Hide existence behind the flag: 404, not 403. return GateResult.deny("NOT_FOUND", "The requested resource was not found.", status=404) class PermissionGate(Gate): """Requires permission codename(s) through ``AuthorizationService``.""" def __init__(self, *codenames: str, require_all: bool = False) -> None: self.codenames = codenames self.require_all = require_all def check(self, request: Any, view: Any) -> GateResult: from infrasynth.security.services import AuthorizationService user = getattr(request, "user", None) authz = AuthorizationService() if self.require_all: allowed = authz.has_all_permissions(user, list(self.codenames)) else: allowed = authz.has_any_permission(user, list(self.codenames)) if allowed: return GateResult.allow() return GateResult.deny( "AUTH_FORBIDDEN", "You do not have permission to perform this action.", details=[{"field": "permission", "issue": ", ".join(self.codenames)}], ) # --- evaluation ------------------------------------------------------------- def _gates_for(view: Any) -> list[Gate]: gates: list[Gate] = list(getattr(view, "infrasynth_gates", []) or []) action = getattr(view, "action", None) if action: handler = getattr(view, action, None) gates += list(getattr(handler, "infrasynth_gates", []) or []) getter = getattr(view, "get_infrasynth_gates", None) if callable(getter): gates += list(getter() or []) return gates def evaluate_gates(request: Any, view: Any) -> None: """Runs every declared gate, raising the namespaced error on the first denial.""" for gate in _gates_for(view): result = gate.check(request, view) if not result.allowed: _raise_denial(result) def _raise_denial(result: GateResult) -> None: from infrasynth.shared.exceptions import ( AppError, AuthError, EntitlementError, NotFoundError, ValidationAppError, ) code = result.code if code.startswith("ENTITLEMENT_"): exc: type[AppError] = EntitlementError elif code.startswith("VALIDATION_"): exc = ValidationAppError elif code.startswith("NOT_FOUND"): exc = NotFoundError else: exc = AuthError raise exc(result.message, code=code, status=result.status, details=result.details) def gated(*gates: Gate) -> Callable[[_F], _F]: """Decorator adding gate(s) to a view class or a viewset action method.""" def decorator(func: _F) -> _F: existing = list(getattr(func, "infrasynth_gates", []) or []) func.infrasynth_gates = existing + list(gates) # type: ignore[attr-defined] return func return decorator class GatePermission(BasePermission): """DRF permission that evaluates a view's declared gates (no gates ⇒ allow).""" message = "Access denied by a gate." def has_permission(self, request: Any, view: Any) -> bool: evaluate_gates(request, view) return True # --- helpers ---------------------------------------------------------------- def _token_has_second_factor(request: Any) -> bool: auth = getattr(request, "auth", None) if auth is None or not hasattr(auth, "get"): return False try: return bool(auth.get("2fa")) except Exception: # noqa: BLE001 - opaque token objects return False def _extract_altcha(request: Any) -> tuple[str | None, str | None, int | None]: from infrasynth.shared.settings_utils import get_setting data = getattr(request, "data", None) or {} payload = data.get("altcha") if hasattr(data, "get") else None if isinstance(payload, dict): return ( payload.get("challenge_id") or payload.get("challengeId"), payload.get("solution"), _as_int(payload.get("number")), ) header_name = str(get_setting("INFRASYNTH_SECURITY", "ALTCHA_HEADER", "X-Altcha")) headers = getattr(request, "headers", {}) or {} raw = headers.get(header_name) or headers.get(header_name.lower()) if raw and ":" in raw: challenge_id, solution, number = (raw.split(":", 2) + [""])[:3] return challenge_id, solution, _as_int(number) def _first(*keys: str): for key in keys: if hasattr(data, "get") and data.get(key) is not None: return data.get(key) if hasattr(request, "query_params") and request.query_params.get(key) is not None: return request.query_params.get(key) return None return ( _first("altcha_challenge_id", "altchaChallengeId"), _first("altcha_solution", "altchaSolution"), _as_int(_first("altcha_number", "altchaNumber")), ) def _as_int(value: Any) -> int | None: try: return int(value) except (TypeError, ValueError): return None