mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-18 14:59:01 +03:00
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.
66 lines
2.7 KiB
Python
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)
|