Files
chatballs/apps/backend/hub_platform/api/permissions.py
T
Andrey a5b6ad2087 🔒 feat(subscriptions): enforce C07 entitlements and quotas across modules
Coverage-matrix operation x entitlement/quota x transport in INVENTORY-CUSTOCRM-0009.

Enforcement infrastructure:
- HasEntitlement DRF permission (org-level feature gate, orthogonal to
  HasCapability); centralized api_exception_handler mapping
  EntitlementRequired->403, QuotaExceeded HARD->409 / RATE+CONCURRENT->429
  (+Retry-After), PolicyUnavailable->503, UsageConflict->409.
- quota_service.check() advisory pre-check.
- UsageReservation lease model + reserve_usage/release_usage/
  expire_stale_reservations for CONCURRENT quotas (SPEC-HUB-0022 section 7);
  worker sweep reaps lapsed leases. QuotaExceeded now carries mode.

Cumulative quota gates (record_usage, mirror ai_agent_slots):
- products (create_product), client_connections (create_integration),
  new_dialogs_per_period (new dialog only, is_new branch in ingest + support),
  managed_ai_credits (successful LlmInvocation, best-effort accounting).

Storage gate: assert_storage_quota pre-write check blocks overage on writes,
reads never gated (PLAN section 12).

Concurrent p2p_calls: reserve on CallSession create, release on terminal
transition in calls/lifecycle.py.

Entitlement gates on views: sales_department, support_department,
product_sales_api (inline), p2p_calls, knowledge_base, managed_ai
(inline handoff on deny).

Deferred (no target module): crm_api, voice_ai, sso_saml,
concurrent_voice_sessions, customer_audit (tracks B/C/C12).

bootstrap_edevs_owner now provisions an active STARTUP subscription
(ensure_default_subscription) since post-C07 every tenant operation requires
a subscription; create_test_subscription reconciles ai_agent_slot counter for
tests that create active agents directly.

Gate (PLAN section 12): coverage-matrix exists; storage overage blocks writes
not reads; downgrade/suspension zero-limit clears entitlements without delete;
Free counts only a brand-new dialog.

Targeted tests: reservations 5, quota_service 4, enforcement mapping 6.
Full backend regression green except one pre-existing SOCKS5 test and one
pre-existing membership-migration ProtectedError (both fail on clean HEAD).
UI unchanged. Production migration/deploy and publication of production
PlanVersion are not part of this commit.
2026-07-16 03:11:55 +03:00

66 lines
2.7 KiB
Python

from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView
from hub_platform.identity.policy import ResourceScope, authorize, has_capability_any_scope
from hub_platform.subscriptions.policy import get_effective_policy
class HasCapability(BasePermission):
"""DRF entry-point guard backed by the shared capability policy.
Views declare ``required_capability`` or a method keyed
``required_capabilities`` mapping. Object/resource scope is still checked by the
view after loading the canonical resource.
"""
message = "Required capability is missing"
def has_permission(self, request: Request, view: APIView) -> bool:
capability = getattr(view, "required_capability", None)
by_method = getattr(view, "required_capabilities", {})
capability = by_method.get(request.method, capability)
if not capability:
return False
context = getattr(request, "tenant_context", None)
if context is None or context.membership is None:
return False
profile = context.membership
if getattr(view, "require_organization_scope", False):
return authorize(
profile,
capability,
ResourceScope(organization_id=profile.organization_id),
)
return has_capability_any_scope(profile, capability)
class HasEntitlement(BasePermission):
"""DRF entry-point guard backed by the organization subscription policy.
Unlike :class:`HasCapability` (per-membership role authorization), an
entitlement is an organization-level feature flag derived from the active
subscription (SPEC-HUB-0022). Views declare ``required_entitlement`` or a
method-keyed ``required_entitlements`` mapping. The check never raises: a
missing entitlement yields a clean 403 via DRF.
"""
message = "Entitlement not available for this organization"
def has_permission(self, request: Request, view: APIView) -> bool:
entitlement = getattr(view, "required_entitlement", None)
by_method = getattr(view, "required_entitlements", {})
entitlement = by_method.get(request.method, entitlement)
if not entitlement:
# No entitlement declared -> this permission is a no-op for the view.
return True
context = getattr(request, "tenant_context", None)
if context is None:
return False
try:
policy = get_effective_policy(context)
except Exception:
# No subscription / inactive subscription -> entitlements unavailable.
return False
return policy.has_entitlement(entitlement)