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
This commit is contained in:
parent
551b42eab5
commit
21731b9887
9 changed files with 710 additions and 11 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -10,6 +10,16 @@ not hand-pick a version (see `../AGENTS.backend-packages.md` §8).
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Composable per-endpoint gates** (`infrasynth.gates`): declare
|
||||||
|
`infrasynth_gates = [...]` (and/or `@gated(...)` on a viewset action) with
|
||||||
|
`TwoFactorGate`, `AltchaGate`, `EntitlementGate`, `FeatureGate`,
|
||||||
|
`PermissionGate`, or a custom `Gate`. Access is evaluated per endpoint, the
|
||||||
|
default is "gate nothing", and denials raise the correct namespaced error
|
||||||
|
(`AUTH_2FA_REQUIRED`, `ENTITLEMENT_PLAN_UPGRADE_REQUIRED`,
|
||||||
|
`VALIDATION_ALTCHA_REQUIRED`, …). `GatePermission` is a default permission
|
||||||
|
class and the kit's `HybridPermission` evaluates declared gates too.
|
||||||
|
- **`2fa` JWT claim** minted only after successful verification and preserved
|
||||||
|
across workspace selection, so `TwoFactorGate` works for multi-workspace users.
|
||||||
- **Verified inbound webhooks.** `InboundReceiveView` now enforces the shared
|
- **Verified inbound webhooks.** `InboundReceiveView` now enforces the shared
|
||||||
HMAC signature (or a provider-specific `BaseInboundHandler.verify`), payload
|
HMAC signature (or a provider-specific `BaseInboundHandler.verify`), payload
|
||||||
size limits, timestamp tolerance, and idempotent re-delivery via
|
size limits, timestamp tolerance, and idempotent re-delivery via
|
||||||
|
|
|
||||||
1
PLAN.md
1
PLAN.md
|
|
@ -174,6 +174,7 @@ InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infra
|
||||||
- [x] Guardas de workflow (`MAX_INSTANCES_PER_WORKFLOW`, `ROUTE_MAX_DEPTH`, `ALLOW_SELF_ASSIGNMENT`, `AUTO_CLONE_ASSIGNEES_ON_REENTRY`)
|
- [x] Guardas de workflow (`MAX_INSTANCES_PER_WORKFLOW`, `ROUTE_MAX_DEPTH`, `ALLOW_SELF_ASSIGNMENT`, `AUTO_CLONE_ASSIGNEES_ON_REENTRY`)
|
||||||
- [x] Throttling tenant-scoped por defecto; `CELERY_BEAT_SCHEDULE` con trabajos periódicos
|
- [x] Throttling tenant-scoped por defecto; `CELERY_BEAT_SCHEDULE` con trabajos periódicos
|
||||||
- [x] README + CHANGELOG; CI con `ruff format --check` y umbral de cobertura
|
- [x] README + CHANGELOG; CI con `ruff format --check` y umbral de cobertura
|
||||||
|
- [x] `infrasynth.gates` — gates por endpoint (`TwoFactorGate`, `AltchaGate`, `EntitlementGate`, `FeatureGate`, `PermissionGate`) declarables sin tocar el kit; `GatePermission` por defecto; claim `2fa` en el JWT
|
||||||
|
|
||||||
## 1. Estructura del Paquete
|
## 1. Estructura del Paquete
|
||||||
|
|
||||||
|
|
|
||||||
73
README.md
73
README.md
|
|
@ -75,6 +75,79 @@ class SlackChannel(BaseChannel):
|
||||||
|
|
||||||
Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`.
|
Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gating your own endpoints (no kit edits)
|
||||||
|
|
||||||
|
`infrasynth.gates` is the composable, per-endpoint access layer. A view declares
|
||||||
|
exactly what it needs; the default is **gate nothing**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from infrasynth.gates import (
|
||||||
|
GatePermission, gated,
|
||||||
|
TwoFactorGate, AltchaGate, EntitlementGate, FeatureGate, PermissionGate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Public form: proof-of-work, no login.
|
||||||
|
class SignupView(APIView):
|
||||||
|
permission_classes = [AllowAny, GatePermission]
|
||||||
|
infrasynth_gates = [AltchaGate()]
|
||||||
|
|
||||||
|
# Sensitive action: step-up 2FA + commercial right + codename.
|
||||||
|
class PayoutView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated, GatePermission]
|
||||||
|
infrasynth_gates = [
|
||||||
|
TwoFactorGate(), # passes users without 2FA;
|
||||||
|
EntitlementGate("billing", feature="payouts"), # use require_configured=True to demand setup
|
||||||
|
PermissionGate("billing.payout"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class TicketViewSet(ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated, GatePermission]
|
||||||
|
infrasynth_gates = [FeatureGate("ticketing")] # 404 when the flag is off
|
||||||
|
|
||||||
|
@gated(TwoFactorGate())
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def close(self, request, pk=None): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Gates run in order; the first denial raises the matching namespaced error
|
||||||
|
(`AUTH_*`, `ENTITLEMENT_*`, `VALIDATION_*`, `NOT_FOUND`) so the envelope gets
|
||||||
|
the right code and status. No gates declared ⇒ the permission is a no-op.
|
||||||
|
- `GatePermission` is in `DEFAULT_PERMISSION_CLASSES`; the kit's own
|
||||||
|
`HybridPermission`/`IsAuthenticatedAndPermitted` also evaluate declared gates,
|
||||||
|
so you only add it explicitly on views that use plain DRF permissions.
|
||||||
|
- `AltchaGate` accepts the solution in a JSON `altcha` object, flat body/query
|
||||||
|
keys, or the `X-Altcha: <challenge_id>:<solution>:<number>` header; clients get
|
||||||
|
a challenge from `/api/v1/auth/altcha/challenge/`.
|
||||||
|
- `TwoFactorGate` verifies a JWT `2fa` claim (minted at verification and carried
|
||||||
|
across workspace selection) or a verified session. Users without 2FA pass by
|
||||||
|
default; `require_configured=True` denies them with `AUTH_2FA_SETUP_REQUIRED`.
|
||||||
|
|
||||||
|
Every gate is also a plain class implementing `check(request, view) -> GateResult`,
|
||||||
|
so an app can ship its own (e.g. an IP allow-list) and pass it to `@gated(...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stable import paths
|
||||||
|
|
||||||
|
Consumers import from the public modules, never internal helpers:
|
||||||
|
|
||||||
|
| Need | Import |
|
||||||
|
|---|---|
|
||||||
|
| Gating | `from infrasynth.gates import GatePermission, AltchaGate, …` |
|
||||||
|
| Permissions/auth | `from infrasynth.security.permissions import HybridPermission` |
|
||||||
|
| JWT/API-key auth | `from infrasynth.security.auth.cookies import CookieJWTAuthentication` |
|
||||||
|
| Authorization | `from infrasynth.security.services import AuthorizationService` |
|
||||||
|
| Tenant context/scoping | `from infrasynth.tenancy.managers import TenantManager` |
|
||||||
|
| Entitlements | `from infrasynth.billing.entitlements import EntitlementService` |
|
||||||
|
| Feature flags | `from infrasynth.features.services import FeatureService` |
|
||||||
|
| Storage | `from infrasynth.files.storage import get_storage_backend` |
|
||||||
|
| Errors/envelope | `from infrasynth.shared.exceptions import EntitlementError` |
|
||||||
|
| Wire format | `from infrasynth.api.renderers import EnvelopeJSONRenderer` |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scheduled work
|
## Scheduled work
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ REST_FRAMEWORK = {
|
||||||
],
|
],
|
||||||
"DEFAULT_PERMISSION_CLASSES": [
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
"rest_framework.permissions.IsAuthenticated",
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
|
"infrasynth.gates.GatePermission",
|
||||||
],
|
],
|
||||||
"DEFAULT_RENDERER_CLASSES": [
|
"DEFAULT_RENDERER_CLASSES": [
|
||||||
"infrasynth.api.renderers.EnvelopeJSONRenderer",
|
"infrasynth.api.renderers.EnvelopeJSONRenderer",
|
||||||
|
|
@ -191,6 +192,7 @@ INFRASYNTH_SECURITY = {
|
||||||
"ALTCHA_DIFFICULTY": 10000,
|
"ALTCHA_DIFFICULTY": 10000,
|
||||||
"ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300,
|
"ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300,
|
||||||
"ALTCHA_PROTECT_LOGIN": False,
|
"ALTCHA_PROTECT_LOGIN": False,
|
||||||
|
"ALTCHA_HEADER": "X-Altcha",
|
||||||
"API_KEY_PREFIX_LENGTH": 8,
|
"API_KEY_PREFIX_LENGTH": 8,
|
||||||
"API_KEY_HASH_ALGORITHM": "pbkdf2_sha256",
|
"API_KEY_HASH_ALGORITHM": "pbkdf2_sha256",
|
||||||
"API_KEY_DEFAULT_EXPIRY_DAYS": 365,
|
"API_KEY_DEFAULT_EXPIRY_DAYS": 365,
|
||||||
|
|
|
||||||
381
infrasynth/gates.py
Normal file
381
infrasynth/gates.py
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"""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
|
||||||
|
|
@ -21,6 +21,8 @@ from typing import Any
|
||||||
|
|
||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
|
from infrasynth.gates import evaluate_gates
|
||||||
|
|
||||||
from .services import AuthorizationService
|
from .services import AuthorizationService
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -52,6 +54,8 @@ class HybridPermission(BasePermission):
|
||||||
user = getattr(request, "user", None)
|
user = getattr(request, "user", None)
|
||||||
if not user or not getattr(user, "is_authenticated", False):
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
return False
|
return False
|
||||||
|
# Declared gates apply to everyone, including owners and superusers.
|
||||||
|
evaluate_gates(request, view)
|
||||||
if getattr(user, "is_superuser", False):
|
if getattr(user, "is_superuser", False):
|
||||||
return True
|
return True
|
||||||
if is_tenant_owner(user):
|
if is_tenant_owner(user):
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,12 @@ class _AuthSupport:
|
||||||
"""Cookie/JWT/2FA helpers shared by the auth and 2FA viewsets."""
|
"""Cookie/JWT/2FA helpers shared by the auth and 2FA viewsets."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _mint_tokens(user, tenant):
|
def _mint_tokens(user, tenant, *, two_factor: bool = False):
|
||||||
refresh = RefreshToken.for_user(user)
|
refresh = RefreshToken.for_user(user)
|
||||||
if tenant is not None:
|
if tenant is not None:
|
||||||
refresh["tenant"] = str(tenant.pk)
|
refresh["tenant"] = str(tenant.pk)
|
||||||
|
# ``2fa`` lets per-endpoint gates require a step-up second factor.
|
||||||
|
refresh["2fa"] = bool(two_factor)
|
||||||
return str(refresh.access_token), str(refresh)
|
return str(refresh.access_token), str(refresh)
|
||||||
|
|
||||||
def _cookie_config(self):
|
def _cookie_config(self):
|
||||||
|
|
@ -116,27 +118,29 @@ class _AuthSupport:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _user_from_refresh_cookie(self, request):
|
def _user_from_refresh_cookie(self, request):
|
||||||
|
"""Returns ``(user, claims)`` from the refresh cookie, or ``(None, {})``."""
|
||||||
raw = request.COOKIES.get(get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token"))
|
raw = request.COOKIES.get(get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token"))
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None, {}
|
||||||
try:
|
try:
|
||||||
refresh = RefreshToken(self._decrypt(raw)) # type: ignore[arg-type]
|
refresh = RefreshToken(self._decrypt(raw)) # type: ignore[arg-type]
|
||||||
user_id = refresh.get("user_id")
|
user_id = refresh.get("user_id")
|
||||||
|
claims = {"2fa": bool(refresh.get("2fa"))}
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None, {}
|
||||||
return UserModel.objects.filter(pk=user_id).first()
|
return UserModel.objects.filter(pk=user_id).first(), claims
|
||||||
|
|
||||||
def _complete_login(self, request, user):
|
def _complete_login(self, request, user, *, two_factor: bool = False):
|
||||||
"""Issues tenant-bound tokens or a workspace-picker challenge."""
|
"""Issues tenant-bound tokens or a workspace-picker challenge."""
|
||||||
memberships = TenantService().get_active_memberships(user)
|
memberships = TenantService().get_active_memberships(user)
|
||||||
if len(memberships) == 1:
|
if len(memberships) == 1:
|
||||||
tenant = memberships[0].tenant
|
tenant = memberships[0].tenant
|
||||||
access, refresh = self._mint_tokens(user, tenant)
|
access, refresh = self._mint_tokens(user, tenant, two_factor=two_factor)
|
||||||
response = Response({"detail": "Login successful.", "tenant": str(tenant.pk)})
|
response = Response({"detail": "Login successful.", "tenant": str(tenant.pk)})
|
||||||
self._set_auth_cookies(response, access, refresh)
|
self._set_auth_cookies(response, access, refresh)
|
||||||
return response
|
return response
|
||||||
if len(memberships) > 1:
|
if len(memberships) > 1:
|
||||||
_, refresh = self._mint_tokens(user, None)
|
_, refresh = self._mint_tokens(user, None, two_factor=two_factor)
|
||||||
response = Response(
|
response = Response(
|
||||||
{
|
{
|
||||||
"detail": "Select a workspace.",
|
"detail": "Select a workspace.",
|
||||||
|
|
@ -204,7 +208,7 @@ class _AuthSupport:
|
||||||
request.session.pop("_2fa_pre_auth_token", None)
|
request.session.pop("_2fa_pre_auth_token", None)
|
||||||
request.session.pop("_2fa_started_at", None)
|
request.session.pop("_2fa_started_at", None)
|
||||||
two_factor_verified.send(sender=self.__class__, user=user, method="totp")
|
two_factor_verified.send(sender=self.__class__, user=user, method="totp")
|
||||||
return self._complete_login(request, user)
|
return self._complete_login(request, user, two_factor=True)
|
||||||
|
|
||||||
|
|
||||||
class AuthViewSet(_AuthSupport, viewsets.GenericViewSet):
|
class AuthViewSet(_AuthSupport, viewsets.GenericViewSet):
|
||||||
|
|
@ -262,7 +266,7 @@ class AuthViewSet(_AuthSupport, viewsets.GenericViewSet):
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], url_path="select-workspace")
|
@action(detail=False, methods=["post"], url_path="select-workspace")
|
||||||
def select_workspace(self, request):
|
def select_workspace(self, request):
|
||||||
user = self._user_from_refresh_cookie(request)
|
user, claims = self._user_from_refresh_cookie(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise AuthenticationFailed("No pending workspace selection.")
|
raise AuthenticationFailed("No pending workspace selection.")
|
||||||
tenant_id = request.data.get("tenantId") or request.data.get("tenant_id")
|
tenant_id = request.data.get("tenantId") or request.data.get("tenant_id")
|
||||||
|
|
@ -272,7 +276,7 @@ class AuthViewSet(_AuthSupport, viewsets.GenericViewSet):
|
||||||
tenant = TenantService().select_tenant(user, tenant_id)
|
tenant = TenantService().select_tenant(user, tenant_id)
|
||||||
except NotFoundError as exc:
|
except NotFoundError as exc:
|
||||||
raise NotFound(str(exc)) from exc
|
raise NotFound(str(exc)) from exc
|
||||||
access, refresh = self._mint_tokens(user, tenant)
|
access, refresh = self._mint_tokens(user, tenant, two_factor=claims.get("2fa", False))
|
||||||
response = Response({"detail": "Workspace selected.", "tenant": str(tenant.pk)})
|
response = Response({"detail": "Workspace selected.", "tenant": str(tenant.pk)})
|
||||||
self._set_auth_cookies(response, access, refresh)
|
self._set_auth_cookies(response, access, refresh)
|
||||||
return response
|
return response
|
||||||
|
|
@ -297,7 +301,13 @@ class AuthViewSet(_AuthSupport, viewsets.GenericViewSet):
|
||||||
)
|
)
|
||||||
except NotFoundError as exc:
|
except NotFoundError as exc:
|
||||||
raise NotFound(str(exc)) from exc
|
raise NotFound(str(exc)) from exc
|
||||||
access, refresh = self._mint_tokens(request.user, tenant)
|
auth = getattr(request, "auth", None)
|
||||||
|
two_factor = (
|
||||||
|
bool(auth.get("2fa"))
|
||||||
|
if auth is not None and hasattr(auth, "get")
|
||||||
|
else bool(getattr(request, "session", None) and request.session.get("_2fa_verified"))
|
||||||
|
)
|
||||||
|
access, refresh = self._mint_tokens(request.user, tenant, two_factor=two_factor)
|
||||||
response = Response({"detail": "Workspace switched.", "tenant": str(tenant.pk)})
|
response = Response({"detail": "Workspace switched.", "tenant": str(tenant.pk)})
|
||||||
self._set_auth_cookies(response, access, refresh)
|
self._set_auth_cookies(response, access, refresh)
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
196
tests/test_gates.py
Normal file
196
tests/test_gates.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
"""Tests for the composable per-endpoint gate layer."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from infrasynth.gates import (
|
||||||
|
AltchaGate,
|
||||||
|
EntitlementGate,
|
||||||
|
FeatureGate,
|
||||||
|
GatePermission,
|
||||||
|
PermissionGate,
|
||||||
|
TwoFactorGate,
|
||||||
|
evaluate_gates,
|
||||||
|
gated,
|
||||||
|
)
|
||||||
|
from infrasynth.shared.exceptions import AppError, AuthError, EntitlementError, NotFoundError
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
class _Anon:
|
||||||
|
is_authenticated = False
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRequest:
|
||||||
|
def __init__(self, user=None, *, auth=None, session=None, data=None, headers=None, query_params=None):
|
||||||
|
self.user = user if user is not None else _Anon()
|
||||||
|
self.auth = auth
|
||||||
|
self.session = session
|
||||||
|
self.data = data or {}
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.query_params = query_params or {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeView:
|
||||||
|
def __init__(self, *gates):
|
||||||
|
self.infrasynth_gates = list(gates)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGatePermission:
|
||||||
|
def test_no_gates_allows(self, user):
|
||||||
|
assert GatePermission().has_permission(FakeRequest(user), FakeView()) is True
|
||||||
|
|
||||||
|
def test_decorator_merges_gates(self):
|
||||||
|
class V:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@gated(FeatureGate("a"))
|
||||||
|
@gated(FeatureGate("b"))
|
||||||
|
def action(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
assert len(action.infrasynth_gates) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestTwoFactorGate:
|
||||||
|
def test_no_config_passes_by_default(self, user):
|
||||||
|
evaluate_gates(FakeRequest(user), FakeView(TwoFactorGate()))
|
||||||
|
|
||||||
|
def test_no_config_required_denies(self, user):
|
||||||
|
with pytest.raises(AuthError) as exc:
|
||||||
|
evaluate_gates(FakeRequest(user), FakeView(TwoFactorGate(require_configured=True)))
|
||||||
|
assert exc.value.code == "AUTH_2FA_SETUP_REQUIRED"
|
||||||
|
|
||||||
|
def test_configured_without_proof_denies(self, user):
|
||||||
|
from infrasynth.security.models import TwoFactorConfig
|
||||||
|
|
||||||
|
TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True, secret_key_encrypted="x")
|
||||||
|
with pytest.raises(AuthError) as exc:
|
||||||
|
evaluate_gates(FakeRequest(user), FakeView(TwoFactorGate()))
|
||||||
|
assert exc.value.code == "AUTH_2FA_REQUIRED"
|
||||||
|
|
||||||
|
def test_configured_with_token_claim_passes(self, user):
|
||||||
|
from infrasynth.security.models import TwoFactorConfig
|
||||||
|
|
||||||
|
TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True, secret_key_encrypted="x")
|
||||||
|
request = FakeRequest(user, auth={"2fa": True})
|
||||||
|
evaluate_gates(request, FakeView(TwoFactorGate()))
|
||||||
|
|
||||||
|
def test_configured_with_session_passes(self, user):
|
||||||
|
from infrasynth.security.models import TwoFactorConfig
|
||||||
|
|
||||||
|
TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True, secret_key_encrypted="x")
|
||||||
|
request = FakeRequest(user, session={"_2fa_verified": True})
|
||||||
|
evaluate_gates(request, FakeView(TwoFactorGate()))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAltchaGate:
|
||||||
|
def test_missing_token_denies(self):
|
||||||
|
with pytest.raises(AppError) as exc:
|
||||||
|
evaluate_gates(FakeRequest(), FakeView(AltchaGate()))
|
||||||
|
assert exc.value.code == "VALIDATION_ALTCHA_REQUIRED"
|
||||||
|
assert exc.value.status == 400
|
||||||
|
|
||||||
|
def test_valid_solution_passes(self):
|
||||||
|
from infrasynth.security.altcha.services import ALTCHAService
|
||||||
|
|
||||||
|
svc = ALTCHAService()
|
||||||
|
challenge = svc.create_challenge()
|
||||||
|
solution, number = svc.compute_solution(challenge["salt"], challenge["difficulty"])
|
||||||
|
request = FakeRequest(
|
||||||
|
data={"altcha": {"challenge_id": challenge["challenge_id"], "solution": solution, "number": number}}
|
||||||
|
)
|
||||||
|
evaluate_gates(request, FakeView(AltchaGate()))
|
||||||
|
|
||||||
|
def test_header_solution_passes(self):
|
||||||
|
from infrasynth.security.altcha.services import ALTCHAService
|
||||||
|
|
||||||
|
svc = ALTCHAService()
|
||||||
|
challenge = svc.create_challenge()
|
||||||
|
solution, number = svc.compute_solution(challenge["salt"], challenge["difficulty"])
|
||||||
|
token = f"{challenge['challenge_id']}:{solution}:{number}"
|
||||||
|
evaluate_gates(FakeRequest(headers={"X-Altcha": token}), FakeView(AltchaGate()))
|
||||||
|
|
||||||
|
def test_bad_solution_denies(self):
|
||||||
|
from infrasynth.security.altcha.services import ALTCHAService
|
||||||
|
|
||||||
|
challenge = ALTCHAService().create_challenge()
|
||||||
|
request = FakeRequest(
|
||||||
|
data={"altcha": {"challenge_id": challenge["challenge_id"], "solution": "deadbeef", "number": 1}}
|
||||||
|
)
|
||||||
|
with pytest.raises(AppError) as exc:
|
||||||
|
evaluate_gates(request, FakeView(AltchaGate()))
|
||||||
|
assert exc.value.code == "VALIDATION_ALTCHA_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntitlementGate:
|
||||||
|
@pytest.fixture
|
||||||
|
def entitled(self, tenant):
|
||||||
|
from infrasynth.billing.models import App, Entitlement, Plan
|
||||||
|
from infrasynth.shared.enums import EntitlementStatus, MonetizationModel
|
||||||
|
|
||||||
|
app = App.objects.create(slug="messenger", name="Messenger", monetization=MonetizationModel.SUBSCRIPTION)
|
||||||
|
plan = Plan.objects.create(app=app, slug="pro", name="Pro", price_amount=0, features={"payouts": True})
|
||||||
|
Entitlement.objects.create(tenant=tenant, app=app, plan=plan, status=EntitlementStatus.ACTIVE)
|
||||||
|
return app
|
||||||
|
|
||||||
|
def test_entitled_passes(self, entitled):
|
||||||
|
evaluate_gates(FakeRequest(), FakeView(EntitlementGate("messenger", feature="payouts")))
|
||||||
|
|
||||||
|
def test_feature_not_in_plan_denies(self, entitled):
|
||||||
|
with pytest.raises(EntitlementError) as exc:
|
||||||
|
evaluate_gates(FakeRequest(), FakeView(EntitlementGate("messenger", feature="broadcast")))
|
||||||
|
assert exc.value.code == "ENTITLEMENT_PLAN_UPGRADE_REQUIRED"
|
||||||
|
assert exc.value.status == 402
|
||||||
|
|
||||||
|
def test_no_entitlement_denies(self, tenant):
|
||||||
|
with pytest.raises(EntitlementError) as exc:
|
||||||
|
evaluate_gates(FakeRequest(), FakeView(EntitlementGate("messenger")))
|
||||||
|
assert exc.value.code == "ENTITLEMENT_APP_NOT_OWNED"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeatureGate:
|
||||||
|
def test_disabled_hides_as_404(self):
|
||||||
|
from infrasynth.features.models import FeatureFlag
|
||||||
|
|
||||||
|
FeatureFlag.objects.create(slug="ticketing", is_active=False)
|
||||||
|
with pytest.raises(NotFoundError) as exc:
|
||||||
|
evaluate_gates(FakeRequest(), FakeView(FeatureGate("ticketing")))
|
||||||
|
assert exc.value.status == 404
|
||||||
|
|
||||||
|
def test_enabled_passes(self):
|
||||||
|
from infrasynth.features.models import FeatureFlag
|
||||||
|
|
||||||
|
FeatureFlag.objects.create(slug="ticketing", is_active=True)
|
||||||
|
evaluate_gates(FakeRequest(), FakeView(FeatureGate("ticketing")))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionGate:
|
||||||
|
def test_missing_permission_denies(self, user):
|
||||||
|
with pytest.raises(AuthError) as exc:
|
||||||
|
evaluate_gates(FakeRequest(user), FakeView(PermissionGate("billing.payout")))
|
||||||
|
assert exc.value.code == "AUTH_FORBIDDEN"
|
||||||
|
|
||||||
|
def test_grant_passes(self, user):
|
||||||
|
from infrasynth.security.models import Grant
|
||||||
|
|
||||||
|
Grant.objects.create(user=user, codename="billing.payout")
|
||||||
|
evaluate_gates(FakeRequest(user), FakeView(PermissionGate("billing.payout")))
|
||||||
|
|
||||||
|
|
||||||
|
class TestHybridPermissionIntegration:
|
||||||
|
def test_kit_permission_evaluates_declared_gates(self, user):
|
||||||
|
from infrasynth.features.models import FeatureFlag
|
||||||
|
from infrasynth.security.permissions import HybridPermission
|
||||||
|
|
||||||
|
FeatureFlag.objects.create(slug="ticketing", is_active=False)
|
||||||
|
view = FakeView(FeatureGate("ticketing"))
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
HybridPermission().has_permission(FakeRequest(user), view)
|
||||||
|
|
||||||
|
def test_kit_permission_allows_when_gate_passes(self, user):
|
||||||
|
from infrasynth.features.models import FeatureFlag
|
||||||
|
from infrasynth.security.permissions import HybridPermission
|
||||||
|
|
||||||
|
FeatureFlag.objects.create(slug="ticketing", is_active=True)
|
||||||
|
assert HybridPermission().has_permission(FakeRequest(user), FakeView(FeatureGate("ticketing"))) is True
|
||||||
|
|
@ -155,6 +155,28 @@ class TestTwoFactorLoginFlow:
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
assert resp.status_code == status.HTTP_200_OK
|
||||||
assert "access_token" in resp.cookies
|
assert "access_token" in resp.cookies
|
||||||
|
|
||||||
|
def test_verify_mints_second_factor_claim(self, db, user):
|
||||||
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
|
|
||||||
|
from infrasynth.shared.crypto import decrypt
|
||||||
|
|
||||||
|
secret = self._enable(user)
|
||||||
|
client = APIClient()
|
||||||
|
client.post("/api/v1/auth/login/", {"username": "testuser", "password": "testpass123"}, format="json")
|
||||||
|
client.post("/api/v1/auth/2fa/verify/", {"code": pyotp.TOTP(secret).now()}, format="json")
|
||||||
|
token = AccessToken(decrypt(client.cookies["access_token"].value))
|
||||||
|
assert token["2fa"] is True
|
||||||
|
|
||||||
|
def test_plain_login_has_no_second_factor_claim(self, db, user):
|
||||||
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
|
|
||||||
|
from infrasynth.shared.crypto import decrypt
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.post("/api/v1/auth/login/", {"username": "testuser", "password": "testpass123"}, format="json")
|
||||||
|
token = AccessToken(decrypt(client.cookies["access_token"].value))
|
||||||
|
assert token["2fa"] is False
|
||||||
|
|
||||||
|
|
||||||
class TestTwoFactorSetup:
|
class TestTwoFactorSetup:
|
||||||
def test_setup_returns_secret_and_qr(self, two_factor_client):
|
def test_setup_returns_secret_and_qr(self, two_factor_client):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue