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
381 lines
13 KiB
Python
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
|