infrasynth-backend-kit/infrasynth/gates.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

381 lines
13 KiB
Python

"""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: <challenge_id>:<solution>:<number>``
* 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