Files
chatballs/apps/backend/hub_platform/support/session.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

249 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Проверка Product Support Token и старт support-сессии (SPEC-HUB-0011 §12).
Алгоритм start: channel checks → token verify → contract lookup → schema
validation → identity/operator/ai/search extraction → snapshot upsert →
create-or-continue Conversation → audit (success/denied). Raw token нигде
не сохраняется и не логируется — только короткий хэш jti.
"""
from __future__ import annotations
from typing import Any
from django.db import transaction
from django.http import HttpRequest
from hub_platform.conversations.models import (
ControlMode,
Conversation,
ExpectedResponder,
LifecycleState,
)
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.models import AuditResult, Organization
from hub_platform.subscriptions.keys import QuotaKey
from hub_platform.subscriptions.usage_service import record_usage
from hub_platform.support import errors
from hub_platform.support.extract import extract_context, validate_schema
from hub_platform.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
from hub_platform.support.selectors import contract_by_code
from hub_platform.support.token import TokenClaims, claims_datetimes, verify_support_token
from hub_platform.support.widget_credential import issue_widget_credential
from hub_platform.tenancy.context import TenantContext
def _deny_channel_policy(channel) -> errors.SupportSessionError | None:
"""Fail-closed проверки канала (SPEC §5.2)."""
if channel.department_id is None or channel.department.code != "support":
return errors.SupportSessionError(errors.CHANNEL_NOT_SUPPORT)
if channel.product_id is None:
return errors.SupportSessionError(errors.CHANNEL_PRODUCT_MISMATCH)
if channel.requires_authenticated_product_identity and not channel.allow_anonymous_sessions:
return None # корректный support-канал
return errors.SupportSessionError(errors.CHANNEL_NOT_SUPPORT)
def verify_and_resolve(
*, channel, token: str
) -> tuple[TokenClaims, ProductSupportContract, dict[str, Any]]:
"""Шаги 1–13 алгоритма: channel → token → contract → schema → mapping.
Возвращает (claims, contract, extracted) без записи в БД. Raw token не трогаем.
"""
channel_error = _deny_channel_policy(channel)
if channel_error is not None:
raise channel_error
product = channel.product
secret = product.support_token_secret or ""
claims = verify_support_token(token=token, secret=secret)
# iss должен совпадать с кодом продукта канала.
if claims.iss != product.code:
raise errors.SupportSessionError(errors.CHANNEL_PRODUCT_MISMATCH)
context = TenantContext.for_resource(channel.organization)
contract = contract_by_code(context=context, code=claims.contract)
if contract is None:
raise errors.SupportSessionError(errors.CONTRACT_NOT_FOUND)
# DRAFT и DISABLED не принимают production traffic (SPEC-HUB-0011 §3).
if contract.status in {ContractStatus.DRAFT, ContractStatus.DISABLED}:
raise errors.SupportSessionError(errors.CONTRACT_DISABLED)
if not contract.allowed_channels.filter(id=channel.id).exists():
raise errors.SupportSessionError(errors.CONTRACT_CHANNEL_NOT_ALLOWED)
validate_schema(claims.data, contract.schema_json)
extracted = extract_context(claims.data, contract)
return claims, contract, extracted
def start_support_session(
*, channel, token: str, request: HttpRequest | None = None
) -> dict[str, Any]:
"""Создаёт/обновляет snapshot и создаёт/продолжает support Conversation.
При любой ошибке проверки пишет audit (DENIED) с машинным кодом и хэшем jti,
затем поднимает SupportSessionError. Raw token не попадает в audit.
Audit-deny пишется вне write-транзакции: иначе откат при raise уничтожил бы
запись. Успешный путь (snapshot + conversation + audit-success) атомарен.
"""
organization: Organization = channel.organization
try:
claims, contract, extracted = verify_and_resolve(channel=channel, token=token)
except errors.SupportSessionError as error:
_audit_denied(
organization=organization, channel=channel, request=request,
error=error, token=token,
)
raise
return _commit_session(
organization=organization, channel=channel, contract=contract,
claims=claims, extracted=extracted, request=request,
)
@transaction.atomic
def _commit_session(
*, organization, channel, contract, claims, extracted, request
) -> dict[str, Any]:
issued_at, expires_at = claims_datetimes(claims)
snapshot = _upsert_snapshot(
organization=organization,
channel=channel,
contract=contract,
claims=claims,
extracted=extracted,
issued_at=issued_at,
expires_at=expires_at,
)
conversation = _create_or_continue_conversation(
organization=organization, channel=channel, snapshot=snapshot
)
record_audit_event(
action="support.session_started",
result=AuditResult.SUCCESS,
organization=organization,
object_type="Conversation",
object_id=str(conversation.id),
payload={
"contract": contract.code,
"subject_key": extracted["subject_key"],
"product": channel.product.code,
"jti_hash": claims.jti_hash[:16],
},
request=request,
)
widget_credential = issue_widget_credential(
conversation_id=conversation.id, snapshot_id=snapshot.id
)
return {
"conversation": conversation,
"snapshot": snapshot,
"widget_credential": widget_credential,
}
def _audit_denied(
*, organization, channel, request, error: errors.SupportSessionError, token: str
) -> None:
# jti_hash безопасен; iss/contract неизвестны до verify — извлекаем только jti без raw token.
jti_hash = _safe_jti_hash(token)
record_audit_event(
action="support.session_denied",
result=AuditResult.DENIED,
organization=organization,
object_type="Channel",
object_id=str(channel.id),
payload={"code": error.code, "jti_hash": jti_hash},
request=request,
)
def _safe_jti_hash(token: str) -> str:
"""Хэш jti без раскрытия raw token: best-effort извлечение payload для audit."""
try:
parts = token.split(".")
if len(parts) != 3:
return ""
import base64
import hashlib
import json as _json
padding = "=" * (-len(parts[1]) % 4)
payload = _json.loads(base64.urlsafe_b64decode(parts[1] + padding))
jti = payload.get("jti", "")
return hashlib.sha256(str(jti).encode("utf-8")).hexdigest()[:16] if jti else ""
except Exception:
return ""
def _upsert_snapshot(
*, organization, channel, contract, claims, extracted, issued_at, expires_at
) -> SupportIdentitySnapshot:
snapshot, _created = SupportIdentitySnapshot.objects.update_or_create(
product=channel.product,
subject_key=extracted["subject_key"],
defaults={
"organization": organization,
"contract": contract,
"contract_code": contract.code,
"account_key": extracted["account_key"] or None,
"display_name": extracted["display_name"],
"display_email": extracted["display_email"],
"payload_json": claims.data,
"operator_context_json": extracted["operator_context"],
"ai_context_json": extracted["ai_context"],
"search_text": extracted["search_text"],
"token_issued_at": issued_at,
"token_expires_at": expires_at,
"token_jti_hash": claims.jti_hash,
},
)
return snapshot
def _create_or_continue_conversation(*, organization, channel, snapshot) -> Conversation:
conversation = (
Conversation.objects.filter(
channel=channel, support_identity_snapshot=snapshot, lifecycle=LifecycleState.OPEN
)
.order_by("-last_activity_at")
.first()
)
if conversation is not None:
return conversation
previous = (
Conversation.objects.filter(
channel=channel, support_identity_snapshot=snapshot
)
.order_by("-created_at")
.first()
)
conversation = Conversation.objects.create(
organization=channel.organization,
channel=channel,
contact=None,
support_identity_snapshot=snapshot,
control_mode=ControlMode.AI,
expected_responder=ExpectedResponder.AI,
previous_conversation=previous,
)
# C07: a brand-new support dialog counts toward new_dialogs quota.
record_usage(
context=TenantContext.for_resource(organization),
quota_key=QuotaKey.NEW_DIALOGS_PER_PERIOD,
quantity=1,
idempotency_key=f"dialog:{conversation.id}",
source="support.conversation_created",
aggregate_type="Conversation",
aggregate_id=str(conversation.id),
)
return conversation