Close the gaps between the documented contract (API-STANDARD, TENANCY, ENTITLEMENTS) and the implementation, and remove committed build artifacts. Security: - verify + process inbound webhooks (HMAC/handler verify, size limit, timestamp tolerance, idempotency via InboundEvent.external_id) - real 2FA login flow (pre-auth challenge; tokens only after verify/recovery) - wire HybridPermission into security/audit views; add API-key rotate and users/<id>/permissions|roles endpoints - tenant-scoped throttling on by default; webhook replay protection - verify MercadoPago webhook signatures - login brute-force guard, configurable password policy, real ALTCHA PoW Correctness: - apply verified billing webhooks idempotently (subscription/entitlement/ invoice/PaymentTransaction); scheduled payment lifecycle jobs - capture audit update diffs automatically; add audit retention purge - working notification retries, per-channel rate limits, log retention - pluggable virus scanner, upload-size limit, pipeline toggle - feature rollout %/environment targeting; settings-driven registrations - workflow guards (instance cap, route depth, self-assignment, clone on re-entry) - wire every previously-dead INFRASYNTH_* setting; drop truly dead ones Delivery: - README + CHANGELOG; CI format check + coverage gate - keep test media out of the tree; untrack .coverage, __pycache__, egg-info, docs/ and invoice artifacts
137 lines
5.2 KiB
Python
137 lines
5.2 KiB
Python
"""``TenantMiddleware`` — resolves and binds the request tenant (``TENANCY.md`` §3.2)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from django.apps import apps
|
|
|
|
from infrasynth.shared.settings_utils import get_setting
|
|
|
|
from .context import reset_current_tenant, set_current_tenant
|
|
from .models import Tenant, TenantMembership
|
|
|
|
__all__ = ["TenantMiddleware"]
|
|
|
|
_DEFAULT_ALLOWLIST = (
|
|
"/api/v1/auth/login/",
|
|
"/api/v1/auth/refresh/",
|
|
"/api/v1/auth/select-workspace/",
|
|
"/api/v1/auth/altcha/",
|
|
"/api/v1/auth/2fa/",
|
|
"/api/v1/billing/webhook/",
|
|
"/api/v1/schema/",
|
|
"/api/v1/tenancy/accept-invitation/",
|
|
"/healthz",
|
|
"/readyz",
|
|
)
|
|
|
|
|
|
class TenantMiddleware:
|
|
"""Binds ``current_tenant`` from the token claim or a tenant-scoped API key.
|
|
|
|
Runs after authentication. Rejects tenant endpoints with no resolved tenant
|
|
(except the allowlist), and rejects a request whose membership was revoked
|
|
immediately rather than waiting for token expiry.
|
|
"""
|
|
|
|
def __init__(self, get_response):
|
|
self.get_response = get_response
|
|
|
|
def __call__(self, request):
|
|
if not get_setting("INFRASYNTH_TENANCY", "ENABLED", True):
|
|
return self.get_response(request)
|
|
|
|
tenant, error_code = self._resolve(request)
|
|
token = set_current_tenant(tenant)
|
|
request.tenant = tenant
|
|
try:
|
|
if error_code:
|
|
return self._reject(error_code, request)
|
|
if self._should_reject(request, tenant):
|
|
return self._reject("AUTH_TENANT_REQUIRED", request)
|
|
return self.get_response(request)
|
|
finally:
|
|
reset_current_tenant(token)
|
|
|
|
# --- resolution ---------------------------------------------------------
|
|
|
|
def _resolve(self, request) -> tuple[Tenant | None, str | None]:
|
|
claim = get_setting("INFRASYNTH_TENANCY", "TENANT_CLAIM", "tenant")
|
|
tenant_id = None
|
|
|
|
auth = getattr(request, "auth", None)
|
|
if auth is not None and hasattr(auth, "get"):
|
|
tenant_id = auth.get(claim)
|
|
|
|
if tenant_id is None:
|
|
tenant_id = self._tenant_from_api_key(request)
|
|
|
|
user = getattr(request, "user", None)
|
|
is_authenticated = bool(user and getattr(user, "is_authenticated", False))
|
|
|
|
if tenant_id is not None:
|
|
tenant = Tenant.objects.filter(pk=tenant_id).first()
|
|
if tenant is None:
|
|
return None, "AUTH_TENANT_NOT_FOUND"
|
|
if (
|
|
is_authenticated
|
|
and not TenantMembership.objects.filter(tenant=tenant, user=user, is_active=True).exists()
|
|
):
|
|
return None, "AUTH_MEMBERSHIP_REVOKED"
|
|
return tenant, None
|
|
|
|
# Fallback: a user with exactly one active membership is auto-selected
|
|
# (mirrors the login auto-select in TENANCY.md §3.1).
|
|
if is_authenticated:
|
|
memberships = list(
|
|
TenantMembership.objects.filter(user=user, is_active=True, tenant__status__in=["active", "trialing"])
|
|
.select_related("tenant")
|
|
.order_by("joined_at")
|
|
)
|
|
if len(memberships) == 1:
|
|
return memberships[0].tenant, None
|
|
return None, None
|
|
|
|
def _tenant_from_api_key(self, request) -> Any:
|
|
raw_key = request.META.get("HTTP_X_API_KEY")
|
|
if not raw_key or "." not in raw_key:
|
|
return None
|
|
prefix = raw_key.split(".", 1)[0]
|
|
try:
|
|
api_key_model = apps.get_model("infrasynth_security", "APIKey")
|
|
except LookupError:
|
|
return None
|
|
api_key = api_key_model.all_objects.filter(prefix=prefix, is_active=True).first()
|
|
if api_key is None:
|
|
return None
|
|
return api_key.tenant_id
|
|
|
|
# --- rejection ----------------------------------------------------------
|
|
|
|
def _should_reject(self, request, tenant: Tenant | None) -> bool:
|
|
if tenant is not None:
|
|
return False
|
|
if not get_setting("INFRASYNTH_TENANCY", "REQUIRE_TENANT_BY_DEFAULT", True):
|
|
return False
|
|
path = getattr(request, "path", "") or ""
|
|
if not path.startswith("/api/"):
|
|
return False
|
|
allowlist = get_setting("INFRASYNTH_TENANCY", "TENANT_ALLOWLIST_PATHS", None) or _DEFAULT_ALLOWLIST
|
|
if any(path.startswith(prefix) for prefix in allowlist):
|
|
return False
|
|
user = getattr(request, "user", None)
|
|
is_authenticated = bool(user and getattr(user, "is_authenticated", False))
|
|
has_api_key = bool(request.META.get("HTTP_X_API_KEY"))
|
|
# Unauthenticated requests are left to the auth classes (401), not 403'd here.
|
|
return is_authenticated or has_api_key
|
|
|
|
def _reject(self, code: str, request):
|
|
from infrasynth.api.exceptions import error_response
|
|
|
|
messages = {
|
|
"AUTH_TENANT_REQUIRED": "A workspace context is required for this endpoint.",
|
|
"AUTH_MEMBERSHIP_REVOKED": "Your membership in this workspace is no longer active.",
|
|
"AUTH_TENANT_NOT_FOUND": "The workspace could not be resolved.",
|
|
}
|
|
return error_response(code, messages.get(code, "Tenant resolution failed."), status_code=403, request=request)
|