diff --git a/.env.example b/.env.example index 80d928a..6f87908 100644 --- a/.env.example +++ b/.env.example @@ -41,13 +41,30 @@ HUB_OPENROUTER_API_KEY= INTERNAL_UI_BASE_URL=http://localhost:5173 POSTGRES_DB=edevs_hub -POSTGRES_USER=edevs_hub -POSTGRES_PASSWORD=edevs_hub +POSTGRES_USER=custocrm_bootstrap +POSTGRES_PASSWORD=custocrm_bootstrap +POSTGRES_APP_USER=custocrm_app +POSTGRES_APP_PASSWORD=custocrm_app +POSTGRES_PLATFORM_USER=custocrm_platform +POSTGRES_PLATFORM_PASSWORD=custocrm_platform +POSTGRES_MIGRATION_USER=custocrm_migration +POSTGRES_MIGRATION_PASSWORD=custocrm_migration POSTGRES_HOST=postgres POSTGRES_PORT=5432 REDIS_URL=redis://redis:6379/0 +# Tenant-owned object storage. Production требует S3-compatible backend; +# local/test могут явно использовать filesystem. +HUB_STORAGE_BACKEND=filesystem +# HUB_S3_BUCKET=custocrm +# HUB_S3_ENDPOINT_URL=https://s3.example.invalid +# HUB_S3_REGION=ru-central1 +# HUB_S3_ACCESS_KEY= +# HUB_S3_SECRET_KEY= +# HUB_S3_ADDRESSING_STYLE=path +# HUB_S3_URL_EXPIRY_SECONDS=900 + # P2P calls: invite — 5 минут, access token — 1 час. HUB_CALL_INVITE_TTL_SECONDS=300 HUB_CALL_ACCESS_TTL_SECONDS=3600 diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/hub_backend/settings_base.py index c3feb81..11c6712 100644 --- a/apps/backend/hub_backend/settings_base.py +++ b/apps/backend/hub_backend/settings_base.py @@ -5,6 +5,8 @@ from pathlib import Path from django.core.exceptions import ImproperlyConfigured from hub_backend.settings_env import env_bool, env_list +from hub_backend.settings_database import build_databases +from hub_backend.settings_storage import build_storage_settings BASE_DIR = Path(__file__).resolve().parent.parent @@ -33,6 +35,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "rest_framework", "hub_platform.identity", + "hub_platform.tenancy", "hub_platform.products", "hub_platform.ai", "hub_platform.integrations", @@ -83,17 +86,9 @@ TEMPLATES = [ AUTH_USER_MODEL = "identity.HumanUser" -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("POSTGRES_DB", "edevs_hub"), - "USER": os.environ.get("POSTGRES_USER", "edevs_hub"), - "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "edevs_hub"), - "HOST": os.environ.get("POSTGRES_HOST", "postgres"), - "PORT": os.environ.get("POSTGRES_PORT", "5432"), - "CONN_MAX_AGE": 60, - } -} +# Tests use the disposable cluster owner to create/drop the test database. The +# dedicated RLS suite explicitly SET ROLEs into the non-owner runtime roles. +DATABASES = build_databases(debug=DEBUG, testing=TESTING) CACHES = { "default": { @@ -191,8 +186,12 @@ STATIC_ROOT = BASE_DIR / "staticfiles" # Файловые вложения знаний (ADR-HUB-0023). Файлы отдаются только через # download-endpoint (FileResponse), прямого статик-роутинга MEDIA нет. -MEDIA_ROOT = Path(os.environ.get("HUB_MEDIA_ROOT", BASE_DIR / "media")) MEDIA_URL = "media/" +HUB_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings( + base_dir=BASE_DIR, + debug=DEBUG, + testing=TESTING, +) # Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений). HUB_PUBLIC_BASE_URL = os.environ.get("HUB_PUBLIC_BASE_URL", "http://localhost:8000") @@ -222,18 +221,6 @@ HUB_CALL_TURN_SECRET = os.environ.get("HUB_CALL_TURN_SECRET", "") HUB_CALL_TURN_TTL_SECONDS = int(os.environ.get("HUB_CALL_TURN_TTL_SECONDS", str(60 * 60))) if HUB_CALL_TURN_TTL_SECONDS <= 0: raise ImproperlyConfigured("HUB_CALL_TURN_TTL_SECONDS must be positive") -STORAGES = { - "default": { - "BACKEND": "django.core.files.storage.FileSystemStorage", - }, - "staticfiles": { - # В тестах manifest-хранилище требует прогнанного collectstatic, - # поэтому используем обычное хранилище без манифеста. - "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" - if TESTING - else "whitenoise.storage.CompressedManifestStaticFilesStorage", - }, -} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Лимиты на чувствительные эндпоинты (брутфорс/злоупотребление). В тестах отключены. diff --git a/apps/backend/hub_backend/settings_database.py b/apps/backend/hub_backend/settings_database.py new file mode 100644 index 0000000..cbb57bf --- /dev/null +++ b/apps/backend/hub_backend/settings_database.py @@ -0,0 +1,63 @@ +import os + +from django.core.exceptions import ImproperlyConfigured + + +def _credentials() -> tuple[dict[str, str], dict[str, str]]: + users = { + "app": os.environ.get( + "POSTGRES_APP_USER", os.environ.get("POSTGRES_USER", "edevs_hub") + ), + "platform": os.environ.get( + "POSTGRES_PLATFORM_USER", os.environ.get("POSTGRES_USER", "edevs_hub") + ), + "migration": os.environ.get( + "POSTGRES_MIGRATION_USER", os.environ.get("POSTGRES_USER", "edevs_hub") + ), + } + passwords = { + "app": os.environ.get( + "POSTGRES_APP_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "edevs_hub") + ), + "platform": os.environ.get( + "POSTGRES_PLATFORM_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "edevs_hub") + ), + "migration": os.environ.get( + "POSTGRES_MIGRATION_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "edevs_hub") + ), + } + return users, passwords + + +def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]: + role = os.environ.get("HUB_DB_ROLE", "app").lower() + if role not in {"app", "platform", "migration"}: + raise ImproperlyConfigured("HUB_DB_ROLE must be app, platform or migration") + users, passwords = _credentials() + if not debug and not testing and len(set(users.values())) != 3: + raise ImproperlyConfigured( + "App, platform and migration database users must be distinct" + ) + + def config(selected_role: str) -> dict: + return { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB", "edevs_hub"), + "USER": users[selected_role], + "PASSWORD": passwords[selected_role], + "HOST": os.environ.get("POSTGRES_HOST", "postgres"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + "CONN_MAX_AGE": 60, + } + + databases = { + "default": config("migration" if testing else role), + "platform": config("platform"), + } + if testing: + databases["default"]["USER"] = os.environ.get("POSTGRES_USER", "edevs_hub") + databases["default"]["PASSWORD"] = os.environ.get( + "POSTGRES_PASSWORD", "edevs_hub" + ) + databases["platform"]["TEST"] = {"MIRROR": "default"} + return databases diff --git a/apps/backend/hub_backend/settings_storage.py b/apps/backend/hub_backend/settings_storage.py new file mode 100644 index 0000000..b98368b --- /dev/null +++ b/apps/backend/hub_backend/settings_storage.py @@ -0,0 +1,56 @@ +import os +from pathlib import Path + +from django.core.exceptions import ImproperlyConfigured + + +def build_storage_settings( + *, + base_dir: Path, + debug: bool, + testing: bool, +) -> tuple[str, Path, dict[str, dict]]: + backend = os.environ.get( + "HUB_STORAGE_BACKEND", + "filesystem" if debug or testing else "s3", + ).lower() + if backend not in {"filesystem", "s3"}: + raise ImproperlyConfigured("HUB_STORAGE_BACKEND must be 's3' or 'filesystem'") + if not debug and not testing and backend != "s3": + raise ImproperlyConfigured("Production tenant storage must use the S3 backend") + + default_storage: dict = { + "BACKEND": "hub_platform.tenancy.storage_backends.TenantFileSystemStorage", + } + if backend == "s3": + bucket_name = os.environ.get("HUB_S3_BUCKET", "") + if not bucket_name: + raise ImproperlyConfigured("HUB_S3_BUCKET is required for S3 storage") + default_storage = { + "BACKEND": "hub_platform.tenancy.storage_backends.TenantS3Storage", + "OPTIONS": { + "bucket_name": bucket_name, + "endpoint_url": os.environ.get("HUB_S3_ENDPOINT_URL") or None, + "region_name": os.environ.get("HUB_S3_REGION") or None, + "access_key": os.environ.get("HUB_S3_ACCESS_KEY") or None, + "secret_key": os.environ.get("HUB_S3_SECRET_KEY") or None, + "addressing_style": os.environ.get("HUB_S3_ADDRESSING_STYLE", "path"), + "default_acl": None, + "file_overwrite": False, + "querystring_auth": True, + "querystring_expire": int( + os.environ.get("HUB_S3_URL_EXPIRY_SECONDS", "900") + ), + }, + } + + media_root = Path(os.environ.get("HUB_MEDIA_ROOT", base_dir / "media")) + storages = { + "default": default_storage, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + if testing + else "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, + } + return backend, media_root, storages diff --git a/apps/backend/hub_platform/ai/attachment_views.py b/apps/backend/hub_platform/ai/attachment_views.py new file mode 100644 index 0000000..cbb6d8a --- /dev/null +++ b/apps/backend/hub_platform/ai/attachment_views.py @@ -0,0 +1,38 @@ +from django.http import FileResponse, Http404 +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.views import APIView + +from hub_platform.ai.models import KnowledgeAttachment +from hub_platform.identity.models import Organization +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.ingress import attachment_route + + +class AttachmentDownloadView(APIView): + # Публичная ссылка защищена непредсказуемым UUID и scoped resource lookup. + permission_classes = [AllowAny] + authentication_classes: list = [] + + def get(self, request: Request, public_id) -> FileResponse: + route = attachment_route(str(public_id)) + if route is None: + raise Http404 + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist as error: + raise Http404 from error + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + attachment = KnowledgeAttachment.objects.filter( + id=route.resource_id, + public_id=public_id, + organization=organization, + knowledge__organization=organization, + ).first() + if attachment is None: + raise Http404 + opened_file = attachment.file.open("rb") + original_name = attachment.original_name + return FileResponse(opened_file, as_attachment=True, filename=original_name) diff --git a/apps/backend/hub_platform/ai/indexing.py b/apps/backend/hub_platform/ai/indexing.py index 26eedd7..c5c2a76 100644 --- a/apps/backend/hub_platform/ai/indexing.py +++ b/apps/backend/hub_platform/ai/indexing.py @@ -21,12 +21,23 @@ def reindex_knowledge(knowledge: Knowledge) -> list[KnowledgeFragment]: if not chunks: return [] try: - embeddings = embed_texts(channel=None, texts=chunks, model=settings.HUB_AI_EMBEDDING_MODEL, purpose="knowledge_index") + embeddings = embed_texts( + organization=knowledge.organization, + texts=chunks, + model=settings.HUB_AI_EMBEDDING_MODEL, + purpose="knowledge_index", + ) vectors = [result.vector for result in embeddings] except ProviderError: vectors = [None] * len(chunks) fragments = [ - KnowledgeFragment(knowledge=knowledge, chunk_index=index, content=chunk, embedding=vector) + KnowledgeFragment( + organization=knowledge.organization, + knowledge=knowledge, + chunk_index=index, + content=chunk, + embedding=vector, + ) for index, (chunk, vector) in enumerate(zip(chunks, vectors)) ] return KnowledgeFragment.objects.bulk_create(fragments) diff --git a/apps/backend/hub_platform/ai/invocation.py b/apps/backend/hub_platform/ai/invocation.py index 2aa2a9f..4d4d33e 100644 --- a/apps/backend/hub_platform/ai/invocation.py +++ b/apps/backend/hub_platform/ai/invocation.py @@ -20,6 +20,7 @@ def invoke_chat(*, channel, messages: list[ChatMessage], purpose: str, model: st limits.assert_within_limits(channel, agent) except limits.LimitExceeded as error: LlmInvocation.objects.create( + organization=channel.organization, channel=channel, product=channel.product, purpose=purpose, operation="chat", model=model, status=LlmInvocationStatus.BLOCKED, error=str(error), ) @@ -37,6 +38,7 @@ def invoke_chat(*, channel, messages: list[ChatMessage], purpose: str, model: st ) except ProviderError as error: LlmInvocation.objects.create( + organization=channel.organization, channel=channel, product=channel.product, purpose=purpose, operation="chat", model=model, status=LlmInvocationStatus.ERROR, error=str(error)[:1000], latency_ms=int((time.monotonic() - started) * 1000), @@ -44,6 +46,7 @@ def invoke_chat(*, channel, messages: list[ChatMessage], purpose: str, model: st raise LlmInvocation.objects.create( + organization=channel.organization, channel=channel, product=channel.product, purpose=purpose, operation="chat", model=result.model, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, total_tokens=result.total_tokens, cost_micros=result.cost_micros or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens), @@ -53,7 +56,14 @@ def invoke_chat(*, channel, messages: list[ChatMessage], purpose: str, model: st return result -def embed_texts(*, channel=None, texts: list[str], model: str, purpose: str = "retrieval") -> list[EmbeddingResult]: +def embed_texts( + *, + channel=None, + organization=None, + texts: list[str], + model: str, + purpose: str = "retrieval", +) -> list[EmbeddingResult]: # Знания авторские (не клиентские PII), поэтому redaction не требуется. provider = get_provider() results: list[EmbeddingResult] = call_with_resilience( @@ -63,6 +73,7 @@ def embed_texts(*, channel=None, texts: list[str], model: str, purpose: str = "r ) tokens = sum(result.tokens for result in results) LlmInvocation.objects.create( + organization=channel.organization if channel else organization, channel=channel, product=(channel.product if channel else None), purpose=purpose, operation="embedding", model=model, prompt_tokens=tokens, total_tokens=tokens, cost_micros=pricing.cost_micros(model, tokens, 0), status=LlmInvocationStatus.SUCCESS, diff --git a/apps/backend/hub_platform/ai/migrations/0005_aiagent_organization_and_more.py b/apps/backend/hub_platform/ai/migrations/0005_aiagent_organization_and_more.py new file mode 100644 index 0000000..748727b --- /dev/null +++ b/apps/backend/hub_platform/ai/migrations/0005_aiagent_organization_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai', '0004_drop_documents_and_releases'), + ('identity', '0013_accessprofilecapability_organization_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='aiagent', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='knowledgeattachment', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='knowledgefragment', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='llminvocation', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/ai/migrations/0006_alter_aiagent_organization_and_more.py b/apps/backend/hub_platform/ai/migrations/0006_alter_aiagent_organization_and_more.py new file mode 100644 index 0000000..3a66e6f --- /dev/null +++ b/apps/backend/hub_platform/ai/migrations/0006_alter_aiagent_organization_and_more.py @@ -0,0 +1,80 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai', '0005_aiagent_organization_and_more'), + ('identity', '0014_alter_accessprofilecapability_organization_and_more'), + ] + + operations = [ + migrations.RunSQL( + sql=""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM ai_llminvocation invocation + LEFT JOIN channels_channel channel ON channel.id = invocation.channel_id + LEFT JOIN identity_product product ON product.id = invocation.product_id + WHERE (channel.id IS NULL AND product.id IS NULL) + OR (channel.id IS NOT NULL AND product.id IS NOT NULL + AND channel.organization_id <> product.organization_id) + ) THEN + RAISE EXCEPTION 'C04 preflight: ambiguous or cross-tenant LLM invocation exists'; + END IF; + END $$; + + UPDATE ai_aiagent agent + SET organization_id = channel.organization_id + FROM channels_channel channel + WHERE agent.channel_id = channel.id; + + UPDATE ai_knowledgeattachment attachment + SET organization_id = knowledge.organization_id + FROM ai_knowledge knowledge + WHERE attachment.knowledge_id = knowledge.id; + + UPDATE ai_knowledgefragment fragment + SET organization_id = knowledge.organization_id + FROM ai_knowledge knowledge + WHERE fragment.knowledge_id = knowledge.id; + + UPDATE ai_llminvocation invocation + SET organization_id = channel.organization_id + FROM channels_channel channel + WHERE channel.id = invocation.channel_id; + + UPDATE ai_llminvocation invocation + SET organization_id = product.organization_id + FROM identity_product product + WHERE invocation.organization_id IS NULL + AND product.id = invocation.product_id; + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AlterField( + model_name='aiagent', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='knowledgeattachment', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='knowledgefragment', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='llminvocation', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/ai/models.py b/apps/backend/hub_platform/ai/models.py index 4752937..fb0c7bf 100644 --- a/apps/backend/hub_platform/ai/models.py +++ b/apps/backend/hub_platform/ai/models.py @@ -3,6 +3,8 @@ import uuid from django.db import models from pgvector.django import VectorField +from hub_platform.tenancy.models import TenantRelationModel + # Один основной агент на канал обработки (ADR-HUB-0019, ADR-HUB-0023). DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6" @@ -28,10 +30,15 @@ class Knowledge(models.Model): def attachment_upload_path(instance: "KnowledgeAttachment", filename: str) -> str: - return f"knowledge/{instance.knowledge_id}/{instance.public_id}/{filename}" + organization = instance.knowledge.organization + return ( + f"organizations/{organization.public_id}/knowledge/" + f"{instance.knowledge_id}/{instance.public_id}/{filename}" + ) -class KnowledgeAttachment(models.Model): +class KnowledgeAttachment(TenantRelationModel): + tenant_relation_fields = ("knowledge",) knowledge = models.ForeignKey(Knowledge, on_delete=models.CASCADE, related_name="attachments") # Непредсказуемый идентификатор публичной ссылки скачивания (ADR-HUB-0023): # агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub. @@ -63,7 +70,8 @@ class KnowledgeAttachment(models.Model): return settings.HUB_PUBLIC_BASE_URL.rstrip("/") + path -class KnowledgeFragment(models.Model): +class KnowledgeFragment(TenantRelationModel): + tenant_relation_fields = ("knowledge",) # Чанк знания + его эмбеддинг (pgvector). ADR-HUB-0016. Перестраивается при # каждом изменении содержимого или вложений знания. knowledge = models.ForeignKey(Knowledge, on_delete=models.CASCADE, related_name="fragments") @@ -84,7 +92,8 @@ class KnowledgeFragment(models.Model): # --- Агент канала: одна сущность, без релизов (ADR-HUB-0023) --- -class AIAgent(models.Model): +class AIAgent(TenantRelationModel): + tenant_relation_fields = ("channel",) channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent") name = models.CharField(max_length=255) is_active = models.BooleanField(default=True) @@ -115,7 +124,8 @@ class LlmInvocationStatus(models.TextChoices): BLOCKED = "BLOCKED", "Заблокировано лимитом" -class LlmInvocation(models.Model): +class LlmInvocation(TenantRelationModel): + tenant_relation_fields = ("channel", "product") # Учёт по каналу (ADR-HUB-0019) и/или продукту, если канал продуктовый. channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations") product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="ai_invocations", null=True, blank=True) diff --git a/apps/backend/hub_platform/ai/provider/base.py b/apps/backend/hub_platform/ai/provider/base.py index 5c19c9d..615ac6a 100644 --- a/apps/backend/hub_platform/ai/provider/base.py +++ b/apps/backend/hub_platform/ai/provider/base.py @@ -1,7 +1,7 @@ from __future__ import annotations import abc -from dataclasses import dataclass, field +from dataclasses import dataclass @dataclass(frozen=True) diff --git a/apps/backend/hub_platform/ai/public_urls.py b/apps/backend/hub_platform/ai/public_urls.py index 3070336..62588fd 100644 --- a/apps/backend/hub_platform/ai/public_urls.py +++ b/apps/backend/hub_platform/ai/public_urls.py @@ -1,7 +1,7 @@ from django.urls import path -from hub_platform.ai import views +from hub_platform.ai.attachment_views import AttachmentDownloadView urlpatterns = [ - path("files//", views.AttachmentDownloadView.as_view(), name="ai-attachment-download"), + path("files//", AttachmentDownloadView.as_view(), name="ai-attachment-download"), ] diff --git a/apps/backend/hub_platform/ai/services.py b/apps/backend/hub_platform/ai/services.py index a6d98e6..b27ed31 100644 --- a/apps/backend/hub_platform/ai/services.py +++ b/apps/backend/hub_platform/ai/services.py @@ -9,6 +9,7 @@ from hub_platform.ai.indexing import reindex_knowledge from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment from hub_platform.channels.models import Channel from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.storage import adjust_storage_usage @dataclass(frozen=True) @@ -155,9 +156,13 @@ def delete_knowledge(*, context: TenantContext, knowledge: Knowledge) -> None: if knowledge.organization_id != context.organization_id: raise ValidationError({"knowledge": "Knowledge belongs to another organization"}) # Файлы вложений удаляются вместе со знанием: сначала с диска, потом запись. - for attachment in knowledge.attachments.all(): + attachments = list(knowledge.attachments.all()) + released_bytes = sum(attachment.size for attachment in attachments) + for attachment in attachments: attachment.file.delete(save=False) knowledge.delete() + if released_bytes: + adjust_storage_usage(context=context, delta_bytes=-released_bytes) _MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 @@ -177,11 +182,15 @@ def add_attachment( # Повторная загрузка с тем же именем заменяет файл (ADR-HUB-0023: без версий). existing = knowledge.attachments.filter(original_name=original_name).first() if existing is not None: + existing_size = existing.size existing.file.delete(save=False) existing.delete() + if existing_size: + adjust_storage_usage(context=context, delta_bytes=-existing_size) data = upload.read() content_type = upload.content_type or "" attachment = KnowledgeAttachment( + organization=context.organization, knowledge=knowledge, original_name=original_name, content_type=content_type, @@ -191,6 +200,8 @@ def add_attachment( from django.core.files.base import ContentFile attachment.file.save(original_name, ContentFile(data), save=True) + if attachment.size: + adjust_storage_usage(context=context, delta_bytes=attachment.size) reindex_knowledge(knowledge) return attachment @@ -199,6 +210,9 @@ def delete_attachment(*, context: TenantContext, attachment: KnowledgeAttachment if attachment.knowledge.organization_id != context.organization_id: raise ValidationError({"attachment": "Attachment belongs to another organization"}) knowledge = attachment.knowledge + released_bytes = attachment.size attachment.file.delete(save=False) attachment.delete() + if released_bytes: + adjust_storage_usage(context=context, delta_bytes=-released_bytes) reindex_knowledge(knowledge) diff --git a/apps/backend/hub_platform/ai/test_storage_isolation.py b/apps/backend/hub_platform/ai/test_storage_isolation.py new file mode 100644 index 0000000..0eec1a8 --- /dev/null +++ b/apps/backend/hub_platform/ai/test_storage_isolation.py @@ -0,0 +1,78 @@ +import tempfile + +from django.core.exceptions import SuspiciousFileOperation +from django.core.files.storage import default_storage +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings + +from hub_platform.ai.models import Knowledge +from hub_platform.identity.bootstrap import bootstrap_edevs_owner +from hub_platform.identity.models import Organization +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.models import OrganizationStorageUsage +from hub_platform.testing import TenantAPIClient, system_tenant_context + +_MEDIA_ROOT = tempfile.mkdtemp(prefix="c04-storage-isolation-") + + +@override_settings(MEDIA_ROOT=_MEDIA_ROOT) +class TenantStorageIsolationTests(TestCase): + def setUp(self) -> None: + bootstrap_edevs_owner( + email="owner@edevs.tech", + password="temporary-password", + ) + self.organization = Organization.objects.get(slug="edevs") + self.knowledge = Knowledge.objects.create( + organization=self.organization, + title="Storage isolation", + ) + self.client = TenantAPIClient() + self.client.login( + username="owner@edevs.tech", + password="temporary-password", + ) + + def _upload(self): + return self.client.post( + f"/api/v1/ai/knowledge/{self.knowledge.id}/attachments/", + data={"file": SimpleUploadedFile("price.txt", b"tenant bytes")}, + format="multipart", + ) + + def test_upload_uses_prefix_quota_and_matching_context(self) -> None: + self.assertEqual(self._upload().status_code, 201) + attachment = self.knowledge.attachments.get() + self.assertTrue( + attachment.file.name.startswith( + f"organizations/{self.organization.public_id}/" + ) + ) + self.assertEqual( + OrganizationStorageUsage.objects.get( + organization=self.organization + ).bytes_used, + len(b"tenant bytes"), + ) + + other = Organization.objects.create(name="Other", slug="storage-other") + with self.assertRaises(SuspiciousFileOperation): + default_storage.exists(attachment.file.name) + with tenant_atomic(system_tenant_context(other)): + with self.assertRaises(SuspiciousFileOperation): + default_storage.exists(attachment.file.name) + with tenant_atomic(system_tenant_context(self.organization)): + self.assertTrue(default_storage.exists(attachment.file.name)) + + def test_delete_releases_storage_usage(self) -> None: + attachment_id = self._upload().json()["attachment"]["id"] + response = self.client.delete( + f"/api/v1/ai/knowledge/{self.knowledge.id}/attachments/{attachment_id}/" + ) + self.assertEqual(response.status_code, 204) + self.assertEqual( + OrganizationStorageUsage.objects.get( + organization=self.organization + ).bytes_used, + 0, + ) diff --git a/apps/backend/hub_platform/ai/views.py b/apps/backend/hub_platform/ai/views.py index 7f8788f..c9f5f1a 100644 --- a/apps/backend/hub_platform/ai/views.py +++ b/apps/backend/hub_platform/ai/views.py @@ -1,12 +1,10 @@ from django.core.exceptions import ValidationError -from django.http import FileResponse, Http404 from rest_framework.parsers import FormParser, JSONParser, MultiPartParser -from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment +from hub_platform.ai.models import AIAgent, Knowledge from hub_platform.ai.selectors import ( agent_for_context, agents_for_context, @@ -29,7 +27,6 @@ from hub_platform.ai.services import ( ) from hub_platform.api.permissions import HasCapability from hub_platform.identity.audit import record_audit_event -from hub_platform.tenancy.context import TenantContext def _agent_input(body: dict[str, object], *, current: AIAgent) -> AgentInput: @@ -356,21 +353,3 @@ class KnowledgeAttachmentDeleteView(_KnowledgeBaseView): delete_attachment(context=request.tenant_context, attachment=attachment) self._audit(request, "attachment_deleted", knowledge) return Response(status=204) - - -class AttachmentDownloadView(APIView): - # Публичная ссылка (ADR-HUB-0023): уходит клиентам в мессенджеры, где нет - # аутентификации Hub. Защита — непредсказуемый UUID. - permission_classes = [AllowAny] - authentication_classes: list = [] - - def get(self, request: Request, public_id) -> FileResponse: - attachment = KnowledgeAttachment.objects.select_related( - "knowledge__organization" - ).filter(public_id=public_id).first() - if attachment is None: - raise Http404 - context = TenantContext.for_resource(attachment.knowledge.organization) - if attachment.knowledge.organization_id != context.organization_id: - raise Http404 - return FileResponse(attachment.file.open("rb"), as_attachment=True, filename=attachment.original_name) diff --git a/apps/backend/hub_platform/calls/consumers.py b/apps/backend/hub_platform/calls/consumers.py index 16059c9..3ed6536 100644 --- a/apps/backend/hub_platform/calls/consumers.py +++ b/apps/backend/hub_platform/calls/consumers.py @@ -20,6 +20,7 @@ from hub_platform.calls.models import TERMINAL_CALL_STATUSES from hub_platform.calls.permissions import staff_call_access_valid from hub_platform.calls.services import authorize_call_access_context from hub_platform.calls.models import ParticipantSide +from hub_platform.tenancy.database import run_tenant_operation logger = logging.getLogger(__name__) @@ -43,9 +44,13 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer): if self.call_id is None: await self._authenticate(msg_type, content) return - if self.side == ParticipantSide.STAFF and not await database_sync_to_async( - staff_call_access_valid - )(context=self.tenant_context, call_session_id=self.call_id): + if self.side == ParticipantSide.STAFF and not await self._tenant_db( + lambda context, call_id: staff_call_access_valid( + context=context, + call_session_id=call_id, + ), + self.call_id, + ): await self.send_json({"type": "error", "code": "ACCESS_REVOKED"}) await self.close(code=4403) return @@ -65,12 +70,12 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer): await self._connection_state(content) elif msg_type == "participant.metrics": # Технические метрики без медиаконтента: сохраняем, не ретранслируем. - await database_sync_to_async(signaling.record_metric)( - self.tenant_context, self.call_id, self.side, content - ) + await self._tenant_db(signaling.record_metric, self.call_id, self.side, content) elif msg_type == "call.ended": - payload = await database_sync_to_async(signaling.end_from_signaling)( - self.tenant_context, self.call_id, self.side + payload = await self._tenant_db( + signaling.end_from_signaling, + self.call_id, + self.side, ) await self._broadcast({"type": "call.state", "call": payload}, include_self=True) # незнакомые типы игнорируются без разрыва соединения @@ -79,9 +84,7 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer): if self.group is None: return await self.channel_layer.group_discard(self.group, self.channel_name) - payload = await database_sync_to_async(signaling.signaling_leave)( - self.tenant_context, self.call_id, self.side - ) + payload = await self._tenant_db(signaling.signaling_leave, self.call_id, self.side) if payload is not None: await self._broadcast( {"type": "participant.connection_state", "side": self.side, "state": "DISCONNECTED"}, @@ -107,9 +110,7 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer): self.tenant_context = context self.group = f"call.{call.id}" await self.channel_layer.group_add(self.group, self.channel_name) - payload = await database_sync_to_async(signaling.signaling_join)( - self.tenant_context, self.call_id, self.side - ) + payload = await self._tenant_db(signaling.signaling_join, self.call_id, self.side) await self.send_json({"type": "call.state", "call": payload}) await self._broadcast({"type": "peer.joined", "side": self.side}) logger.info("call signaling joined: call=%s side=%s", self.call_id, self.side) @@ -117,23 +118,22 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer): async def _relay(self, msg_type: str, content: dict) -> None: # Сервер валидирует состояние CallSession перед передачей (SPEC §9); # payload не логируется — там SDP/ICE. - status = await database_sync_to_async(signaling.call_status)( - self.tenant_context, self.call_id - ) + status = await self._tenant_db(signaling.call_status, self.call_id) if status in TERMINAL_CALL_STATUSES: return if msg_type == "webrtc.offer": - changed = await database_sync_to_async(signaling.start_negotiation)( - self.tenant_context, self.call_id - ) + changed = await self._tenant_db(signaling.start_negotiation, self.call_id) if changed is not None: await self._broadcast({"type": "call.state", "call": changed}, include_self=True) await self._broadcast({**content, "side": self.side}) async def _connection_state(self, content: dict) -> None: connected = content.get("state") == "CONNECTED" - payload, became_active = await database_sync_to_async(signaling.report_connection)( - self.tenant_context, self.call_id, self.side, connected + payload, became_active = await self._tenant_db( + signaling.report_connection, + self.call_id, + self.side, + connected, ) await self._broadcast( {"type": "participant.connection_state", "side": self.side, "state": str(content.get("state", ""))[:16]}, @@ -149,6 +149,13 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer): {"type": "call.message", "payload": payload, "sender": self.channel_name, "include_self": include_self}, ) + async def _tenant_db(self, operation, *args): + return await database_sync_to_async(run_tenant_operation)( + self.tenant_context, + operation, + *args, + ) + async def call_message(self, event: dict) -> None: if not event.get("include_self") and event.get("sender") == self.channel_name: return diff --git a/apps/backend/hub_platform/calls/metrics.py b/apps/backend/hub_platform/calls/metrics.py new file mode 100644 index 0000000..0ddd43f --- /dev/null +++ b/apps/backend/hub_platform/calls/metrics.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from hub_platform.calls.models import ( + CallConnectionType, + CallMetric, + CallSession, + ParticipantSide, +) +from hub_platform.tenancy.context import TenantContext + +_ALLOWED_CANDIDATE_TYPES = {"host", "srflx", "prflx", "relay"} +_MAX_ROUND_TRIP_MS = 60_000 + + +def _sanitize_candidate_type(value) -> str: + text = str(value or "").lower() + return text if text in _ALLOWED_CANDIDATE_TYPES else "" + + +def _connection_type(local: str, remote: str) -> str: + if "relay" in (local, remote): + return CallConnectionType.RELAY + if local or remote: + return CallConnectionType.DIRECT + return CallConnectionType.UNKNOWN + + +def record_call_metric( + *, + context: TenantContext, + call_session_id, + side: str, + local_candidate_type=None, + remote_candidate_type=None, + round_trip_ms=None, +) -> None: + """Persist derived connection metrics without SDP, ICE addresses or media.""" + + if side not in ParticipantSide.values: + return + if not CallSession.objects.filter( + id=call_session_id, + organization=context.organization, + ).exists(): + return + local = _sanitize_candidate_type(local_candidate_type) + remote = _sanitize_candidate_type(remote_candidate_type) + rtt: int | None = None + if isinstance(round_trip_ms, (int, float)) and not isinstance(round_trip_ms, bool): + rtt = max(0, min(_MAX_ROUND_TRIP_MS, int(round_trip_ms))) + CallMetric.objects.update_or_create( + call_session_id=call_session_id, + side=side, + defaults={ + "organization": context.organization, + "connection_type": _connection_type(local, remote), + "local_candidate_type": local, + "remote_candidate_type": remote, + "round_trip_ms": rtt, + }, + ) diff --git a/apps/backend/hub_platform/calls/migrations/0003_callinvite_organization_callmetric_organization_and_more.py b/apps/backend/hub_platform/calls/migrations/0003_callinvite_organization_callmetric_organization_and_more.py new file mode 100644 index 0000000..3f3b232 --- /dev/null +++ b/apps/backend/hub_platform/calls/migrations/0003_callinvite_organization_callmetric_organization_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calls', '0002_callmetric'), + ('identity', '0013_accessprofilecapability_organization_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='callinvite', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='callmetric', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='callparticipant', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/calls/migrations/0004_alter_callinvite_organization_and_more.py b/apps/backend/hub_platform/calls/migrations/0004_alter_callinvite_organization_and_more.py new file mode 100644 index 0000000..059057f --- /dev/null +++ b/apps/backend/hub_platform/calls/migrations/0004_alter_callinvite_organization_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calls', '0003_callinvite_organization_callmetric_organization_and_more'), + ('identity', '0014_alter_accessprofilecapability_organization_and_more'), + ] + + operations = [ + migrations.RunSQL( + sql=""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM calls_callinvite invite + JOIN calls_callsession call ON call.id = invite.call_session_id + JOIN conversations_connectionidentity identity ON identity.id = invite.connection_identity_id + JOIN conversations_contact contact ON contact.id = identity.contact_id + WHERE call.organization_id <> contact.organization_id + ) OR EXISTS ( + SELECT 1 + FROM calls_callparticipant participant + JOIN calls_callsession call ON call.id = participant.call_session_id + JOIN conversations_connectionidentity identity ON identity.id = participant.connection_identity_id + JOIN conversations_contact contact ON contact.id = identity.contact_id + WHERE participant.connection_identity_id IS NOT NULL + AND call.organization_id <> contact.organization_id + ) THEN + RAISE EXCEPTION 'C04 preflight: cross-tenant call relation exists'; + END IF; + END $$; + + UPDATE calls_callinvite invite + SET organization_id = call.organization_id + FROM calls_callsession call + WHERE invite.call_session_id = call.id; + + UPDATE calls_callparticipant participant + SET organization_id = call.organization_id + FROM calls_callsession call + WHERE participant.call_session_id = call.id; + + UPDATE calls_callmetric metric + SET organization_id = call.organization_id + FROM calls_callsession call + WHERE metric.call_session_id = call.id; + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AlterField( + model_name='callinvite', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='callmetric', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='callparticipant', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/calls/models.py b/apps/backend/hub_platform/calls/models.py index caec617..3a190a8 100644 --- a/apps/backend/hub_platform/calls/models.py +++ b/apps/backend/hub_platform/calls/models.py @@ -3,6 +3,8 @@ import uuid from django.conf import settings from django.db import models +from hub_platform.tenancy.models import TenantRelationModel + class CallStatus(models.TextChoices): REQUESTED = "REQUESTED", "Запрошен" @@ -115,7 +117,8 @@ class CallSession(models.Model): return f"call:{self.id}/{self.status}" -class CallInvite(models.Model): +class CallInvite(TenantRelationModel): + tenant_relation_fields = ("call_session", "connection_identity") id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) call_session = models.OneToOneField(CallSession, on_delete=models.CASCADE, related_name="invite") connection_identity = models.ForeignKey( @@ -138,7 +141,8 @@ class CallInvite(models.Model): return f"invite:{self.id}/{self.delivery_status}" -class CallParticipant(models.Model): +class CallParticipant(TenantRelationModel): + tenant_relation_fields = ("call_session", "connection_identity") call_session = models.ForeignKey(CallSession, on_delete=models.CASCADE, related_name="participants") side = models.CharField(max_length=16, choices=ParticipantSide.choices) user = models.ForeignKey( @@ -179,7 +183,7 @@ class CallParticipant(models.Model): return f"participant:{self.call_session_id}/{self.side}" -class CallMetric(models.Model): +class CallMetric(TenantRelationModel): """Технические метрики соединения без медиаконтента (SPEC-HUB-0013 §13). Хранится только КАТЕГОРИЯ ICE-кандидата (host/srflx/prflx/relay) и RTT, но @@ -188,6 +192,8 @@ class CallMetric(models.Model): сетевые адреса участников. """ + tenant_relation_fields = ("call_session",) + call_session = models.ForeignKey(CallSession, on_delete=models.CASCADE, related_name="metrics") side = models.CharField(max_length=16, choices=ParticipantSide.choices) connection_type = models.CharField( diff --git a/apps/backend/hub_platform/calls/public_access.py b/apps/backend/hub_platform/calls/public_access.py new file mode 100644 index 0000000..c0f931a --- /dev/null +++ b/apps/backend/hub_platform/calls/public_access.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from django.utils import timezone + +from hub_platform.calls.errors import ( + CallAccessDenied, + CallConflict, + CallInvalidTransition, + CallTokenError, +) +from hub_platform.calls.lifecycle import transition_call +from hub_platform.calls.models import ( + TERMINAL_CALL_STATUSES, + CallEndedBy, + CallInvite, + CallSession, + CallStatus, + ParticipantSide, +) +from hub_platform.calls.permissions import ensure_call_access +from hub_platform.calls.tokens import ( + CallAccessClaims, + hash_invite_token, + issue_call_access_token, + verify_call_access_token, +) +from hub_platform.identity.models import Organization, OrganizationMembership +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.ingress import call_invite_route, call_session_route + + +@dataclass(frozen=True) +class ResolvedInvite: + invite: CallInvite + customer_access_token: str + + +def resolve_invite(*, token: str) -> ResolvedInvite: + message = "Недействительное или истёкшее приглашение" + token_hash = hash_invite_token(token) + route = call_invite_route(token_hash) + if route is None: + raise CallTokenError(message) + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist: + raise CallTokenError(message) from None + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + invite = ( + CallInvite.objects.select_for_update() + .select_related( + "call_session", + "call_session__initiated_by", + "connection_identity", + ) + .filter( + id=route.resource_id, + organization=organization, + token_hash=token_hash, + ) + .first() + ) + now = timezone.now() + if ( + invite is None + or invite.expires_at <= now + or invite.opened_at is not None + or invite.call_session.status in TERMINAL_CALL_STATUSES + ): + raise CallTokenError(message) + invite.opened_at = now + invite.save(update_fields=["opened_at"]) + access_token = issue_call_access_token( + call_session_id=invite.call_session_id, + side=ParticipantSide.CUSTOMER, + subject_id=str(invite.id), + ) + return ResolvedInvite(invite=invite, customer_access_token=access_token) + + +def _authorize_call_access( + *, + token: str, + allow_terminal: bool = False, +) -> tuple[CallAccessClaims, CallSession, TenantContext]: + claims = verify_call_access_token(token) + route = call_session_route(str(claims.call_session_id)) + if route is None: + raise CallTokenError("Недействительный или истёкший call access token") + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist: + raise CallTokenError("Недействительный или истёкший call access token") from None + resource_context = TenantContext.for_resource(organization) + with tenant_atomic(resource_context): + try: + call = CallSession.objects.select_related( + "conversation", "conversation__channel", "initiated_by", "organization" + ).get(id=claims.call_session_id, organization=organization) + except CallSession.DoesNotExist: + raise CallTokenError("Недействительный или истёкший call access token") from None + if call.status in TERMINAL_CALL_STATUSES and not allow_terminal: + raise CallTokenError("Звонок уже завершён") + membership = None + if claims.side == ParticipantSide.STAFF: + participant = call.participants.select_related("user").filter( + side=ParticipantSide.STAFF, + user_id=claims.subject_id, + ).first() + valid = participant is not None + if participant is not None: + membership = OrganizationMembership.objects.select_related("user").filter( + user=participant.user, + organization=organization, + blocked_at__isnull=True, + ).first() + try: + ensure_call_access(user=membership, call_session=call) + except CallAccessDenied: + valid = False + else: + invite = CallInvite.objects.filter( + id=claims.subject_id, + call_session=call, + organization=organization, + ).first() + valid = invite is not None and ( + invite.expires_at > timezone.now() + or call.status not in {CallStatus.REQUESTED, CallStatus.RINGING} + ) + if not valid: + raise CallTokenError("Недействительный или истёкший call access token") + context = ( + TenantContext.for_membership(membership) + if membership is not None + else resource_context + ) + return claims, call, context + + +def authorize_call_access_token( + *, + token: str, + allow_terminal: bool = False, +) -> tuple[CallAccessClaims, CallSession]: + claims, call, _context = _authorize_call_access( + token=token, + allow_terminal=allow_terminal, + ) + return claims, call + + +def authorize_call_access_context( + *, token: str, allow_terminal: bool = False +) -> tuple[CallAccessClaims, CallSession, TenantContext]: + return _authorize_call_access(token=token, allow_terminal=allow_terminal) + + +def _customer_call( + *, + token: str, + allow_terminal: bool = False, +) -> tuple[CallSession, TenantContext]: + claims, call, context = _authorize_call_access( + token=token, + allow_terminal=allow_terminal, + ) + if claims.side != ParticipantSide.CUSTOMER: + raise CallTokenError("Недействительный или истёкший call access token") + return call, context + + +def accept_call_by_access_token(*, token: str) -> CallSession: + call, context = _customer_call(token=token) + with tenant_atomic(context): + try: + if call.status == CallStatus.REQUESTED: + call = transition_call(call_session_id=call.id, target_status=CallStatus.RINGING) + transition_call(call_session_id=call.id, target_status=CallStatus.ACCEPTED) + return CallSession.objects.select_related("initiated_by").get( + id=call.id, + organization=context.organization, + ) + except CallInvalidTransition as error: + raise CallConflict("Приглашение уже нельзя принять") from error + + +def decline_call_by_access_token(*, token: str) -> CallSession: + call, context = _customer_call(token=token, allow_terminal=True) + with tenant_atomic(context): + if call.status == CallStatus.DECLINED: + return call + try: + transition_call( + call_session_id=call.id, + target_status=CallStatus.DECLINED, + ended_by=CallEndedBy.CUSTOMER, + ) + return CallSession.objects.select_related("initiated_by").get( + id=call.id, + organization=context.organization, + ) + except CallInvalidTransition as error: + raise CallConflict("Приглашение уже нельзя отклонить") from error + + +def call_state_by_access_token(*, token: str) -> CallSession: + _claims, call, context = _authorize_call_access(token=token, allow_terminal=True) + with tenant_atomic(context): + return CallSession.objects.select_related("initiated_by").get( + id=call.id, + organization=context.organization, + ) diff --git a/apps/backend/hub_platform/calls/services.py b/apps/backend/hub_platform/calls/services.py index 3954cdc..91f55a7 100644 --- a/apps/backend/hub_platform/calls/services.py +++ b/apps/backend/hub_platform/calls/services.py @@ -15,10 +15,8 @@ from hub_platform.calls.errors import ( ) from hub_platform.calls.lifecycle import transition_call from hub_platform.calls.models import ( - CallConnectionType, CallEndedBy, CallInvite, - CallMetric, CallParticipant, CallSession, CallStatus, @@ -27,13 +25,20 @@ from hub_platform.calls.models import ( TERMINAL_CALL_STATUSES, UNFINISHED_CALL_STATUSES, ) +from hub_platform.calls.metrics import record_call_metric from hub_platform.calls.permissions import ensure_call_access, ensure_conversation_call_access +from hub_platform.calls.public_access import ( + ResolvedInvite, + accept_call_by_access_token, + authorize_call_access_context, + authorize_call_access_token, + call_state_by_access_token, + decline_call_by_access_token, + resolve_invite, +) from hub_platform.calls.tokens import ( - CallAccessClaims, - hash_invite_token, issue_call_access_token, issue_invite_token, - verify_call_access_token, ) from hub_platform.conversations.models import ( ConnectionIdentity, @@ -46,9 +51,18 @@ from hub_platform.conversations.models import ( from hub_platform.conversations.services import ClaimError, claim_locked_conversation from hub_platform.events.services import DomainEvent, enqueue_event from hub_platform.integrations.models import IntegrationProvider -from hub_platform.identity.models import OrganizationMembership from hub_platform.tenancy.context import TenantContext +__all__ = ( + "accept_call_by_access_token", + "authorize_call_access_context", + "authorize_call_access_token", + "call_state_by_access_token", + "decline_call_by_access_token", + "resolve_invite", + "record_call_metric", +) + # Outbox-событие доставки приглашения в TG/MAX (обработчик — calls.event_handlers). CALL_INVITE_SEND = "calls.invite_send" @@ -60,12 +74,6 @@ class CreatedCall: staff_access_token: str -@dataclass(frozen=True) -class ResolvedInvite: - invite: CallInvite - customer_access_token: str - - def _conversation_identity(conversation: Conversation) -> ConnectionIdentity: if conversation.contact_id is None or conversation.connection_id is None: raise CallConflict("У диалога нет клиентской identity для звонка") @@ -135,6 +143,7 @@ def create_call_request(*, context: TenantContext, conversation_id: int) -> Crea raise CallConflict("Не удалось создать второй незавершённый звонок") from error CallInvite.objects.create( + organization=context.organization, call_session=call, connection_identity=identity, token_hash=token_hash, @@ -142,8 +151,14 @@ def create_call_request(*, context: TenantContext, conversation_id: int) -> Crea ) CallParticipant.objects.bulk_create( [ - CallParticipant(call_session=call, side=ParticipantSide.STAFF, user=initiator), CallParticipant( + organization=context.organization, + call_session=call, + side=ParticipantSide.STAFF, + user=initiator, + ), + CallParticipant( + organization=context.organization, call_session=call, side=ParticipantSide.CUSTOMER, connection_identity=identity, @@ -180,33 +195,6 @@ def create_call_request(*, context: TenantContext, conversation_id: int) -> Crea return CreatedCall(call_session=call, invite_token=invite_token, staff_access_token=staff_token) -@transaction.atomic -def resolve_invite(*, token: str) -> ResolvedInvite: - message = "Недействительное или истёкшее приглашение" - invite = ( - CallInvite.objects.select_for_update() - .select_related("call_session", "connection_identity") - .filter(token_hash=hash_invite_token(token)) - .first() - ) - now = timezone.now() - if ( - invite is None - or invite.expires_at <= now - or invite.opened_at is not None - or invite.call_session.status in TERMINAL_CALL_STATUSES - ): - raise CallTokenError(message) - invite.opened_at = now - invite.save(update_fields=["opened_at"]) - access_token = issue_call_access_token( - call_session_id=invite.call_session_id, - side=ParticipantSide.CUSTOMER, - subject_id=str(invite.id), - ) - return ResolvedInvite(invite=invite, customer_access_token=access_token) - - def issue_staff_access_token(*, context: TenantContext, call_session: CallSession) -> str: user = context.actor_user if user is None or context.membership is None: @@ -224,63 +212,6 @@ def issue_staff_access_token(*, context: TenantContext, call_session: CallSessio ) -def authorize_call_access_token(*, token: str, allow_terminal: bool = False) -> tuple[CallAccessClaims, CallSession]: - claims = verify_call_access_token(token) - try: - call = CallSession.objects.select_related( - "conversation", "conversation__channel", "initiated_by", "organization" - ).get(id=claims.call_session_id) - except CallSession.DoesNotExist: - raise CallTokenError("Недействительный или истёкший call access token") from None - if call.status in TERMINAL_CALL_STATUSES and not allow_terminal: - raise CallTokenError("Звонок уже завершён") - if claims.side == ParticipantSide.STAFF: - participant = call.participants.select_related("user").filter( - side=ParticipantSide.STAFF, - user_id=claims.subject_id, - ).first() - valid = participant is not None - if participant is not None: - membership = OrganizationMembership.objects.select_related("user").filter( - user=participant.user, - organization_id=call.organization_id, - blocked_at__isnull=True, - ).first() - try: - ensure_call_access(user=membership, call_session=call) - except CallAccessDenied: - valid = False - else: - # После принятия/завершения истечение invite не отзывает доступ к - # состоянию: TTL самого access token остаётся единственным пределом. - invite = CallInvite.objects.filter(id=claims.subject_id, call_session=call).first() - valid = invite is not None and ( - invite.expires_at > timezone.now() - or call.status not in {CallStatus.REQUESTED, CallStatus.RINGING} - ) - if not valid: - raise CallTokenError("Недействительный или истёкший call access token") - return claims, call - - -def authorize_call_access_context( - *, token: str, allow_terminal: bool = False -) -> tuple[CallAccessClaims, CallSession, TenantContext]: - claims, call = authorize_call_access_token(token=token, allow_terminal=allow_terminal) - if claims.side == ParticipantSide.STAFF: - membership = OrganizationMembership.objects.select_related( - "user", "organization" - ).get( - user_id=claims.subject_id, - organization=call.organization, - blocked_at__isnull=True, - ) - context = TenantContext.for_membership(membership) - else: - context = TenantContext.for_resource(call.organization) - return claims, call, context - - def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSession: user = context.actor_user if user is None or context.membership is None: @@ -299,96 +230,6 @@ def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSes raise CallConflict("Звонок уже нельзя отменить") from error -def _customer_call(*, token: str, allow_terminal: bool = False) -> CallSession: - claims, call = authorize_call_access_token(token=token, allow_terminal=allow_terminal) - if claims.side != ParticipantSide.CUSTOMER: - raise CallTokenError("Недействительный или истёкший call access token") - return call - - -def accept_call_by_access_token(*, token: str) -> CallSession: - call = _customer_call(token=token) - try: - if call.status == CallStatus.REQUESTED: - call = transition_call(call_session_id=call.id, target_status=CallStatus.RINGING) - return transition_call(call_session_id=call.id, target_status=CallStatus.ACCEPTED) - except CallInvalidTransition as error: - raise CallConflict("Приглашение уже нельзя принять") from error - - -def decline_call_by_access_token(*, token: str) -> CallSession: - call = _customer_call(token=token, allow_terminal=True) - if call.status == CallStatus.DECLINED: - return call - try: - return transition_call( - call_session_id=call.id, - target_status=CallStatus.DECLINED, - ended_by=CallEndedBy.CUSTOMER, - ) - except CallInvalidTransition as error: - raise CallConflict("Приглашение уже нельзя отклонить") from error - - -def call_state_by_access_token(*, token: str) -> CallSession: - _claims, call = authorize_call_access_token(token=token, allow_terminal=True) - return call - - -# ICE candidate типы (RFC 8445), допустимые в метриках. Храним только категорию, -# без адреса/порта/foundation самого кандидата. -_ALLOWED_CANDIDATE_TYPES = {"host", "srflx", "prflx", "relay"} -_MAX_ROUND_TRIP_MS = 60_000 - - -def _sanitize_candidate_type(value) -> str: - text = str(value or "").lower() - return text if text in _ALLOWED_CANDIDATE_TYPES else "" - - -def _connection_type(local: str, remote: str) -> str: - if "relay" in (local, remote): - return CallConnectionType.RELAY - if local or remote: - return CallConnectionType.DIRECT - return CallConnectionType.UNKNOWN - - -def record_call_metric( - *, - call_session_id, - side: str, - local_candidate_type=None, - remote_candidate_type=None, - round_trip_ms=None, -) -> None: - """Сохранить технические метрики соединения участника (без медиаконтента). - - Идемпотентно по (call_session, side): при reconnect/ICE-restart метрика - обновляется актуальным типом маршрута. Никакие SDP/ICE payload не пишутся — - только производная категория кандидата и RTT. - """ - if side not in ParticipantSide.values: - return - if not CallSession.objects.filter(id=call_session_id).exists(): - return - local = _sanitize_candidate_type(local_candidate_type) - remote = _sanitize_candidate_type(remote_candidate_type) - rtt: int | None = None - if isinstance(round_trip_ms, (int, float)) and not isinstance(round_trip_ms, bool): - rtt = max(0, min(_MAX_ROUND_TRIP_MS, int(round_trip_ms))) - CallMetric.objects.update_or_create( - call_session_id=call_session_id, - side=side, - defaults={ - "connection_type": _connection_type(local, remote), - "local_candidate_type": local, - "remote_candidate_type": remote, - "round_trip_ms": rtt, - }, - ) - - def active_call_for_conversation(conversation: Conversation) -> CallSession | None: return ( CallSession.objects.filter( diff --git a/apps/backend/hub_platform/calls/signaling.py b/apps/backend/hub_platform/calls/signaling.py index 57566b2..57b448e 100644 --- a/apps/backend/hub_platform/calls/signaling.py +++ b/apps/backend/hub_platform/calls/signaling.py @@ -153,6 +153,7 @@ def record_metric(context: TenantContext, call_id, side: str, content: dict) -> """ _call(context, call_id) record_call_metric( + context=context, call_session_id=call_id, side=side, local_candidate_type=content.get("localCandidateType"), diff --git a/apps/backend/hub_platform/calls/tests/test_metrics.py b/apps/backend/hub_platform/calls/tests/test_metrics.py index f9d03b0..9c4b9d5 100644 --- a/apps/backend/hub_platform/calls/tests/test_metrics.py +++ b/apps/backend/hub_platform/calls/tests/test_metrics.py @@ -16,7 +16,12 @@ class RecordCallMetricTests(CallTestCase): ).call_session def _record(self, **kwargs): - record_call_metric(call_session_id=self.call.id, side=ParticipantSide.STAFF, **kwargs) + record_call_metric( + context=system_tenant_context(self.organization), + call_session_id=self.call.id, + side=ParticipantSide.STAFF, + **kwargs, + ) return CallMetric.objects.get(call_session=self.call, side=ParticipantSide.STAFF) def test_direct_connection_type_from_non_relay_candidates(self) -> None: @@ -55,11 +60,21 @@ class RecordCallMetricTests(CallTestCase): self.assertEqual(metrics.first().connection_type, CallConnectionType.RELAY) def test_unknown_side_ignored(self) -> None: - record_call_metric(call_session_id=self.call.id, side="ALIEN", local_candidate_type="host") + record_call_metric( + context=system_tenant_context(self.organization), + call_session_id=self.call.id, + side="ALIEN", + local_candidate_type="host", + ) self.assertFalse(CallMetric.objects.filter(call_session=self.call).exists()) def test_missing_call_ignored(self) -> None: - record_call_metric(call_session_id=uuid.uuid4(), side=ParticipantSide.STAFF, local_candidate_type="host") + record_call_metric( + context=system_tenant_context(self.organization), + call_session_id=uuid.uuid4(), + side=ParticipantSide.STAFF, + local_candidate_type="host", + ) self.assertEqual(CallMetric.objects.count(), 0) def test_signaling_helper_maps_camel_case_payload(self) -> None: diff --git a/apps/backend/hub_platform/channels/management/commands/seed_channels.py b/apps/backend/hub_platform/channels/management/commands/seed_channels.py index e32699d..58111d4 100644 --- a/apps/backend/hub_platform/channels/management/commands/seed_channels.py +++ b/apps/backend/hub_platform/channels/management/commands/seed_channels.py @@ -15,6 +15,8 @@ from hub_platform.channels.models import Channel from hub_platform.identity.models import Department, Organization from hub_platform.integrations.models import Integration, IntegrationProvider from hub_platform.products.models import Product +from hub_platform.tenancy.context import TenantActorKind, TenantContext +from hub_platform.tenancy.database import set_local_tenant # Тон общения (поле tone агента): простой текст для мессенджера. TONE = ( @@ -58,6 +60,11 @@ class Command(BaseCommand): except (Organization.DoesNotExist, ValueError): self.stderr.write("organization not found") return + context = TenantContext.for_resource( + organization, + actor_kind=TenantActorKind.SYSTEM, + ) + set_local_tenant(context) sales = Department.objects.filter(organization=organization, code="sales").first() provider = ( Integration.objects.filter(organization=organization, provider=IntegrationProvider.OPENROUTER) @@ -84,6 +91,7 @@ class Command(BaseCommand): # Агент канала (ADR-HUB-0023): одна сущность, без релизов. if not AIAgent.objects.filter(channel=channel).exists(): AIAgent.objects.create( + organization=organization, channel=channel, name=f"{name} Agent", is_active=True, diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/hub_platform/conversations/ingest.py index 5d495e5..534c3dd 100644 --- a/apps/backend/hub_platform/conversations/ingest.py +++ b/apps/backend/hub_platform/conversations/ingest.py @@ -29,7 +29,7 @@ from hub_platform.conversations.models import ( ) from hub_platform.conversations import transports from hub_platform.conversations.transports.base import InboundMessage -from hub_platform.events.models import InboxEvent +from hub_platform.events.models import EventOwnership, InboxEvent from hub_platform.notifications.models import NotificationAudience, NotificationType from hub_platform.notifications.services import notify from hub_platform.tenancy.context import TenantContext @@ -45,10 +45,16 @@ _ROLE = { } -def _already_processed(source: str, external_id: str, text: str) -> bool: +def _already_processed(context: TenantContext, source: str, external_id: str, text: str) -> bool: payload_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32] try: - InboxEvent.objects.create(source=source, external_event_id=external_id, payload_hash=payload_hash) + InboxEvent.objects.create( + source=source, + external_event_id=external_id, + payload_hash=payload_hash, + ownership=EventOwnership.TENANT, + organization=context.organization, + ) return False except IntegrityError: return True @@ -67,7 +73,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: return context = TenantContext.for_resource(channel.organization) source = f"{integration.provider.lower()}:{integration.id}" - if _already_processed(source, inbound.external_id, inbound.text): + if _already_processed(context, source, inbound.external_id, inbound.text): return # Явный шаринг контакта: сообщение без текста, но с телефоном. diff --git a/apps/backend/hub_platform/conversations/migrations/0005_connectionidentity_organization_and_more.py b/apps/backend/hub_platform/conversations/migrations/0005_connectionidentity_organization_and_more.py new file mode 100644 index 0000000..592f1c8 --- /dev/null +++ b/apps/backend/hub_platform/conversations/migrations/0005_connectionidentity_organization_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0004_conversationread'), + ('identity', '0013_accessprofilecapability_organization_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='connectionidentity', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='conversationread', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='message', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/conversations/migrations/0006_alter_connectionidentity_organization_and_more.py b/apps/backend/hub_platform/conversations/migrations/0006_alter_connectionidentity_organization_and_more.py new file mode 100644 index 0000000..ab1e29e --- /dev/null +++ b/apps/backend/hub_platform/conversations/migrations/0006_alter_connectionidentity_organization_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0005_connectionidentity_organization_and_more'), + ('identity', '0014_alter_accessprofilecapability_organization_and_more'), + ] + + operations = [ + migrations.RunSQL( + sql=""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM conversations_connectionidentity identity + JOIN conversations_contact contact ON contact.id = identity.contact_id + JOIN integrations_integration connection ON connection.id = identity.connection_id + WHERE contact.organization_id <> connection.organization_id + ) THEN + RAISE EXCEPTION 'C04 preflight: cross-tenant connection identity exists'; + END IF; + END $$; + + UPDATE conversations_connectionidentity identity + SET organization_id = contact.organization_id + FROM conversations_contact contact + WHERE identity.contact_id = contact.id; + + UPDATE conversations_conversationread read + SET organization_id = conversation.organization_id + FROM conversations_conversation conversation + WHERE read.conversation_id = conversation.id; + + UPDATE conversations_message message + SET organization_id = conversation.organization_id + FROM conversations_conversation conversation + WHERE message.conversation_id = conversation.id; + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AlterField( + model_name='connectionidentity', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='conversationread', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='message', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/conversations/models.py b/apps/backend/hub_platform/conversations/models.py index c2dd2fa..93adf54 100644 --- a/apps/backend/hub_platform/conversations/models.py +++ b/apps/backend/hub_platform/conversations/models.py @@ -1,6 +1,8 @@ from django.conf import settings from django.db import models +from hub_platform.tenancy.models import TenantRelationModel + # Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога # разделено на независимые оси; перехват оператором — атомарный. @@ -17,7 +19,8 @@ class Contact(models.Model): return self.name or f"contact:{self.id}" -class ConnectionIdentity(models.Model): +class ConnectionIdentity(TenantRelationModel): + tenant_relation_fields = ("contact", "connection") # Устойчивая идентичность контакта внутри конкретного подключения (ADR-HUB-0006). contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="identities") connection = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="identities") @@ -98,11 +101,13 @@ class Conversation(models.Model): return f"conv:{self.id}/{self.lifecycle}/{self.control_mode}" -class ConversationRead(models.Model): +class ConversationRead(TenantRelationModel): """Персональная отметка прочтения диалога: до какого сообщения дочитал сотрудник. Обновляется при открытии диалога; бейдж непрочитанных в списке считается относительно этой отметки.""" + tenant_relation_fields = ("conversation",) + conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name="reads") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="conversation_reads") last_read_message_id = models.BigIntegerField(default=0) @@ -128,7 +133,8 @@ class MessageKind(models.TextChoices): CONTACT = "contact", "Контакт" -class Message(models.Model): +class Message(TenantRelationModel): + tenant_relation_fields = ("conversation",) conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name="messages") author_type = models.CharField(max_length=16, choices=MessageAuthor.choices) author_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") diff --git a/apps/backend/hub_platform/events/handlers.py b/apps/backend/hub_platform/events/handlers.py index c690d6c..8dd82bd 100644 --- a/apps/backend/hub_platform/events/handlers.py +++ b/apps/backend/hub_platform/events/handlers.py @@ -4,6 +4,7 @@ from typing import Callable from hub_platform.events.models import OutboxEvent from hub_platform.events.services import tenant_context_for_event from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic logger = logging.getLogger(__name__) @@ -24,4 +25,9 @@ def dispatch(event: OutboxEvent) -> None: if handler is None: logger.info("No handler registered for event %s", event.event_type) return - handler(event.payload, tenant_context_for_event(event)) + context = tenant_context_for_event(event) + if context is None: + handler(event.payload, None) + return + with tenant_atomic(context): + handler(event.payload, context) diff --git a/apps/backend/hub_platform/events/management/commands/run_worker.py b/apps/backend/hub_platform/events/management/commands/run_worker.py index 474f544..32cc446 100644 --- a/apps/backend/hub_platform/events/management/commands/run_worker.py +++ b/apps/backend/hub_platform/events/management/commands/run_worker.py @@ -13,6 +13,7 @@ from hub_platform.events.services import claim_next_outbox_event, mark_retry from hub_platform.identity.models import Organization from hub_platform.notifications.binding import poll_notifier_bots from hub_platform.tenancy.context import TenantActorKind, TenantContext +from hub_platform.tenancy.database import tenant_atomic logger = logging.getLogger(__name__) @@ -44,7 +45,10 @@ class Command(BaseCommand): dispatch(event) event.status = OutboxStatus.PROCESSED event.processed_at = timezone.now() - event.save(update_fields=["status", "processed_at"]) + event.save( + using="platform", + update_fields=["status", "processed_at"], + ) except Exception as exc: # pragma: no cover logger.exception("Outbox event failed: %s", event.id) mark_retry(event, str(exc)) @@ -55,26 +59,30 @@ class Command(BaseCommand): last_poll = now try: for context in self._tenant_contexts(): - poll_all_messengers(context) + with tenant_atomic(context): + poll_all_messengers(context) except Exception: # pragma: no cover logger.exception("Messenger polling cycle failed") try: for context in self._tenant_contexts(): - poll_notifier_bots(context) + with tenant_atomic(context): + poll_notifier_bots(context) except Exception: # pragma: no cover logger.exception("Notifier polling cycle failed") if now - last_call_sweep >= CALL_SWEEP_INTERVAL: last_call_sweep = now try: for context in self._tenant_contexts(): - expire_stale_calls(context) + with tenant_atomic(context): + expire_stale_calls(context) except Exception: # pragma: no cover logger.exception("Call sweep cycle failed") if now - last_maintenance >= MAINTENANCE_INTERVAL: last_maintenance = now try: for context in self._tenant_contexts(): - close_stale_conversations(context) + with tenant_atomic(context): + close_stale_conversations(context) except Exception: # pragma: no cover logger.exception("Maintenance cycle failed") time.sleep(1) diff --git a/apps/backend/hub_platform/events/migrations/0004_inboxevent_organization_inboxevent_ownership_and_more.py b/apps/backend/hub_platform/events/migrations/0004_inboxevent_organization_inboxevent_ownership_and_more.py new file mode 100644 index 0000000..02ca1d6 --- /dev/null +++ b/apps/backend/hub_platform/events/migrations/0004_inboxevent_organization_inboxevent_ownership_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0003_outbox_tenant_context'), + ('identity', '0014_alter_accessprofilecapability_organization_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='inboxevent', + name='organization', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inbox_events', to='identity.organization'), + ), + migrations.AddField( + model_name='inboxevent', + name='ownership', + field=models.CharField(choices=[('PLATFORM', 'Platform'), ('TENANT', 'Tenant')], db_index=True, default='PLATFORM', max_length=16), + ), + migrations.AddConstraint( + model_name='inboxevent', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('organization__isnull', False), ('ownership', 'TENANT')), models.Q(('organization__isnull', True), ('ownership', 'PLATFORM')), _connector='OR'), name='inbox_ownership_matches_organization'), + ), + migrations.AddConstraint( + model_name='outboxevent', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('organization__isnull', False), ('ownership', 'TENANT')), models.Q(('organization__isnull', True), ('ownership', 'PLATFORM')), _connector='OR'), name='outbox_ownership_matches_organization'), + ), + ] diff --git a/apps/backend/hub_platform/events/models.py b/apps/backend/hub_platform/events/models.py index e8cb619..93c3446 100644 --- a/apps/backend/hub_platform/events/models.py +++ b/apps/backend/hub_platform/events/models.py @@ -71,6 +71,15 @@ class OutboxEvent(models.Model): models.Index(fields=["status", "next_attempt_at"]), models.Index(fields=["aggregate_type", "aggregate_id"]), ] + constraints = [ + models.CheckConstraint( + condition=( + models.Q(ownership=EventOwnership.TENANT, organization__isnull=False) + | models.Q(ownership=EventOwnership.PLATFORM, organization__isnull=True) + ), + name="outbox_ownership_matches_organization", + ) + ] def __str__(self) -> str: return f"{self.event_type}:{self.id}" @@ -81,6 +90,19 @@ class InboxEvent(models.Model): source = models.CharField(max_length=128) external_event_id = models.CharField(max_length=256) payload_hash = models.CharField(max_length=128) + ownership = models.CharField( + max_length=16, + choices=EventOwnership.choices, + default=EventOwnership.PLATFORM, + db_index=True, + ) + organization = models.ForeignKey( + "identity.Organization", + on_delete=models.PROTECT, + related_name="inbox_events", + null=True, + blank=True, + ) received_at = models.DateTimeField(auto_now_add=True) processed_at = models.DateTimeField(null=True, blank=True) @@ -89,7 +111,14 @@ class InboxEvent(models.Model): models.UniqueConstraint( fields=["source", "external_event_id"], name="uniq_inbox_source_external_event_id", - ) + ), + models.CheckConstraint( + condition=( + models.Q(ownership=EventOwnership.TENANT, organization__isnull=False) + | models.Q(ownership=EventOwnership.PLATFORM, organization__isnull=True) + ), + name="inbox_ownership_matches_organization", + ), ] def __str__(self) -> str: diff --git a/apps/backend/hub_platform/events/services.py b/apps/backend/hub_platform/events/services.py index a2cc6a7..1cfdfc1 100644 --- a/apps/backend/hub_platform/events/services.py +++ b/apps/backend/hub_platform/events/services.py @@ -44,33 +44,43 @@ def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None: if event.organization_id is None: raise ValueError("Tenant event has no organization") organization = Organization.objects.get(pk=event.organization_id) - membership = None - actor_user = None - if event.membership_id is not None: - membership = OrganizationMembership.objects.select_related("user", "organization").get( - pk=event.membership_id, - organization=organization, - blocked_at__isnull=True, - user__is_active=True, - ) - actor_user = membership.user - if event.actor_user_id not in {None, membership.user_id}: - raise ValueError("Tenant event actor does not match membership") - elif event.actor_user_id is not None: - actor_user = event.actor_user try: actor_kind = TenantActorKind(event.actor_kind) except ValueError as error: raise ValueError("Tenant event has invalid actor kind") from error - if actor_kind == TenantActorKind.HUMAN and membership is None: - raise ValueError("Human tenant event has no membership") - return TenantContext( - organization=organization, - membership=membership, - actor_user=actor_user, + resource_context = TenantContext.for_resource( + organization, actor_kind=actor_kind, correlation_id=event.correlation_id, ) + from hub_platform.tenancy.database import tenant_atomic + + with tenant_atomic(resource_context): + membership = None + actor_user = None + if event.membership_id is not None: + membership = OrganizationMembership.objects.select_related( + "user", "organization" + ).get( + pk=event.membership_id, + organization=organization, + blocked_at__isnull=True, + user__is_active=True, + ) + actor_user = membership.user + if event.actor_user_id not in {None, membership.user_id}: + raise ValueError("Tenant event actor does not match membership") + elif event.actor_user_id is not None: + actor_user = event.actor_user + if actor_kind == TenantActorKind.HUMAN and membership is None: + raise ValueError("Human tenant event has no membership") + return TenantContext( + organization=organization, + membership=membership, + actor_user=actor_user, + actor_kind=actor_kind, + correlation_id=event.correlation_id, + ) def mark_retry(event: OutboxEvent, error: str, max_attempts: int = 5) -> None: @@ -78,13 +88,16 @@ def mark_retry(event: OutboxEvent, error: str, max_attempts: int = 5) -> None: event.last_error = error event.status = OutboxStatus.DEAD_LETTER if event.attempts >= max_attempts else OutboxStatus.FAILED event.next_attempt_at = timezone.now() + timedelta(seconds=min(300, 2**event.attempts)) - event.save(update_fields=["attempts", "last_error", "status", "next_attempt_at"]) + event.save( + using="platform", + update_fields=["attempts", "last_error", "status", "next_attempt_at"], + ) def claim_next_outbox_event() -> OutboxEvent | None: - with transaction.atomic(): + with transaction.atomic(using="platform"): event = ( - OutboxEvent.objects.select_for_update(skip_locked=True) + OutboxEvent.objects.using("platform").select_for_update(skip_locked=True) .filter( status__in=[OutboxStatus.PENDING, OutboxStatus.FAILED], next_attempt_at__lte=timezone.now(), @@ -95,5 +108,5 @@ def claim_next_outbox_event() -> OutboxEvent | None: if event is None: return None event.status = OutboxStatus.PROCESSING - event.save(update_fields=["status"]) + event.save(using="platform", update_fields=["status"]) return event diff --git a/apps/backend/hub_platform/identity/access_models.py b/apps/backend/hub_platform/identity/access_models.py index f691c37..251d38e 100644 --- a/apps/backend/hub_platform/identity/access_models.py +++ b/apps/backend/hub_platform/identity/access_models.py @@ -7,6 +7,7 @@ from django.db.models.functions import Lower from hub_platform.identity.capabilities import CAPABILITY_REGISTRY, ScopeType, capability_spec from hub_platform.identity.models import Department, Organization, OrganizationMembership +from hub_platform.tenancy.models import TenantRelationModel class AccessProfile(models.Model): @@ -40,7 +41,8 @@ class AccessProfile(models.Model): return f"{self.organization.slug}/{self.name}" -class AccessProfileCapability(models.Model): +class AccessProfileCapability(TenantRelationModel): + tenant_relation_fields = ("access_profile",) access_profile = models.ForeignKey( AccessProfile, on_delete=models.CASCADE, related_name="capability_links" ) @@ -73,11 +75,18 @@ class AccessProfileCapability(models.Model): raise ValidationError({"capability_code": "Protected capability cannot be assigned"}) def save(self, *args, **kwargs) -> None: + self.validate_tenant_relations() self.full_clean() super().save(*args, **kwargs) -class EmployeeAccessAssignment(models.Model): +class EmployeeAccessAssignment(TenantRelationModel): + tenant_relation_fields = ( + "employee", + "access_profile", + "department", + "assigned_by", + ) employee = models.ForeignKey( OrganizationMembership, on_delete=models.PROTECT, @@ -139,5 +148,6 @@ class EmployeeAccessAssignment(models.Model): raise ValidationError("Department is required only for DEPARTMENT scope") def save(self, *args, **kwargs) -> None: + self.validate_tenant_relations() self.full_clean() super().save(*args, **kwargs) diff --git a/apps/backend/hub_platform/identity/access_views.py b/apps/backend/hub_platform/identity/access_views.py index ebdb19f..46323e7 100644 --- a/apps/backend/hub_platform/identity/access_views.py +++ b/apps/backend/hub_platform/identity/access_views.py @@ -78,7 +78,11 @@ class AccessProfileListCreateView(APIView): ) AccessProfileCapability.objects.bulk_create( [ - AccessProfileCapability(access_profile=profile, capability_code=code) + AccessProfileCapability( + organization=profile.organization, + access_profile=profile, + capability_code=code, + ) for code in codes ] ) @@ -145,7 +149,11 @@ class AccessProfileDetailView(APIView): profile.capability_links.all().delete() AccessProfileCapability.objects.bulk_create( [ - AccessProfileCapability(access_profile=profile, capability_code=code) + AccessProfileCapability( + organization=profile.organization, + access_profile=profile, + capability_code=code, + ) for code in codes ] ) diff --git a/apps/backend/hub_platform/identity/auth/common.py b/apps/backend/hub_platform/identity/auth/common.py index cb39a4c..e499e36 100644 --- a/apps/backend/hub_platform/identity/auth/common.py +++ b/apps/backend/hub_platform/identity/auth/common.py @@ -1,32 +1,52 @@ from rest_framework.request import Request -from hub_platform.identity.models import HumanUser +from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership from hub_platform.identity.policy import get_effective_access from hub_platform.identity.sessions import revoke_user_sessions +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.ingress import membership_routes_for_user def _user_payload(user: HumanUser) -> dict[str, object]: memberships = [] - active_memberships = ( - user.memberships.filter(blocked_at__isnull=True) - .select_related("organization", "primary_department") - .order_by("organization__name", "id") + routes = membership_routes_for_user(user.id) + organizations = Organization.objects.in_bulk( + [route.organization_id for route in routes] ) - for membership in active_memberships: - membership_payload = { - "id": membership.id, - "organizationPublicId": str(membership.organization.public_id), - "organization": membership.organization.slug, - "organizationName": membership.organization.name, - "role": membership.role, - "positionTitle": membership.position_title, - "department": ( - membership.primary_department.code if membership.primary_department else None - ), - "totpRequired": membership.totp_required, - } - membership_payload.update(get_effective_access(membership)) - memberships.append(membership_payload) + for route in routes: + organization = organizations.get(route.organization_id) + if organization is None: + continue + with tenant_atomic(organization.id): + membership = ( + OrganizationMembership.objects.select_related( + "organization", "primary_department" + ) + .filter( + id=route.resource_id, + user=user, + organization=organization, + blocked_at__isnull=True, + ) + .first() + ) + if membership is None: + continue + membership_payload = { + "id": membership.id, + "organizationPublicId": str(membership.organization.public_id), + "organization": membership.organization.slug, + "organizationName": membership.organization.name, + "role": membership.role, + "positionTitle": membership.position_title, + "department": ( + membership.primary_department.code if membership.primary_department else None + ), + "totpRequired": membership.totp_required, + } + membership_payload.update(get_effective_access(membership)) + memberships.append(membership_payload) + memberships.sort(key=lambda item: (str(item["organizationName"]), int(item["id"]))) return { "id": user.id, "email": user.email, diff --git a/apps/backend/hub_platform/identity/auth/profile.py b/apps/backend/hub_platform/identity/auth/profile.py index f3b95de..b814711 100644 --- a/apps/backend/hub_platform/identity/auth/profile.py +++ b/apps/backend/hub_platform/identity/auth/profile.py @@ -8,6 +8,7 @@ from rest_framework.views import APIView from hub_platform.identity.audit import record_audit_event from hub_platform.identity.auth.common import _revoke_other_user_sessions, _user_payload +from hub_platform.tenancy.ingress import user_requires_totp from hub_platform.identity.models import HumanUser @@ -88,9 +89,7 @@ class ProfileTotpDisableView(APIView): if not request.user.check_password(current_password): return Response({"detail": "Current password is invalid"}, status=400) - if request.user.memberships.filter( - blocked_at__isnull=True, totp_required=True - ).exists(): + if user_requires_totp(request.user.id): return Response({"detail": "TOTP is required by an organization policy"}, status=409) request.user.totp_enabled = False request.user.totp_secret = "" diff --git a/apps/backend/hub_platform/identity/employee_security_views.py b/apps/backend/hub_platform/identity/employee_security_views.py index d55d933..f85357b 100644 --- a/apps/backend/hub_platform/identity/employee_security_views.py +++ b/apps/backend/hub_platform/identity/employee_security_views.py @@ -15,7 +15,6 @@ class EmployeeResetPasswordView(APIView): @transaction.atomic def post(self, request: Request, user_id: int) -> Response: - actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: return Response({"detail": "Employee not found"}, status=404) @@ -26,7 +25,6 @@ class EmployeeRevokeSessionsView(APIView): permission_classes = [IsAuthenticated] def post(self, request: Request, user_id: int) -> Response: - actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: return Response({"detail": "Employee not found"}, status=404) diff --git a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py index 52716f6..bed56dc 100644 --- a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py +++ b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py @@ -23,6 +23,7 @@ from hub_platform.identity.models import ( ) from hub_platform.products.models import Product, ProductDepartment from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic from ._seed_specs import CHANNEL_SPECS, PRODUCT_SPECS, TONE @@ -212,19 +213,20 @@ class Command(BaseCommand): "organization", "user" ).get(user=core.owner) context = TenantContext.for_membership(membership) - call_command( - "seed_catalog", - organization=str(core.organization.public_id), - verbosity=0, - ) - channels_created, agents_created = _seed_channels(context=context) - from hub_platform.support.seed_support import seed_support_reference + with tenant_atomic(context): + call_command( + "seed_catalog", + organization=str(core.organization.public_id), + verbosity=0, + ) + channels_created, agents_created = _seed_channels(context=context) + from hub_platform.support.seed_support import seed_support_reference - support_stats = seed_support_reference(context=context) - content_result = import_ai_content( - base_dir=Path(__file__).resolve().parents[6], - context=context, - ) + support_stats = seed_support_reference(context=context) + content_result = import_ai_content( + base_dir=Path(__file__).resolve().parents[6], + context=context, + ) owner_state = "created" if core.created_owner else "ready" self.stdout.write( diff --git a/apps/backend/hub_platform/identity/migrations/0013_accessprofilecapability_organization_and_more.py b/apps/backend/hub_platform/identity/migrations/0013_accessprofilecapability_organization_and_more.py new file mode 100644 index 0000000..939f314 --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0013_accessprofilecapability_organization_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0012_membership_identity'), + ] + + operations = [ + migrations.AddField( + model_name='accessprofilecapability', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='employeeaccessassignment', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/identity/migrations/0014_alter_accessprofilecapability_organization_and_more.py b/apps/backend/hub_platform/identity/migrations/0014_alter_accessprofilecapability_organization_and_more.py new file mode 100644 index 0000000..a796a98 --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0014_alter_accessprofilecapability_organization_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0013_accessprofilecapability_organization_and_more'), + ] + + operations = [ + migrations.RunSQL( + sql=""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM identity_employeeaccessassignment assignment + JOIN identity_employeeprofile employee ON employee.id = assignment.employee_id + JOIN identity_accessprofile profile ON profile.id = assignment.access_profile_id + JOIN identity_employeeprofile assigner ON assigner.id = assignment.assigned_by_id + LEFT JOIN identity_department department ON department.id = assignment.department_id + WHERE employee.organization_id <> profile.organization_id + OR employee.organization_id <> assigner.organization_id + OR (department.id IS NOT NULL AND employee.organization_id <> department.organization_id) + ) THEN + RAISE EXCEPTION 'C04 preflight: cross-tenant access assignment exists'; + END IF; + END $$; + + UPDATE identity_accessprofilecapability capability + SET organization_id = profile.organization_id + FROM identity_accessprofile profile + WHERE capability.access_profile_id = profile.id; + + UPDATE identity_employeeaccessassignment assignment + SET organization_id = employee.organization_id + FROM identity_employeeprofile employee + WHERE assignment.employee_id = employee.id; + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AlterField( + model_name='accessprofilecapability', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='employeeaccessassignment', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/integrations/services.py b/apps/backend/hub_platform/integrations/services.py index f6af435..6166ba8 100644 --- a/apps/backend/hub_platform/integrations/services.py +++ b/apps/backend/hub_platform/integrations/services.py @@ -122,7 +122,7 @@ _CHECKS = { } -def _check_web(integration: Integration) -> tuple[bool, str, dict]: +def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, str, dict]: """Web-виджет обслуживается нашим же backend'ом — внешнего API нет. Проверяем конфигурацию: привязку к каналу и что именно это подключение отдаётся виджету (webchat берёт первое WEB-подключение канала).""" @@ -130,7 +130,7 @@ def _check_web(integration: Integration) -> tuple[bool, str, dict]: return False, "Подключение не привязано к каналу — виджет не активен", {} from hub_platform.webchat.services import web_connection_for_channel - active = web_connection_for_channel(integration.channel.code) + active = web_connection_for_channel(context, integration.channel.code) if active is None or active.id != integration.id: return False, "Для этого канала виджет обслуживает другое WEB-подключение", {} return True, f"Web-виджет активен · канал «{integration.channel.name}»", {} @@ -140,7 +140,7 @@ def test_integration(*, context: TenantContext, integration: Integration) -> Int if integration.organization_id != context.organization_id: raise ValidationError({"integration": "Integration belongs to another organization"}) if integration.provider == IntegrationProvider.WEB: - ok, detail, meta = _check_web(integration) + ok, detail, meta = _check_web(context, integration) else: check = _CHECKS.get(integration.provider) if check is None: diff --git a/apps/backend/hub_platform/integrations/views.py b/apps/backend/hub_platform/integrations/views.py index 16dc73f..02ef6c2 100644 --- a/apps/backend/hub_platform/integrations/views.py +++ b/apps/backend/hub_platform/integrations/views.py @@ -66,7 +66,6 @@ class IntegrationListView(APIView): return Response({"items": [integration_payload(item) for item in items]}) def post(self, request: Request) -> Response: - profile = request.tenant_context.membership try: integration = create_integration( context=request.tenant_context, data=_input(request.data) diff --git a/apps/backend/hub_platform/notifications/migrations/0005_messengerbinding_organization_and_more.py b/apps/backend/hub_platform/notifications/migrations/0005_messengerbinding_organization_and_more.py new file mode 100644 index 0000000..fff06ef --- /dev/null +++ b/apps/backend/hub_platform/notifications/migrations/0005_messengerbinding_organization_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0013_accessprofilecapability_organization_and_more'), + ('notifications', '0004_notification_department_scope'), + ] + + operations = [ + migrations.AddField( + model_name='messengerbinding', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='messengerbindingcode', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='notificationread', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/notifications/migrations/0006_alter_messengerbinding_organization_and_more.py b/apps/backend/hub_platform/notifications/migrations/0006_alter_messengerbinding_organization_and_more.py new file mode 100644 index 0000000..a526bcf --- /dev/null +++ b/apps/backend/hub_platform/notifications/migrations/0006_alter_messengerbinding_organization_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0014_alter_accessprofilecapability_organization_and_more'), + ('notifications', '0005_messengerbinding_organization_and_more'), + ] + + operations = [ + migrations.RunSQL( + sql=""" + UPDATE notifications_messengerbinding binding + SET organization_id = integration.organization_id + FROM integrations_integration integration + WHERE binding.integration_id = integration.id; + + UPDATE notifications_messengerbindingcode code + SET organization_id = integration.organization_id + FROM integrations_integration integration + WHERE code.integration_id = integration.id; + + UPDATE notifications_notificationread read + SET organization_id = notification.organization_id + FROM notifications_notification notification + WHERE read.notification_id = notification.id; + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AlterField( + model_name='messengerbinding', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='messengerbindingcode', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='notificationread', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/notifications/models.py b/apps/backend/hub_platform/notifications/models.py index 58a1c83..57136e0 100644 --- a/apps/backend/hub_platform/notifications/models.py +++ b/apps/backend/hub_platform/notifications/models.py @@ -1,6 +1,8 @@ from django.conf import settings from django.db import models +from hub_platform.tenancy.models import TenantRelationModel + # Уведомления: событие создаётся один раз и адресуется аудитории; прочтение — # персональное (NotificationRead). Фундамент под любые типы событий, не только чат. @@ -61,7 +63,8 @@ class Notification(models.Model): return f"notif:{self.type}/{self.audience}" -class NotificationRead(models.Model): +class NotificationRead(TenantRelationModel): + tenant_relation_fields = ("notification",) notification = models.ForeignKey(Notification, on_delete=models.CASCADE, related_name="reads") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notification_reads") read_at = models.DateTimeField(auto_now_add=True) @@ -75,12 +78,14 @@ def default_push_types() -> list[str]: return [NotificationType.DIALOG_WAITING, NotificationType.DIALOG_NEW_MESSAGE] -class MessengerBinding(models.Model): +class MessengerBinding(TenantRelationModel): """Привязка сотрудника к сервисному боту уведомлений (TG/MAX). Создаётся при подтверждении одноразового кода из профиля; уведомления доставляются в external_chat_id через транспорт интеграции.""" + tenant_relation_fields = ("integration",) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="messenger_bindings") integration = models.ForeignKey("integrations.Integration", on_delete=models.CASCADE, related_name="messenger_bindings") external_chat_id = models.CharField(max_length=128) @@ -95,7 +100,8 @@ class MessengerBinding(models.Model): return f"binding:{self.user_id}/{self.integration_id}" -class MessengerBindingCode(models.Model): +class MessengerBindingCode(TenantRelationModel): + tenant_relation_fields = ("integration",) # Одноразовый код привязки (deep-link ?start=); TTL ~10 минут. user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="messenger_binding_codes") integration = models.ForeignKey("integrations.Integration", on_delete=models.CASCADE, related_name="messenger_binding_codes") diff --git a/apps/backend/hub_platform/notifications/services.py b/apps/backend/hub_platform/notifications/services.py index a15cc71..f13a490 100644 --- a/apps/backend/hub_platform/notifications/services.py +++ b/apps/backend/hub_platform/notifications/services.py @@ -81,6 +81,13 @@ def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False queryset = unread_for(context) if not all_unread: queryset = queryset.filter(id__in=ids or []) - rows = [NotificationRead(notification=n, user=context.actor_user) for n in queryset] + rows = [ + NotificationRead( + organization=context.organization, + notification=notification, + user=context.actor_user, + ) + for notification in queryset + ] NotificationRead.objects.bulk_create(rows, ignore_conflicts=True) return len(rows) diff --git a/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py b/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py index db3293f..06fcf39 100644 --- a/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py +++ b/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py @@ -13,6 +13,8 @@ from django.core.management.base import BaseCommand, CommandError from hub_platform.identity.models import Organization from hub_platform.orders.services import hash_ingest_token from hub_platform.products.models import Product +from hub_platform.tenancy.context import TenantActorKind, TenantContext +from hub_platform.tenancy.database import tenant_atomic class Command(BaseCommand): @@ -28,11 +30,16 @@ class Command(BaseCommand): organization = Organization.objects.get(public_id=options["organization"]) except (Organization.DoesNotExist, ValueError) as error: raise CommandError("Unknown organization public UUID") from error - product = Product.objects.filter(organization=organization, code=code).first() - if product is None: - raise CommandError(f"product '{code}' not found") - token = secrets.token_urlsafe(32) - product.ingest_token_hash = hash_ingest_token(token) - product.save(update_fields=["ingest_token_hash", "updated_at"]) + context = TenantContext.for_resource( + organization, + actor_kind=TenantActorKind.SYSTEM, + ) + with tenant_atomic(context): + product = Product.objects.filter(organization=organization, code=code).first() + if product is None: + raise CommandError(f"product '{code}' not found") + token = secrets.token_urlsafe(32) + product.ingest_token_hash = hash_ingest_token(token) + product.save(update_fields=["ingest_token_hash", "updated_at"]) self.stdout.write(self.style.SUCCESS(f"ingest token for {code} (store it now, shown once):")) self.stdout.write(token) diff --git a/apps/backend/hub_platform/orders/management/commands/seed_orders.py b/apps/backend/hub_platform/orders/management/commands/seed_orders.py index 8d753a9..a8944a5 100644 --- a/apps/backend/hub_platform/orders/management/commands/seed_orders.py +++ b/apps/backend/hub_platform/orders/management/commands/seed_orders.py @@ -15,6 +15,7 @@ from hub_platform.orders.models import Order from hub_platform.orders.services import OrderItemInput, create_order, mark_paid from hub_platform.products.models import Offer from hub_platform.tenancy.context import TenantActorKind, TenantContext +from hub_platform.tenancy.database import set_local_tenant class Command(BaseCommand): @@ -33,6 +34,7 @@ class Command(BaseCommand): context = TenantContext.for_resource( organization, actor_kind=TenantActorKind.SYSTEM ) + set_local_tenant(context) if Order.objects.filter(organization=organization).exists(): self.stdout.write("orders already present — skipping") return diff --git a/apps/backend/hub_platform/orders/migrations/0003_orderitem_organization.py b/apps/backend/hub_platform/orders/migrations/0003_orderitem_organization.py new file mode 100644 index 0000000..eff188e --- /dev/null +++ b/apps/backend/hub_platform/orders/migrations/0003_orderitem_organization.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0013_accessprofilecapability_organization_and_more'), + ('orders', '0002_order_external_id_order_source_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='orderitem', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/orders/migrations/0004_alter_orderitem_organization.py b/apps/backend/hub_platform/orders/migrations/0004_alter_orderitem_organization.py new file mode 100644 index 0000000..72a3dd6 --- /dev/null +++ b/apps/backend/hub_platform/orders/migrations/0004_alter_orderitem_organization.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0014_alter_accessprofilecapability_organization_and_more'), + ('orders', '0003_orderitem_organization'), + ] + + operations = [ + migrations.RunSQL( + sql=""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM orders_orderitem item + JOIN orders_order customer_order ON customer_order.id = item.order_id + JOIN products_offer offer ON offer.id = item.offer_id + JOIN identity_product product ON product.id = offer.product_id + LEFT JOIN products_price price ON price.id = item.price_id + LEFT JOIN products_offer price_offer ON price_offer.id = price.offer_id + LEFT JOIN identity_product price_product ON price_product.id = price_offer.product_id + WHERE customer_order.organization_id <> product.organization_id + OR (price.id IS NOT NULL AND customer_order.organization_id <> price_product.organization_id) + ) THEN + RAISE EXCEPTION 'C04 preflight: cross-tenant order item exists'; + END IF; + END $$; + + UPDATE orders_orderitem item + SET organization_id = customer_order.organization_id + FROM orders_order customer_order + WHERE item.order_id = customer_order.id; + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AlterField( + model_name='orderitem', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/orders/models.py b/apps/backend/hub_platform/orders/models.py index 3660798..b1db1e5 100644 --- a/apps/backend/hub_platform/orders/models.py +++ b/apps/backend/hub_platform/orders/models.py @@ -1,5 +1,7 @@ from django.db import models +from hub_platform.tenancy.models import TenantRelationModel + # Коммерческий заказ (ADR-HUB-0018): каталог и факт продажи живут в Хабе, # само исполнение (выдача доступа) — на стороне бэкенда продукта. Позиции # хранят снимок offer/цены, чтобы запись не «плыла» при изменении каталога. @@ -55,7 +57,8 @@ class Order(models.Model): return f"{self.code}/{self.payment_status}" -class OrderItem(models.Model): +class OrderItem(TenantRelationModel): + tenant_relation_fields = ("order", "offer", "price") order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items") offer = models.ForeignKey("products.Offer", on_delete=models.PROTECT, related_name="order_items") price = models.ForeignKey("products.Price", on_delete=models.PROTECT, null=True, blank=True, related_name="order_items") diff --git a/apps/backend/hub_platform/orders/services.py b/apps/backend/hub_platform/orders/services.py index f540a02..7a64869 100644 --- a/apps/backend/hub_platform/orders/services.py +++ b/apps/backend/hub_platform/orders/services.py @@ -74,7 +74,16 @@ def create_order( ) OrderItem.objects.bulk_create( [ - OrderItem(order=order, offer=offer, price=price, title=offer.name, quantity=quantity, amount_minor=amount, currency=currency) + OrderItem( + organization=organization, + order=order, + offer=offer, + price=price, + title=offer.name, + quantity=quantity, + amount_minor=amount, + currency=currency, + ) for offer, price, quantity, amount in resolved ] ) @@ -120,12 +129,6 @@ class IngestItemInput: quantity: int = 1 -def resolve_product_by_token(token: str) -> Product | None: - if not token: - return None - return Product.objects.filter(ingest_token_hash=hash_ingest_token(token)).first() - - @transaction.atomic def ingest_order( *, @@ -190,7 +193,16 @@ def ingest_order( ) OrderItem.objects.bulk_create( [ - OrderItem(order=order, offer=offer, price=price, title=offer.name, quantity=quantity, amount_minor=amount, currency=currency) + OrderItem( + organization=organization, + order=order, + offer=offer, + price=price, + title=offer.name, + quantity=quantity, + amount_minor=amount, + currency=currency, + ) for offer, price, quantity, amount in resolved ] ) diff --git a/apps/backend/hub_platform/orders/views.py b/apps/backend/hub_platform/orders/views.py index 138d76b..7bf280a 100644 --- a/apps/backend/hub_platform/orders/views.py +++ b/apps/backend/hub_platform/orders/views.py @@ -7,6 +7,7 @@ from rest_framework.views import APIView from hub_platform.api.permissions import HasCapability from hub_platform.conversations.models import Contact, Conversation from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.models import Organization from hub_platform.orders.models import Order from hub_platform.orders.selectors import order_for_context, orders_for_context from hub_platform.orders.serializers import order_payload @@ -15,13 +16,16 @@ from hub_platform.orders.services import ( OrderItemInput, cancel_order, create_order, + hash_ingest_token, ingest_order, mark_paid, - resolve_product_by_token, set_fulfillment, ) +from hub_platform.products.models import Product from hub_platform.identity.policy import accessible_department_ids, require_capability from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.ingress import product_ingest_route def _validation_error(error: ValidationError) -> Response: @@ -112,11 +116,31 @@ class OrderIngestView(APIView): def post(self, request: Request) -> Response: token = request.headers.get("X-Product-Token", "") - product = resolve_product_by_token(token) - if product is None: + route = product_ingest_route(hash_ingest_token(token)) if token else None + if route is None: return Response({"detail": "Invalid product token"}, status=401) - context = TenantContext.for_resource(product.organization) + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist: + return Response({"detail": "Invalid product token"}, status=401) + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + product = Product.objects.filter( + id=route.resource_id, + organization=organization, + ingest_token_hash=hash_ingest_token(token), + ).first() + if product is None: + return Response({"detail": "Invalid product token"}, status=401) + return self._post_for_product(request, context=context, product=product) + def _post_for_product( + self, + request: Request, + *, + context: TenantContext, + product: Product, + ) -> Response: raw_items = request.data.get("items") if not isinstance(raw_items, list) or not raw_items: return Response({"detail": "items must be a non-empty list"}, status=400) diff --git a/apps/backend/hub_platform/products/management/commands/seed_catalog.py b/apps/backend/hub_platform/products/management/commands/seed_catalog.py index a8d9e8c..eecb107 100644 --- a/apps/backend/hub_platform/products/management/commands/seed_catalog.py +++ b/apps/backend/hub_platform/products/management/commands/seed_catalog.py @@ -26,6 +26,7 @@ from hub_platform.products.models import ( ) from hub_platform.identity.models import Organization from hub_platform.tenancy.context import TenantActorKind, TenantContext +from hub_platform.tenancy.database import set_local_tenant VALID_FROM = datetime(2026, 1, 1, tzinfo=timezone.utc) @@ -108,6 +109,7 @@ class Command(BaseCommand): context = TenantContext.for_resource( organization, actor_kind=TenantActorKind.SYSTEM ) + set_local_tenant(context) created_offers = 0 created_prices = 0 for product_code, offers in CATALOG.items(): diff --git a/apps/backend/hub_platform/products/migrations/0009_marketplacepublication_organization_and_more.py b/apps/backend/hub_platform/products/migrations/0009_marketplacepublication_organization_and_more.py new file mode 100644 index 0000000..c4e7ce3 --- /dev/null +++ b/apps/backend/hub_platform/products/migrations/0009_marketplacepublication_organization_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0013_accessprofilecapability_organization_and_more'), + ('products', '0008_remove_product_knowledge_fields'), + ] + + operations = [ + migrations.AddField( + model_name='marketplacepublication', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='offer', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='price', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AddField( + model_name='productdepartment', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/products/migrations/0010_alter_marketplacepublication_organization_and_more.py b/apps/backend/hub_platform/products/migrations/0010_alter_marketplacepublication_organization_and_more.py new file mode 100644 index 0000000..0677c2f --- /dev/null +++ b/apps/backend/hub_platform/products/migrations/0010_alter_marketplacepublication_organization_and_more.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0014_alter_accessprofilecapability_organization_and_more'), + ('products', '0009_marketplacepublication_organization_and_more'), + ] + + operations = [ + migrations.RunSQL( + sql=""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM products_productdepartment link + JOIN identity_product product ON product.id = link.product_id + JOIN identity_department department ON department.id = link.department_id + WHERE product.organization_id <> department.organization_id + ) THEN + RAISE EXCEPTION 'C04 preflight: cross-tenant product department exists'; + END IF; + IF EXISTS ( + SELECT 1 + FROM products_offer offer + JOIN products_offer primary_offer ON primary_offer.id = offer.primary_box_offer_id + JOIN identity_product product ON product.id = offer.product_id + JOIN identity_product primary_product ON primary_product.id = primary_offer.product_id + WHERE product.organization_id <> primary_product.organization_id + ) THEN + RAISE EXCEPTION 'C04 preflight: cross-tenant offer relation exists'; + END IF; + END $$; + + UPDATE products_offer offer + SET organization_id = product.organization_id + FROM identity_product product + WHERE offer.product_id = product.id; + + UPDATE products_price price + SET organization_id = product.organization_id + FROM products_offer offer + JOIN identity_product product ON product.id = offer.product_id + WHERE price.offer_id = offer.id; + + UPDATE products_marketplacepublication publication + SET organization_id = product.organization_id + FROM products_price price + JOIN products_offer offer ON offer.id = price.offer_id + JOIN identity_product product ON product.id = offer.product_id + WHERE publication.price_id = price.id; + + UPDATE products_productdepartment link + SET organization_id = product.organization_id + FROM identity_product product + WHERE link.product_id = product.id; + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AlterField( + model_name='marketplacepublication', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='offer', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='price', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.AlterField( + model_name='productdepartment', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/products/models.py b/apps/backend/hub_platform/products/models.py index ebe6f54..91a4bda 100644 --- a/apps/backend/hub_platform/products/models.py +++ b/apps/backend/hub_platform/products/models.py @@ -5,6 +5,7 @@ from django.db import models from django.db.models import Q from hub_platform.identity.crypto import EncryptedCharField +from hub_platform.tenancy.models import TenantRelationModel class ProductStatus(models.TextChoices): @@ -38,7 +39,8 @@ class Product(models.Model): return f"{self.organization.slug}/{self.code}" -class ProductDepartment(models.Model): +class ProductDepartment(TenantRelationModel): + tenant_relation_fields = ("product", "department") product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="department_links") department = models.ForeignKey("identity.Department", on_delete=models.PROTECT, related_name="product_links") created_at = models.DateTimeField(auto_now_add=True) @@ -64,7 +66,8 @@ class OfferPaymentType(models.TextChoices): SUBSCRIPTION = "SUBSCRIPTION", "Подписка" -class Offer(models.Model): +class Offer(TenantRelationModel): + tenant_relation_fields = ("product", "primary_box_offer") product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="offers") code = models.SlugField(max_length=64) name = models.CharField(max_length=255) @@ -117,7 +120,8 @@ class BillingPeriod(models.TextChoices): YEAR = "YEAR", "Год" -class Price(models.Model): +class Price(TenantRelationModel): + tenant_relation_fields = ("offer",) offer = models.ForeignKey(Offer, on_delete=models.PROTECT, related_name="prices") version = models.PositiveIntegerField() amount_minor = models.PositiveBigIntegerField() @@ -158,7 +162,8 @@ class MarketplacePublicationStatus(models.TextChoices): DISABLED = "DISABLED", "Отключено" -class MarketplacePublication(models.Model): +class MarketplacePublication(TenantRelationModel): + tenant_relation_fields = ("price",) marketplace_code = models.SlugField(max_length=64) price = models.ForeignKey(Price, on_delete=models.PROTECT, related_name="marketplace_publications") status = models.CharField( diff --git a/apps/backend/hub_platform/products/services.py b/apps/backend/hub_platform/products/services.py index 597f081..45bb661 100644 --- a/apps/backend/hub_platform/products/services.py +++ b/apps/backend/hub_platform/products/services.py @@ -99,6 +99,7 @@ def create_offer(*, context: TenantContext, product: Product, data: OfferInput) if product.organization_id != context.organization_id: raise ValidationError({"product": "Product belongs to another organization"}) offer = Offer( + organization=context.organization, product=product, code=data.code.strip().lower(), name=data.name.strip(), @@ -152,6 +153,7 @@ def add_price_version(*, context: TenantContext, offer: Offer, data: PriceInput) same_line.filter(is_active=True).update(is_active=False, valid_until=valid_from) last = same_line.order_by("-version").first() price = Price( + organization=context.organization, offer=offer, version=last.version + 1 if last is not None else 1, amount_minor=data.amount_minor, diff --git a/apps/backend/hub_platform/products/views.py b/apps/backend/hub_platform/products/views.py index cf3233f..a80271b 100644 --- a/apps/backend/hub_platform/products/views.py +++ b/apps/backend/hub_platform/products/views.py @@ -96,7 +96,6 @@ class ProductDetailView(APIView): required_capability = "products.view" def get(self, request: Request, product_id: int) -> Response: - profile = request.tenant_context.membership try: product = product_for_context(context=request.tenant_context, product_id=product_id) except Product.DoesNotExist: diff --git a/apps/backend/hub_platform/sales/management/commands/import_legacy_orders.py b/apps/backend/hub_platform/sales/management/commands/import_legacy_orders.py index 2459cd1..dc19084 100644 --- a/apps/backend/hub_platform/sales/management/commands/import_legacy_orders.py +++ b/apps/backend/hub_platform/sales/management/commands/import_legacy_orders.py @@ -20,10 +20,11 @@ from __future__ import annotations from collections import Counter -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from django.db.models import QuerySet from hub_platform.orders.models import Order +from hub_platform.identity.models import Organization from hub_platform.products.models import Product from hub_platform.sales.services import ( LEGACY_CLASS_PENDING, @@ -31,6 +32,8 @@ from hub_platform.sales.services import ( import_legacy_order, provision_product_sales_source, ) +from hub_platform.tenancy.context import TenantActorKind, TenantContext +from hub_platform.tenancy.database import tenant_atomic class Command(BaseCommand): @@ -40,19 +43,32 @@ class Command(BaseCommand): parser.add_argument("--apply", action="store_true", help="Импортировать подтверждённые заказы в Sale/SaleEvent") parser.add_argument("--provision-sources", action="store_true", help="Создать SalesSource и скопировать ingest_token_hash") parser.add_argument("--product", default="", help="Код продукта (по умолчанию — все)") + parser.add_argument("--organization", required=True, help="Organization public UUID") - def _orders(self, product_code: str) -> QuerySet[Order]: - orders = Order.objects.select_related("organization", "product", "contact", "conversation").prefetch_related("items") + def _orders(self, context: TenantContext, product_code: str) -> QuerySet[Order]: + orders = Order.objects.filter(organization=context.organization).select_related("organization", "product", "contact", "conversation").prefetch_related("items") if product_code: orders = orders.filter(product__code=product_code) return orders.order_by("id") def handle(self, *args: object, **options: object) -> None: + try: + organization = Organization.objects.get(public_id=options["organization"]) + except (Organization.DoesNotExist, ValueError) as error: + raise CommandError("Unknown organization public UUID") from error + context = TenantContext.for_resource( + organization, + actor_kind=TenantActorKind.SYSTEM, + ) + with tenant_atomic(context): + self._handle_for_tenant(context=context, options=options) + + def _handle_for_tenant(self, *, context: TenantContext, options: dict) -> None: apply = bool(options["apply"]) provision = bool(options["provision_sources"]) product_code = str(options["product"]) - orders = self._orders(product_code) + orders = self._orders(context, product_code) classes = Counter(classify_legacy_order(order) for order in orders) total = sum(classes.values()) @@ -64,7 +80,7 @@ class Command(BaseCommand): self.stdout.write(f" требуют ручного решения (PENDING): {classes.get(LEGACY_CLASS_PENDING, 0)}") if provision: - self._provision(product_code) + self._provision(context, product_code) if not apply: self.stdout.write(self.style.WARNING("DRY-RUN: изменения не внесены. Повторите с --apply для импорта.")) @@ -84,8 +100,8 @@ class Command(BaseCommand): self.stdout.write(self.style.SUCCESS(f"Импортировано: {created}; уже были: {skipped}; пропущено PENDING: {pending}")) self.stdout.write("Legacy orders НЕ удалены и остаются read-only источником до отдельного подтверждения владельца (§11 шаг 12).") - def _provision(self, product_code: str) -> None: - products = Product.objects.all() + def _provision(self, context: TenantContext, product_code: str) -> None: + products = Product.objects.filter(organization=context.organization) if product_code: products = products.filter(code=product_code) self.stdout.write(self.style.MIGRATE_HEADING("Provisioning SalesSource:")) diff --git a/apps/backend/hub_platform/sales/management/commands/issue_sales_source_credential.py b/apps/backend/hub_platform/sales/management/commands/issue_sales_source_credential.py index f415e50..0a5ae57 100644 --- a/apps/backend/hub_platform/sales/management/commands/issue_sales_source_credential.py +++ b/apps/backend/hub_platform/sales/management/commands/issue_sales_source_credential.py @@ -13,6 +13,8 @@ from hub_platform.identity.models import Organization from hub_platform.products.models import Product from hub_platform.sales.models import Environment, SalesSource, SalesSourceType from hub_platform.sales.services import issue_sales_source_credential +from hub_platform.tenancy.context import TenantActorKind, TenantContext +from hub_platform.tenancy.database import tenant_atomic class Command(BaseCommand): @@ -30,19 +32,24 @@ class Command(BaseCommand): organization = Organization.objects.get(public_id=options["organization"]) except (Organization.DoesNotExist, ValueError) as error: raise CommandError("Unknown organization public UUID") from error - product = Product.objects.filter( - organization=organization, code=code - ).select_related("organization").first() - if product is None: - raise CommandError(f"product '{code}' not found") - - source, created = SalesSource.objects.get_or_create( - organization=product.organization, - product=product, - code=str(options["code"]), - defaults={"type": SalesSourceType.PRODUCT_API, "environment": str(options["environment"])}, + context = TenantContext.for_resource( + organization, + actor_kind=TenantActorKind.SYSTEM, ) - raw = issue_sales_source_credential(source=source) + with tenant_atomic(context): + product = Product.objects.filter( + organization=organization, code=code + ).select_related("organization").first() + if product is None: + raise CommandError(f"product '{code}' not found") + + source, created = SalesSource.objects.get_or_create( + organization=product.organization, + product=product, + code=str(options["code"]), + defaults={"type": SalesSourceType.PRODUCT_API, "environment": str(options["environment"])}, + ) + raw = issue_sales_source_credential(source=source) verb = "created" if created else "rotated" self.stdout.write(self.style.SUCCESS(f"Product Sales API source {verb} for {code} ({source.environment}); key (shown once):")) self.stdout.write(raw) diff --git a/apps/backend/hub_platform/sales/product_sales_urls.py b/apps/backend/hub_platform/sales/product_sales_urls.py index 6b34e6e..d503888 100644 --- a/apps/backend/hub_platform/sales/product_sales_urls.py +++ b/apps/backend/hub_platform/sales/product_sales_urls.py @@ -1,9 +1,13 @@ from django.urls import path -from hub_platform.sales import views +from hub_platform.sales import public_views # Канонический Product Sales API (SPEC-HUB-0014 §4.1): # POST https://hub.edevs.tech/api/v1/product-sales/events urlpatterns = [ - path("events", views.ProductSalesEventView.as_view(), name="product-sales-events"), + path( + "events", + public_views.ProductSalesEventView.as_view(), + name="product-sales-events", + ), ] diff --git a/apps/backend/hub_platform/sales/public_views.py b/apps/backend/hub_platform/sales/public_views.py new file mode 100644 index 0000000..65aa7af --- /dev/null +++ b/apps/backend/hub_platform/sales/public_views.py @@ -0,0 +1,103 @@ +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.models import Organization +from hub_platform.sales.models import SalesSource, SalesSourceStatus +from hub_platform.sales.services import ( + SalesApiError, + hash_credential, + record_product_sales_event, +) +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.ingress import sales_source_route + + +def _api_error(error: SalesApiError) -> Response: + return Response( + {"detail": error.message, "error": error.error_code}, + status=error.status_code, + ) + + +def _bearer(request: Request) -> str: + header = request.headers.get("Authorization", "") + if header.startswith("Bearer "): + return header[len("Bearer ") :].strip() + return "" + + +class ProductSalesEventView(APIView): + """Канонический вход Product Sales API (SPEC-HUB-0014 §4).""" + + permission_classes = [AllowAny] + authentication_classes: list = [] + + def post(self, request: Request) -> Response: + credential = _bearer(request) + credential_hash = hash_credential(credential) if credential else "" + route = sales_source_route(credential_hash) if credential_hash else None + if route is None: + return self._invalid_credential() + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist: + return self._invalid_credential() + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + source = SalesSource.objects.select_related( + "product", "organization" + ).filter( + id=route.resource_id, + organization=organization, + credential_hash=credential_hash, + status=SalesSourceStatus.ACTIVE, + ).first() + if source is None: + return self._invalid_credential() + return self._post_for_source(request, context=context, source=source) + + def _post_for_source( + self, + request: Request, + *, + context: TenantContext, + source: SalesSource, + ) -> Response: + try: + result = record_product_sales_event( + context=context, + source=source, + payload=request.data, + ) + except SalesApiError as error: + record_audit_event( + action="sales.event_rejected", + actor=None, + organization=source.organization, + object_type="SalesSource", + object_id=str(source.id), + payload={"error": error.error_code}, + request=request, + ) + return _api_error(error) + + body = { + "accepted": True, + "duplicate": result.duplicate, + "event_id": result.event.external_event_id, + } + return Response(body, status=200 if result.duplicate else 202) + + @staticmethod + def _invalid_credential() -> Response: + return Response( + { + "detail": "Invalid or revoked credential", + "error": "invalid_credential", + }, + status=401, + ) diff --git a/apps/backend/hub_platform/sales/services.py b/apps/backend/hub_platform/sales/services.py index 037da58..a7d1e5d 100644 --- a/apps/backend/hub_platform/sales/services.py +++ b/apps/backend/hub_platform/sales/services.py @@ -80,16 +80,6 @@ def hash_credential(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() -def resolve_sales_source_by_credential(token: str) -> SalesSource | None: - if not token: - return None - return ( - SalesSource.objects.select_related("product", "organization") - .filter(credential_hash=hash_credential(token)) - .first() - ) - - # --- Разбор конверта Product Sales API (SPEC §4.3) --- diff --git a/apps/backend/hub_platform/sales/tests.py b/apps/backend/hub_platform/sales/tests.py index e430027..44b5458 100644 --- a/apps/backend/hub_platform/sales/tests.py +++ b/apps/backend/hub_platform/sales/tests.py @@ -374,7 +374,11 @@ class LegacyMigrationTests(TestCase): from django.core.management import call_command self._order(payment_status="PAID", source="firepage", external_id="fp-dry") - call_command("import_legacy_orders") + call_command( + "import_legacy_orders", + "--organization", + str(self.organization.public_id), + ) self.assertEqual(Sale.objects.count(), 0) def test_apply_imports_and_skips_pending(self) -> None: @@ -383,7 +387,12 @@ class LegacyMigrationTests(TestCase): self._order(payment_status="PAID", source="firepage", external_id="fp-a") self._order(payment_status="CANCELLED") self._order(payment_status="PENDING") - call_command("import_legacy_orders", "--apply") + call_command( + "import_legacy_orders", + "--organization", + str(self.organization.public_id), + "--apply", + ) self.assertEqual(Sale.objects.count(), 2) self.assertEqual(Sale.objects.filter(status=SaleStatus.CANCELLED).count(), 1) diff --git a/apps/backend/hub_platform/sales/views.py b/apps/backend/hub_platform/sales/views.py index ab7ebc0..05186b2 100644 --- a/apps/backend/hub_platform/sales/views.py +++ b/apps/backend/hub_platform/sales/views.py @@ -2,7 +2,6 @@ from __future__ import annotations from django.utils import timezone from django.utils.dateparse import parse_datetime -from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -25,59 +24,13 @@ from hub_platform.sales.services import ( create_manual_sale, issue_attribution_token, record_manual_action, - record_product_sales_event, - resolve_sales_source_by_credential, ) -from hub_platform.tenancy.context import TenantContext def _api_error(error: SalesApiError) -> Response: return Response({"detail": error.message, "error": error.error_code}, status=error.status_code) -def _bearer(request: Request) -> str: - header = request.headers.get("Authorization", "") - if header.startswith("Bearer "): - return header[len("Bearer ") :].strip() - return "" - - -class ProductSalesEventView(APIView): - """Канонический вход Product Sales API (SPEC-HUB-0014 §4). - - Аутентификация — Bearer-ключ конкретного SalesSource, без пользовательской - сессии. Идемпотентно по (source, event_id). - """ - - permission_classes = [AllowAny] - authentication_classes: list = [] # только Bearer источника, не сессия пользователя - - def post(self, request: Request) -> Response: - source = resolve_sales_source_by_credential(_bearer(request)) - if source is None: - return Response({"detail": "Invalid or revoked credential", "error": "invalid_credential"}, status=401) - context = TenantContext.for_resource(source.organization) - - try: - result = record_product_sales_event( - context=context, source=source, payload=request.data - ) - except SalesApiError as error: - record_audit_event( - action="sales.event_rejected", - actor=None, - organization=source.organization, - object_type="SalesSource", - object_id=str(source.id), - payload={"error": error.error_code}, - request=request, - ) - return _api_error(error) - - body = {"accepted": True, "duplicate": result.duplicate, "event_id": result.event.external_event_id} - return Response(body, status=200 if result.duplicate else 202) - - class _Base(APIView): def _org(self, request: Request): return request.tenant_context.organization diff --git a/apps/backend/hub_platform/support/public_urls.py b/apps/backend/hub_platform/support/public_urls.py index 413b626..e310b6d 100644 --- a/apps/backend/hub_platform/support/public_urls.py +++ b/apps/backend/hub_platform/support/public_urls.py @@ -1,12 +1,16 @@ from django.urls import path -from hub_platform.support import views +from hub_platform.support import public_views urlpatterns = [ - path("sessions/", views.SupportSessionStartView.as_view(), name="support-session-start"), + path( + "sessions/", + public_views.SupportSessionStartView.as_view(), + name="support-session-start", + ), path( "sessions/messages/", - views.SupportSessionMessagesView.as_view(), + public_views.SupportSessionMessagesView.as_view(), name="support-session-messages", ), ] diff --git a/apps/backend/hub_platform/support/public_views.py b/apps/backend/hub_platform/support/public_views.py new file mode 100644 index 0000000..735cc8a --- /dev/null +++ b/apps/backend/hub_platform/support/public_views.py @@ -0,0 +1,169 @@ +from django.db import models +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from hub_platform.channels.models import Channel +from hub_platform.conversations.models import Conversation +from hub_platform.conversations.serializers import conversation_payload +from hub_platform.identity.models import Organization +from hub_platform.support import errors +from hub_platform.support.messages import post_support_message, support_messages_since +from hub_platform.support.serializers import support_identity_snapshot_payload +from hub_platform.support.session import start_support_session +from hub_platform.support.token import verify_support_token +from hub_platform.support.widget_credential import verify_widget_credential +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.ingress import ( + support_channel_routes, + support_conversation_route, +) + + +class _Public(APIView): + authentication_classes: list = [] + permission_classes = [AllowAny] + + +class SupportSessionStartView(_Public): + def post(self, request: Request) -> Response: + channel_code = str(request.data.get("channel", "")).strip() + token = str(request.data.get("token", "")) + if not channel_code or not token: + return _denied() + routes = support_channel_routes(channel_code) + if len(routes) == 1: + route = routes[0] + else: + verified = [] + for candidate in routes: + try: + verify_support_token(token=token, secret=candidate.support_secret) + except errors.SupportSessionError: + continue + verified.append(candidate) + if len(verified) != 1: + return _denied() + route = verified[0] + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist: + return _denied() + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + channel = Channel.objects.select_related( + "department", "product", "organization" + ).filter( + id=route.resource_id, + organization=organization, + code=channel_code, + is_active=True, + ).first() + if channel is None: + return _denied() + try: + result = start_support_session( + channel=channel, + token=token, + request=request, + ) + except errors.SupportSessionError: + return _denied() + return Response( + { + "conversation": conversation_payload( + result["conversation"], + with_messages=True, + ), + "snapshot": support_identity_snapshot_payload(result["snapshot"]), + "widgetCredential": result["widget_credential"], + }, + status=201, + ) + + +def _widget_context(request: Request): + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return None + claims = verify_widget_credential(auth[7:]) + if claims is None: + return None + route = support_conversation_route( + claims["conversation_id"], + claims["snapshot_id"], + ) + if route is None: + return None + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist: + return None + return TenantContext.for_resource(organization), claims + + +def _resolve_widget_conversation( + context: TenantContext, + claims, +) -> Conversation | None: + return ( + Conversation.objects.select_related( + "organization", "channel", "support_identity_snapshot" + ) + .filter( + id=claims["conversation_id"], + support_identity_snapshot_id=claims["snapshot_id"], + organization=context.organization, + organization_id=models.F("support_identity_snapshot__organization_id"), + channel__organization_id=models.F("organization_id"), + ) + .first() + ) + + +class SupportSessionMessagesView(_Public): + def get(self, request: Request) -> Response: + resolved = _widget_context(request) + if resolved is None: + return Response({"detail": "Сессия не найдена"}, status=401) + context, claims = resolved + try: + since = int(request.GET.get("since", "0") or 0) + except ValueError: + since = 0 + with tenant_atomic(context): + conversation = _resolve_widget_conversation(context, claims) + if conversation is None: + return Response({"detail": "Сессия не найдена"}, status=401) + return Response(support_messages_since(conversation, since)) + + def post(self, request: Request) -> Response: + resolved = _widget_context(request) + if resolved is None: + return Response({"detail": "Сессия не найдена"}, status=401) + text = str(request.data.get("text", "")).strip() + if not text: + return Response({"detail": "Пустое сообщение"}, status=400) + context, claims = resolved + with tenant_atomic(context): + conversation = _resolve_widget_conversation(context, claims) + if conversation is None: + return Response({"detail": "Сессия не найдена"}, status=401) + post_support_message( + context=context, + conversation=conversation, + text=text[:4000], + ) + return Response({"ok": True}, status=201) + + +def _denied() -> Response: + return Response( + { + "error": "support_unavailable", + "message": errors.PUBLIC_SUPPORT_UNAVAILABLE, + }, + status=422, + ) diff --git a/apps/backend/hub_platform/support/views.py b/apps/backend/hub_platform/support/views.py index 3afada6..5075cdb 100644 --- a/apps/backend/hub_platform/support/views.py +++ b/apps/backend/hub_platform/support/views.py @@ -1,18 +1,11 @@ from django.core.exceptions import ValidationError -from django.db import models -from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView from hub_platform.api.permissions import HasCapability -from hub_platform.channels.models import Channel -from hub_platform.conversations.models import Conversation -from hub_platform.conversations.serializers import conversation_payload from hub_platform.identity.audit import record_audit_event from hub_platform.identity.policy import accessible_department_ids -from hub_platform.support import errors -from hub_platform.support.messages import post_support_message, support_messages_since from hub_platform.support.models import ProductSupportContract from hub_platform.support.selectors import contract_for_context, contracts_for_context from hub_platform.support.serializers import ( @@ -20,9 +13,6 @@ from hub_platform.support.serializers import ( support_identity_snapshot_payload, ) from hub_platform.support.services import ContractInput, register_contract, set_contract_status -from hub_platform.support.session import start_support_session, verify_and_resolve -from hub_platform.support.widget_credential import verify_widget_credential -from hub_platform.tenancy.context import TenantContext def _validation_error(error: ValidationError) -> Response: @@ -34,12 +24,6 @@ def _validation_error(error: ValidationError) -> Response: return Response({"detail": detail}, status=400) -class _Public(APIView): - # Продукт → Hub: нет пользовательской сессии Django, нет CSRF. - authentication_classes: list = [] - permission_classes = [AllowAny] - - class _ManagerBase(APIView): permission_classes = [HasCapability] required_capability = "products.manage" @@ -135,98 +119,6 @@ class SupportContractStatusView(_ManagerBase): return Response({"contract": support_contract_payload(contract)}) -class SupportSessionStartView(_Public): - def post(self, request: Request) -> Response: - channel_code = str(request.data.get("channel", "")).strip() - token = str(request.data.get("token", "")) - if not channel_code or not token: - return _denied() - candidates = list(Channel.objects.select_related( - "department", "product", "organization" - ).filter(code=channel_code, is_active=True)) - if len(candidates) == 1: - # The organization is unambiguous, so the domain service owns - # validation and records its normal denied audit when necessary. - channel = candidates[0] - else: - # A public channel code may exist in multiple organizations. Resolve - # it only by a uniquely valid product token; never pick the first. - verified = [] - for candidate in candidates: - try: - verify_and_resolve(channel=candidate, token=token) - except errors.SupportSessionError: - continue - verified.append(candidate) - if len(verified) != 1: - return _denied() - channel = verified[0] - try: - result = start_support_session(channel=channel, token=token, request=request) - except errors.SupportSessionError: - # Audit DENIED уже записан в сервисе; публичный ответ безопасный. - return _denied() - return Response( - { - "conversation": conversation_payload(result["conversation"], with_messages=True), - "snapshot": support_identity_snapshot_payload(result["snapshot"]), - "widgetCredential": result["widget_credential"], - }, - status=201, - ) - - -def _resolve_widget_conversation(request: Request) -> Conversation | None: - """Возвращает conversation по widget-credential (Authorization: Bearer) или None.""" - auth = request.headers.get("Authorization", "") - if not auth.startswith("Bearer "): - return None - claims = verify_widget_credential(auth[7:]) - if claims is None: - return None - conversation = ( - Conversation.objects.select_related( - "organization", "channel", "support_identity_snapshot" - ) - .filter( - id=claims["conversation_id"], - support_identity_snapshot_id=claims["snapshot_id"], - organization_id=models.F("support_identity_snapshot__organization_id"), - channel__organization_id=models.F("organization_id"), - ) - .first() - ) - return conversation - - -class SupportSessionMessagesView(_Public): - # Polling (GET) и отправка (POST) сообщений support-диалога виджетом. - # Авторизация — stateless widget-credential (Bearer), выданный при старте сессии. - def get(self, request: Request) -> Response: - conversation = _resolve_widget_conversation(request) - if conversation is None: - return Response({"detail": "Сессия не найдена"}, status=401) - try: - since = int(request.GET.get("since", "0") or 0) - except ValueError: - since = 0 - return Response(support_messages_since(conversation, since)) - - def post(self, request: Request) -> Response: - conversation = _resolve_widget_conversation(request) - if conversation is None: - return Response({"detail": "Сессия не найдена"}, status=401) - text = str(request.data.get("text", "")).strip() - if not text: - return Response({"detail": "Пустое сообщение"}, status=400) - post_support_message( - context=TenantContext.for_resource(conversation.organization), - conversation=conversation, - text=text[:4000], - ) - return Response({"ok": True}, status=201) - - class SupportSnapshotsBySubjectView(APIView): # История обращений клиента для правой панели оператора (этап 1 — минимально). permission_classes = [HasCapability] @@ -258,10 +150,3 @@ class SupportSnapshotsBySubjectView(APIView): ).distinct() snapshots = snapshots[:20] return Response({"items": [support_identity_snapshot_payload(s) for s in snapshots]}) - - -def _denied() -> Response: - return Response( - {"error": "support_unavailable", "message": errors.PUBLIC_SUPPORT_UNAVAILABLE}, - status=422, - ) diff --git a/apps/backend/hub_platform/tenancy/apps.py b/apps/backend/hub_platform/tenancy/apps.py new file mode 100644 index 0000000..aef7f1d --- /dev/null +++ b/apps/backend/hub_platform/tenancy/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TenancyConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "hub_platform.tenancy" diff --git a/apps/backend/hub_platform/tenancy/database.py b/apps/backend/hub_platform/tenancy/database.py new file mode 100644 index 0000000..dc0c9f9 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/database.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any, Callable + +from django.db import DEFAULT_DB_ALIAS, connections, transaction + +from hub_platform.tenancy.context import TenantContext + +ORGANIZATION_SETTING = "custocrm.organization_id" + + +def _organization_id(context_or_id: TenantContext | int) -> int: + if isinstance(context_or_id, TenantContext): + return int(context_or_id.organization_id) + return int(context_or_id) + + +def set_local_tenant( + context_or_id: TenantContext | int, + *, + using: str = DEFAULT_DB_ALIAS, +) -> None: + """Set transaction-local RLS context; never mutates pooled session state.""" + + connection = connections[using] + if not connection.in_atomic_block: + raise RuntimeError("Tenant database context requires transaction.atomic()") + organization_id = _organization_id(context_or_id) + with connection.cursor() as cursor: + cursor.execute("SELECT current_setting(%s, true)", [ORGANIZATION_SETTING]) + current = cursor.fetchone()[0] + if current not in {None, "", str(organization_id)}: + raise RuntimeError("Cannot switch tenant inside an active transaction") + cursor.execute( + "SELECT set_config(%s, %s, true)", + [ORGANIZATION_SETTING, str(organization_id)], + ) + + +@contextmanager +def tenant_atomic( + context_or_id: TenantContext | int, + *, + using: str = DEFAULT_DB_ALIAS, +) -> Iterator[None]: + connection = connections[using] + nested = connection.in_atomic_block + previous = "" + if nested: + with connection.cursor() as cursor: + cursor.execute("SELECT current_setting(%s, true)", [ORGANIZATION_SETTING]) + previous = cursor.fetchone()[0] or "" + with transaction.atomic(using=using): + set_local_tenant(context_or_id, using=using) + yield + if nested: + # SET LOCAL survives a released savepoint. Restore the parent scope so + # a nested tenant operation cannot leak into its technical transaction. + with connection.cursor() as cursor: + cursor.execute( + "SELECT set_config(%s, %s, true)", + [ORGANIZATION_SETTING, previous], + ) + + +def current_tenant_id(*, using: str = DEFAULT_DB_ALIAS) -> int | None: + with connections[using].cursor() as cursor: + cursor.execute("SELECT current_setting(%s, true)", [ORGANIZATION_SETTING]) + value = cursor.fetchone()[0] + return int(value) if value and value.isdigit() else None + + +def run_tenant_operation( + context: TenantContext, + operation: Callable[..., Any], + *args: Any, + **kwargs: Any, +) -> Any: + with tenant_atomic(context): + return operation(context, *args, **kwargs) diff --git a/apps/backend/hub_platform/tenancy/ingress.py b/apps/backend/hub_platform/tenancy/ingress.py new file mode 100644 index 0000000..98b3366 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/ingress.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from django.conf import settings +from django.db import connections + +from hub_platform.identity.crypto import decrypt_secret + + +@dataclass(frozen=True, slots=True) +class IngressRoute: + organization_id: int + resource_id: int | str + + +@dataclass(frozen=True, slots=True) +class SupportIngressRoute(IngressRoute): + support_secret: str + + +def _rows(query: str, parameters: list[Any]) -> list[tuple]: + alias = "default" if settings.TESTING else "platform" + with connections[alias].cursor() as cursor: + cursor.execute(query, parameters) + return list(cursor.fetchall()) + + +def _unique_route(view: str, lookup_key: str) -> IngressRoute | None: + rows = _rows( + f"SELECT organization_id, resource_id FROM custocrm.{view} " + "WHERE lookup_key = %s ORDER BY resource_id LIMIT 2", + [lookup_key], + ) + if len(rows) != 1: + return None + return IngressRoute(organization_id=int(rows[0][0]), resource_id=rows[0][1]) + + +def membership_routes_for_user(user_id: int) -> list[IngressRoute]: + return [ + IngressRoute(organization_id=int(row[0]), resource_id=int(row[1])) + for row in _rows( + "SELECT organization_id, resource_id FROM custocrm.membership_directory " + "WHERE user_id = %s AND blocked_at IS NULL ORDER BY organization_id, resource_id", + [user_id], + ) + ] + + +def user_requires_totp(user_id: int) -> bool: + return bool( + _rows( + "SELECT 1 FROM custocrm.membership_directory " + "WHERE user_id = %s AND blocked_at IS NULL AND totp_required LIMIT 1", + [user_id], + ) + ) + + +def product_ingest_route(credential_hash: str) -> IngressRoute | None: + return _unique_route("product_ingest_directory", credential_hash) + + +def sales_source_route(credential_hash: str) -> IngressRoute | None: + return _unique_route("sales_source_directory", credential_hash) + + +def attachment_route(public_id: str) -> IngressRoute | None: + return _unique_route("attachment_directory", public_id) + + +def call_invite_route(token_hash: str) -> IngressRoute | None: + return _unique_route("call_invite_directory", token_hash) + + +def call_session_route(call_session_id: str) -> IngressRoute | None: + return _unique_route("call_session_directory", call_session_id) + + +def web_session_route(token_hash: str) -> IngressRoute | None: + return _unique_route("web_session_directory", token_hash) + + +def web_channel_route(channel_code: str) -> IngressRoute | None: + return _unique_route("web_channel_directory", channel_code) + + +def support_channel_routes(channel_code: str) -> list[SupportIngressRoute]: + return [ + SupportIngressRoute( + organization_id=int(row[0]), + resource_id=int(row[1]), + support_secret=decrypt_secret(row[2]), + ) + for row in _rows( + "SELECT organization_id, resource_id, support_token_secret " + "FROM custocrm.support_channel_directory WHERE lookup_key = %s " + "ORDER BY resource_id", + [channel_code], + ) + ] + + +def support_conversation_route( + conversation_id: int, + snapshot_id: int, +) -> IngressRoute | None: + rows = _rows( + "SELECT organization_id, resource_id FROM custocrm.support_conversation_directory " + "WHERE resource_id = %s AND snapshot_id = %s LIMIT 2", + [conversation_id, snapshot_id], + ) + if len(rows) != 1: + return None + return IngressRoute(organization_id=int(rows[0][0]), resource_id=int(rows[0][1])) diff --git a/apps/backend/hub_platform/tenancy/management/__init__.py b/apps/backend/hub_platform/tenancy/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/hub_platform/tenancy/management/commands/__init__.py b/apps/backend/hub_platform/tenancy/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/hub_platform/tenancy/management/commands/migrate_tenant_media.py b/apps/backend/hub_platform/tenancy/management/commands/migrate_tenant_media.py new file mode 100644 index 0000000..1f8ed98 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/management/commands/migrate_tenant_media.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError + +from hub_platform.ai.models import KnowledgeAttachment +from hub_platform.identity.models import Organization +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.media_migration import ( + copy_attachment, + reconcile_attachment_storage_usage, +) + + +class Command(BaseCommand): + help = ( + "Copy one organization's legacy media to tenant-prefixed storage, verify " + "SHA-256 and write an immutable manifest. Source files are never deleted." + ) + + def add_arguments(self, parser) -> None: + parser.add_argument("--organization-public-id", required=True) + parser.add_argument("--source-root", required=True) + parser.add_argument("--manifest", required=True) + parser.add_argument( + "--apply", + action="store_true", + help="Copy and update DB keys. Without this flag only verification runs.", + ) + + def handle(self, *args, **options) -> None: + try: + organization = Organization.objects.get( + public_id=options["organization_public_id"] + ) + except (Organization.DoesNotExist, ValueError) as error: + raise CommandError("Organization not found") from error + + source_root = Path(options["source_root"]) + manifest_path = Path(options["manifest"]) + if not source_root.is_dir(): + raise CommandError(f"Source root does not exist: {source_root}") + if manifest_path.exists(): + raise CommandError("Manifest already exists; refusing to overwrite it") + + context = TenantContext.for_resource(organization) + entries = [] + try: + with tenant_atomic(context): + attachments = list( + KnowledgeAttachment.objects.filter(organization=organization) + .select_related("knowledge", "organization") + .order_by("id") + ) + for attachment in attachments: + entries.append( + copy_attachment( + attachment=attachment, + source_root=source_root, + apply=options["apply"], + ) + ) + usage = ( + reconcile_attachment_storage_usage(context=context) + if options["apply"] + else sum(entry.size for entry in entries) + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text( + json.dumps( + { + "version": 1, + "createdAt": datetime.now(UTC).isoformat(), + "organizationPublicId": str(organization.public_id), + "apply": bool(options["apply"]), + "sourceRoot": str(source_root.resolve()), + "storageBytes": usage, + "entries": [entry.payload() for entry in entries], + "sourceDeleted": False, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + except (FileNotFoundError, OSError, ValueError) as error: + raise CommandError(str(error)) from error + self.stdout.write( + self.style.SUCCESS( + f"Verified {len(entries)} objects, {usage} bytes; manifest={manifest_path}" + ) + ) diff --git a/apps/backend/hub_platform/tenancy/media_migration.py b/apps/backend/hub_platform/tenancy/media_migration.py new file mode 100644 index 0000000..2772cd1 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/media_migration.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import hashlib +from dataclasses import asdict, dataclass +from pathlib import Path + +from django.core.files import File +from django.core.files.storage import default_storage +from django.db import transaction + +from hub_platform.ai.models import KnowledgeAttachment, attachment_upload_path +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.models import OrganizationStorageUsage + + +@dataclass(frozen=True, slots=True) +class MediaCopyEntry: + attachment_id: int + source_key: str + target_key: str + size: int + sha256: str + status: str + + def payload(self) -> dict[str, int | str]: + return asdict(self) + + +def _sha256_path(path: Path) -> tuple[str, int]: + digest = hashlib.sha256() + size = 0 + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + size += len(chunk) + return digest.hexdigest(), size + + +def _sha256_storage(key: str) -> tuple[str, int]: + digest = hashlib.sha256() + size = 0 + with default_storage.open(key, "rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + size += len(chunk) + return digest.hexdigest(), size + + +def _source_path(source_root: Path, source_key: str) -> Path: + root = source_root.resolve() + candidate = (root / source_key).resolve() + if root != candidate and root not in candidate.parents: + raise ValueError(f"Source key escapes source root: {source_key}") + if not candidate.is_file(): + raise FileNotFoundError(candidate) + return candidate + + +def _target_key(attachment: KnowledgeAttachment) -> str: + filename = Path(attachment.original_name).name + return attachment_upload_path(attachment, filename) + + +def copy_attachment( + *, + attachment: KnowledgeAttachment, + source_root: Path, + apply: bool, +) -> MediaCopyEntry: + source_key = attachment.file.name + target_key = _target_key(attachment) + source_path = _source_path(source_root, source_key) + source_hash, source_size = _sha256_path(source_path) + status = "verified" + + if apply: + if default_storage.exists(target_key): + target_hash, target_size = _sha256_storage(target_key) + if (target_hash, target_size) != (source_hash, source_size): + raise ValueError(f"Destination hash mismatch: {target_key}") + status = "already-copied" + else: + with source_path.open("rb") as source: + stored_key = default_storage.save(target_key, File(source)) + if stored_key != target_key: + raise ValueError(f"Storage changed target key to {stored_key}") + target_hash, target_size = _sha256_storage(target_key) + if (target_hash, target_size) != (source_hash, source_size): + raise ValueError(f"Copied object hash mismatch: {target_key}") + status = "copied" + attachment.file.name = target_key + attachment.size = source_size + attachment.save(update_fields=["file", "size"]) + + return MediaCopyEntry( + attachment_id=attachment.id, + source_key=source_key, + target_key=target_key, + size=source_size, + sha256=source_hash, + status=status, + ) + + +@transaction.atomic +def reconcile_attachment_storage_usage(*, context: TenantContext) -> int: + total = sum( + KnowledgeAttachment.objects.filter(organization=context.organization).values_list( + "size", flat=True + ) + ) + OrganizationStorageUsage.objects.update_or_create( + organization=context.organization, + defaults={"bytes_used": total}, + ) + return total diff --git a/apps/backend/hub_platform/tenancy/middleware.py b/apps/backend/hub_platform/tenancy/middleware.py index ee0ff1a..846f4e1 100644 --- a/apps/backend/hub_platform/tenancy/middleware.py +++ b/apps/backend/hub_platform/tenancy/middleware.py @@ -1,49 +1,71 @@ from __future__ import annotations from collections.abc import Callable +import re +import uuid from django.http import Http404, HttpRequest, HttpResponse from hub_platform.events.context import get_correlation_id -from hub_platform.identity.models import OrganizationMembership +from hub_platform.identity.models import Organization, OrganizationMembership from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic class TenantContextMiddleware: """Resolve an authenticated membership from the organization URL UUID.""" route_kwarg = "organization_public_id" + route_pattern = re.compile( + r"^/api/v1/organizations/" + r"(?P[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})(?:/|$)" + ) def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: - return self.get_response(request) + match = self.route_pattern.match(request.path_info) + if match is None: + return self.get_response(request) + if not request.user.is_authenticated or not request.user.is_active: + raise Http404 + try: + public_id = uuid.UUID(match.group("public_id")) + organization = Organization.objects.get(public_id=public_id) + except (ValueError, Organization.DoesNotExist) as error: + raise Http404 from error + + with tenant_atomic(organization.pk): + try: + membership = ( + OrganizationMembership.objects.select_related( + "organization", "user", "primary_department" + ) + .get( + organization=organization, + user=request.user, + blocked_at__isnull=True, + ) + ) + except OrganizationMembership.DoesNotExist as error: + raise Http404 from error + if membership.totp_required and not request.user.totp_enabled: + raise Http404 + request.tenant_context = TenantContext.for_membership( + membership, + correlation_id=get_correlation_id(), + ) + return self.get_response(request) def process_view(self, request: HttpRequest, view_func, view_args, view_kwargs): public_id = view_kwargs.get(self.route_kwarg) if public_id is None: return None - if not request.user.is_authenticated or not request.user.is_active: + if getattr(request, "tenant_context", None) is None: raise Http404 - try: - membership = ( - OrganizationMembership.objects.select_related( - "organization", "user", "primary_department" - ) - .get( - organization__public_id=public_id, - user=request.user, - blocked_at__isnull=True, - ) - ) - except OrganizationMembership.DoesNotExist as error: - raise Http404 from error - if membership.totp_required and not request.user.totp_enabled: + if request.tenant_context.organization.public_id != public_id: raise Http404 - request.tenant_context = TenantContext.for_membership( - membership, - correlation_id=get_correlation_id(), - ) del view_kwargs[self.route_kwarg] return None diff --git a/apps/backend/hub_platform/tenancy/migrations/0001_initial.py b/apps/backend/hub_platform/tenancy/migrations/0001_initial.py new file mode 100644 index 0000000..8c4e424 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('identity', '0013_accessprofilecapability_organization_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='OrganizationStorageUsage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bytes_used', models.PositiveBigIntegerField(default=0)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('organization', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='storage_usage', to='identity.organization')), + ], + options={ + 'constraints': [models.CheckConstraint(condition=models.Q(('bytes_used__gte', 0)), name='storage_usage_bytes_non_negative')], + }, + ), + ] diff --git a/apps/backend/hub_platform/tenancy/migrations/0002_cross_tenant_constraints.py b/apps/backend/hub_platform/tenancy/migrations/0002_cross_tenant_constraints.py new file mode 100644 index 0000000..cc217ec --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0002_cross_tenant_constraints.py @@ -0,0 +1,222 @@ +from django.db import migrations + + +TENANT_FOREIGN_KEYS = ( + ("identity_employeeprofile", "identity_department", "primary_department_id"), + ("identity_accessprofilecapability", "identity_accessprofile", "access_profile_id"), + ("identity_employeeaccessassignment", "identity_employeeprofile", "employee_id"), + ("identity_employeeaccessassignment", "identity_accessprofile", "access_profile_id"), + ("identity_employeeaccessassignment", "identity_department", "department_id"), + ("identity_employeeaccessassignment", "identity_employeeprofile", "assigned_by_id"), + ("identity_organizationinvitation", "identity_employeeprofile", "created_by_id"), + ("products_productdepartment", "identity_product", "product_id"), + ("products_productdepartment", "identity_department", "department_id"), + ("products_offer", "identity_product", "product_id"), + ("products_offer", "products_offer", "primary_box_offer_id"), + ("products_price", "products_offer", "offer_id"), + ("products_marketplacepublication", "products_price", "price_id"), + ("ai_knowledgeattachment", "ai_knowledge", "knowledge_id"), + ("ai_knowledgefragment", "ai_knowledge", "knowledge_id"), + ("ai_aiagent", "channels_channel", "channel_id"), + ("ai_llminvocation", "channels_channel", "channel_id"), + ("ai_llminvocation", "identity_product", "product_id"), + ("integrations_integration", "channels_channel", "channel_id"), + ("channels_channel", "identity_department", "department_id"), + ("channels_channel", "identity_product", "product_id"), + ("channels_channel", "integrations_integration", "provider_integration_id"), + ("conversations_connectionidentity", "conversations_contact", "contact_id"), + ("conversations_connectionidentity", "integrations_integration", "connection_id"), + ("conversations_conversation", "channels_channel", "channel_id"), + ("conversations_conversation", "integrations_integration", "connection_id"), + ("conversations_conversation", "conversations_contact", "contact_id"), + ("conversations_conversation", "support_supportidentitysnapshot", "support_identity_snapshot_id"), + ("conversations_conversation", "conversations_conversation", "previous_conversation_id"), + ("conversations_conversationread", "conversations_conversation", "conversation_id"), + ("conversations_message", "conversations_conversation", "conversation_id"), + ("orders_order", "conversations_contact", "contact_id"), + ("orders_order", "conversations_conversation", "conversation_id"), + ("orders_order", "identity_product", "product_id"), + ("orders_order", "channels_channel", "channel_id"), + ("orders_orderitem", "orders_order", "order_id"), + ("orders_orderitem", "products_offer", "offer_id"), + ("orders_orderitem", "products_price", "price_id"), + ("sales_salessource", "identity_product", "product_id"), + ("sales_sale", "identity_product", "product_id"), + ("sales_sale", "sales_salessource", "sales_source_id"), + ("sales_sale", "conversations_contact", "contact_id"), + ("sales_sale", "conversations_conversation", "conversation_id"), + ("sales_sale", "sales_saleevent", "last_event_id"), + ("sales_saleevent", "sales_salessource", "sales_source_id"), + ("sales_saleevent", "sales_sale", "sale_id"), + ("sales_attributiontoken", "identity_product", "product_id"), + ("sales_attributiontoken", "products_offer", "offer_id"), + ("sales_attributiontoken", "conversations_contact", "contact_id"), + ("sales_attributiontoken", "conversations_conversation", "conversation_id"), + ("sales_attributiontoken", "channels_channel", "channel_id"), + ("sales_attributiontoken", "integrations_integration", "connection_id"), + ("sales_externalcustomeridentity", "identity_product", "product_id"), + ("sales_externalcustomeridentity", "conversations_contact", "contact_id"), + ("support_productsupportcontract", "identity_product", "product_id"), + ("support_supportidentitysnapshot", "identity_product", "product_id"), + ("support_supportidentitysnapshot", "support_productsupportcontract", "contract_id"), + ("calls_callsession", "conversations_conversation", "conversation_id"), + ("calls_callsession", "integrations_integration", "delivery_connection_id"), + ("calls_callinvite", "calls_callsession", "call_session_id"), + ("calls_callinvite", "conversations_connectionidentity", "connection_identity_id"), + ("calls_callparticipant", "calls_callsession", "call_session_id"), + ("calls_callparticipant", "conversations_connectionidentity", "connection_identity_id"), + ("calls_callmetric", "calls_callsession", "call_session_id"), + ("notifications_notification", "identity_department", "department_id"), + ("notifications_notificationread", "notifications_notification", "notification_id"), + ("notifications_messengerbinding", "integrations_integration", "integration_id"), + ("notifications_messengerbindingcode", "integrations_integration", "integration_id"), + ("webchat_websession", "integrations_integration", "connection_id"), + ("webchat_websession", "conversations_connectionidentity", "identity_id"), + ("events_outboxevent", "identity_employeeprofile", "membership_id"), +) + +TENANT_USER_FIELDS = ( + ("conversations_conversation", "assigned_operator_id"), + ("conversations_conversationread", "user_id"), + ("conversations_message", "author_user_id"), + ("calls_callsession", "initiated_by_id"), + ("calls_callparticipant", "user_id"), + ("notifications_notification", "recipient_user_id"), + ("notifications_notificationread", "user_id"), + ("notifications_messengerbinding", "user_id"), + ("notifications_messengerbindingcode", "user_id"), + ("sales_saleevent", "actor_user_id"), +) + +TENANT_PAIRS = ( + ("ai_aiagent_knowledge_items", "ai_aiagent", "aiagent_id", "ai_knowledge", "knowledge_id"), + ("support_productsupportcontract_allowed_channels", "support_productsupportcontract", "productsupportcontract_id", "channels_channel", "channel_id"), +) + + +def add_constraints(apps, schema_editor): + schema_editor.execute("CREATE SCHEMA IF NOT EXISTS custocrm") + schema_editor.execute( + """ + CREATE OR REPLACE FUNCTION custocrm.enforce_tenant_fk() RETURNS trigger + LANGUAGE plpgsql AS $$ + DECLARE parent_org bigint; fk_value text; + BEGIN + fk_value := to_jsonb(NEW) ->> TG_ARGV[1]; + IF fk_value IS NULL THEN RETURN NEW; END IF; + EXECUTE format('SELECT organization_id FROM %%s WHERE id::text = $1', TG_ARGV[0]::regclass) + INTO parent_org USING fk_value; + IF parent_org IS DISTINCT FROM NEW.organization_id THEN + RAISE EXCEPTION 'cross-tenant relation on %%.%%', TG_TABLE_NAME, TG_ARGV[1]; + END IF; + RETURN NEW; + END $$; + + CREATE OR REPLACE FUNCTION custocrm.enforce_tenant_user() RETURNS trigger + LANGUAGE plpgsql AS $$ + DECLARE user_value text; + BEGIN + user_value := to_jsonb(NEW) ->> TG_ARGV[0]; + IF user_value IS NULL THEN RETURN NEW; END IF; + IF NOT EXISTS ( + SELECT 1 FROM identity_employeeprofile membership + WHERE membership.user_id::text = user_value + AND membership.organization_id = NEW.organization_id + ) THEN + RAISE EXCEPTION 'tenant user has no membership on %%.%%', TG_TABLE_NAME, TG_ARGV[0]; + END IF; + RETURN NEW; + END $$; + + CREATE OR REPLACE FUNCTION custocrm.enforce_tenant_pair() RETURNS trigger + LANGUAGE plpgsql AS $$ + DECLARE left_org bigint; right_org bigint; left_value text; right_value text; + BEGIN + left_value := to_jsonb(NEW) ->> TG_ARGV[1]; + right_value := to_jsonb(NEW) ->> TG_ARGV[3]; + EXECUTE format('SELECT organization_id FROM %%s WHERE id::text = $1', TG_ARGV[0]::regclass) + INTO left_org USING left_value; + EXECUTE format('SELECT organization_id FROM %%s WHERE id::text = $1', TG_ARGV[2]::regclass) + INTO right_org USING right_value; + IF left_org IS DISTINCT FROM right_org THEN + RAISE EXCEPTION 'cross-tenant many-to-many relation on %%', TG_TABLE_NAME; + END IF; + RETURN NEW; + END $$; + """ + ) + with schema_editor.connection.cursor() as cursor: + for index, (child, parent, fk_column) in enumerate(TENANT_FOREIGN_KEYS): + cursor.execute( + f"SELECT EXISTS (SELECT 1 FROM {child} child JOIN {parent} parent " + f"ON parent.id = child.{fk_column} WHERE child.{fk_column} IS NOT NULL " + "AND child.organization_id IS DISTINCT FROM parent.organization_id)" + ) + if cursor.fetchone()[0]: + raise RuntimeError(f"C04 preflight: cross-tenant relation {child}.{fk_column}") + schema_editor.execute( + f"CREATE CONSTRAINT TRIGGER c04_tfk_{index} AFTER INSERT OR UPDATE ON {child} " + "DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION " + f"custocrm.enforce_tenant_fk('{parent}', '{fk_column}')" + ) + for index, (table, user_column) in enumerate(TENANT_USER_FIELDS): + cursor.execute( + f"SELECT EXISTS (SELECT 1 FROM {table} item WHERE item.{user_column} IS NOT NULL " + "AND NOT EXISTS (SELECT 1 FROM identity_employeeprofile membership " + f"WHERE membership.user_id = item.{user_column} " + "AND membership.organization_id = item.organization_id))" + ) + if cursor.fetchone()[0]: + raise RuntimeError(f"C04 preflight: tenant user mismatch {table}.{user_column}") + schema_editor.execute( + f"CREATE CONSTRAINT TRIGGER c04_tuser_{index} AFTER INSERT OR UPDATE ON {table} " + "DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION " + f"custocrm.enforce_tenant_user('{user_column}')" + ) + for index, (table, left_table, left_column, right_table, right_column) in enumerate(TENANT_PAIRS): + cursor.execute( + f"SELECT EXISTS (SELECT 1 FROM {table} link " + f"JOIN {left_table} left_item ON left_item.id = link.{left_column} " + f"JOIN {right_table} right_item ON right_item.id = link.{right_column} " + "WHERE left_item.organization_id IS DISTINCT FROM right_item.organization_id)" + ) + if cursor.fetchone()[0]: + raise RuntimeError(f"C04 preflight: cross-tenant relation in {table}") + schema_editor.execute( + f"CREATE CONSTRAINT TRIGGER c04_tpair_{index} AFTER INSERT OR UPDATE ON {table} " + "DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION " + f"custocrm.enforce_tenant_pair('{left_table}', '{left_column}', " + f"'{right_table}', '{right_column}')" + ) + + +def remove_constraints(apps, schema_editor): + for index, (table, _parent, _column) in enumerate(TENANT_FOREIGN_KEYS): + schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_tfk_{index} ON {table}") + for index, (table, _column) in enumerate(TENANT_USER_FIELDS): + schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_tuser_{index} ON {table}") + for index, (table, *_rest) in enumerate(TENANT_PAIRS): + schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_tpair_{index} ON {table}") + schema_editor.execute("DROP FUNCTION IF EXISTS custocrm.enforce_tenant_pair()") + schema_editor.execute("DROP FUNCTION IF EXISTS custocrm.enforce_tenant_user()") + schema_editor.execute("DROP FUNCTION IF EXISTS custocrm.enforce_tenant_fk()") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0001_initial"), + ("identity", "0014_alter_accessprofilecapability_organization_and_more"), + ("products", "0010_alter_marketplacepublication_organization_and_more"), + ("ai", "0006_alter_aiagent_organization_and_more"), + ("integrations", "0002_integration_channel_integration_poll_marker"), + ("channels", "0004_remove_channel_ai_fields"), + ("conversations", "0006_alter_connectionidentity_organization_and_more"), + ("orders", "0004_alter_orderitem_organization"), + ("sales", "0002_employee_actor_type"), + ("support", "0002_alter_productsupportcontract_code"), + ("calls", "0004_alter_callinvite_organization_and_more"), + ("notifications", "0006_alter_messengerbinding_organization_and_more"), + ("webchat", "0003_alter_websession_organization"), + ("events", "0004_inboxevent_organization_inboxevent_ownership_and_more"), + ] + operations = [migrations.RunPython(add_constraints, remove_constraints)] diff --git a/apps/backend/hub_platform/tenancy/migrations/0003_rls_policies.py b/apps/backend/hub_platform/tenancy/migrations/0003_rls_policies.py new file mode 100644 index 0000000..d42a084 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0003_rls_policies.py @@ -0,0 +1,210 @@ +from django.db import migrations + + +TENANT_TABLES = ( + "identity_department", + "identity_employeeprofile", + "identity_accessprofile", + "identity_accessprofilecapability", + "identity_employeeaccessassignment", + "identity_organizationinvitation", + "identity_auditevent", + "identity_product", + "tenancy_organizationstorageusage", + "products_productdepartment", + "products_offer", + "products_price", + "products_marketplacepublication", + "ai_knowledge", + "ai_knowledgeattachment", + "ai_knowledgefragment", + "ai_aiagent", + "ai_llminvocation", + "integrations_integration", + "channels_channel", + "conversations_contact", + "conversations_connectionidentity", + "conversations_conversation", + "conversations_conversationread", + "conversations_message", + "orders_order", + "orders_orderitem", + "sales_salessource", + "sales_sale", + "sales_saleevent", + "sales_attributiontoken", + "sales_externalcustomeridentity", + "support_productsupportcontract", + "support_supportidentitysnapshot", + "calls_callsession", + "calls_callinvite", + "calls_callparticipant", + "calls_callmetric", + "notifications_notification", + "notifications_notificationread", + "notifications_messengerbinding", + "notifications_messengerbindingcode", + "webchat_websession", + "events_outboxevent", + "events_inboxevent", +) + +INDIRECT_TABLE_POLICIES = { + "ai_aiagent_knowledge_items": """ + EXISTS ( + SELECT 1 FROM ai_aiagent agent + JOIN ai_knowledge knowledge ON knowledge.id = knowledge_id + WHERE agent.id = aiagent_id + AND agent.organization_id = custocrm.current_organization_id() + AND knowledge.organization_id = agent.organization_id + ) + """, + "support_productsupportcontract_allowed_channels": """ + EXISTS ( + SELECT 1 FROM support_productsupportcontract contract + JOIN channels_channel channel ON channel.id = channel_id + WHERE contract.id = productsupportcontract_id + AND contract.organization_id = custocrm.current_organization_id() + AND channel.organization_id = contract.organization_id + ) + """, +} + +MIXED_PLATFORM_TABLES = ( + "identity_auditevent", + "events_outboxevent", + "events_inboxevent", +) + + +def _ensure_roles(schema_editor): + schema_editor.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_runtime_app') THEN + CREATE ROLE custocrm_runtime_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_runtime_platform') THEN + CREATE ROLE custocrm_runtime_platform NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_schema') THEN + CREATE ROLE custocrm_schema NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS; + END IF; + IF NOT pg_has_role(current_user, 'custocrm_schema', 'MEMBER') THEN + EXECUTE format('GRANT custocrm_schema TO %%I', current_user); + END IF; + END $$; + + CREATE SCHEMA IF NOT EXISTS custocrm AUTHORIZATION custocrm_schema; + ALTER SCHEMA custocrm OWNER TO custocrm_schema; + REVOKE ALL ON SCHEMA custocrm FROM PUBLIC; + GRANT USAGE ON SCHEMA custocrm TO custocrm_runtime_app, custocrm_runtime_platform; + + CREATE OR REPLACE FUNCTION custocrm.current_organization_id() RETURNS bigint + LANGUAGE sql STABLE PARALLEL SAFE AS $$ + SELECT CASE + WHEN current_setting('custocrm.organization_id', true) ~ '^[1-9][0-9]*$' + THEN current_setting('custocrm.organization_id', true)::bigint + ELSE NULL + END + $$; + ALTER FUNCTION custocrm.current_organization_id() OWNER TO custocrm_schema; + ALTER FUNCTION custocrm.enforce_tenant_fk() OWNER TO custocrm_schema; + ALTER FUNCTION custocrm.enforce_tenant_user() OWNER TO custocrm_schema; + ALTER FUNCTION custocrm.enforce_tenant_pair() OWNER TO custocrm_schema; + REVOKE ALL ON FUNCTION custocrm.current_organization_id() FROM PUBLIC; + REVOKE ALL ON FUNCTION custocrm.enforce_tenant_fk() FROM PUBLIC; + REVOKE ALL ON FUNCTION custocrm.enforce_tenant_user() FROM PUBLIC; + REVOKE ALL ON FUNCTION custocrm.enforce_tenant_pair() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION custocrm.current_organization_id() + TO custocrm_runtime_app, custocrm_runtime_platform; + GRANT EXECUTE ON FUNCTION custocrm.enforce_tenant_fk(), + custocrm.enforce_tenant_user(), custocrm.enforce_tenant_pair() + TO custocrm_runtime_app, custocrm_schema; + GRANT USAGE ON SCHEMA public TO custocrm_runtime_app, custocrm_runtime_platform; + """ + ) + + +def enable_rls(apps, schema_editor): + _ensure_roles(schema_editor) + for table in TENANT_TABLES: + schema_editor.execute( + f""" + ALTER TABLE {table} OWNER TO custocrm_schema; + ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; + ALTER TABLE {table} FORCE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE {table} FROM PUBLIC; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_app; + GRANT ALL ON TABLE {table} TO custocrm_schema; + DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}; + CREATE POLICY custocrm_tenant_isolation ON {table} + FOR ALL TO custocrm_runtime_app + USING (organization_id = custocrm.current_organization_id()) + WITH CHECK (organization_id = custocrm.current_organization_id()); + DROP POLICY IF EXISTS custocrm_schema_access ON {table}; + CREATE POLICY custocrm_schema_access ON {table} + FOR ALL TO custocrm_schema USING (true) WITH CHECK (true); + """ + ) + for table, expression in INDIRECT_TABLE_POLICIES.items(): + schema_editor.execute( + f""" + ALTER TABLE {table} OWNER TO custocrm_schema; + ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; + ALTER TABLE {table} FORCE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE {table} FROM PUBLIC; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_app; + GRANT ALL ON TABLE {table} TO custocrm_schema; + DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}; + CREATE POLICY custocrm_tenant_isolation ON {table} + FOR ALL TO custocrm_runtime_app + USING ({expression}) WITH CHECK ({expression}); + DROP POLICY IF EXISTS custocrm_schema_access ON {table}; + CREATE POLICY custocrm_schema_access ON {table} + FOR ALL TO custocrm_schema USING (true) WITH CHECK (true); + """ + ) + for table in MIXED_PLATFORM_TABLES: + schema_editor.execute( + f""" + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_platform; + DROP POLICY IF EXISTS custocrm_platform_boundary ON {table}; + CREATE POLICY custocrm_platform_boundary ON {table} + FOR ALL TO custocrm_runtime_platform USING (true) WITH CHECK (true); + """ + ) + schema_editor.execute( + """ + DROP POLICY IF EXISTS custocrm_app_platform_audit_insert ON identity_auditevent; + CREATE POLICY custocrm_app_platform_audit_insert ON identity_auditevent + FOR INSERT TO custocrm_runtime_app WITH CHECK (organization_id IS NULL); + DROP POLICY IF EXISTS custocrm_app_platform_outbox_insert ON events_outboxevent; + CREATE POLICY custocrm_app_platform_outbox_insert ON events_outboxevent + FOR INSERT TO custocrm_runtime_app + WITH CHECK (ownership = 'PLATFORM' AND organization_id IS NULL); + + GRANT SELECT ON identity_organization TO custocrm_runtime_app, custocrm_runtime_platform; + GRANT SELECT, INSERT, UPDATE, DELETE ON identity_humanuser + TO custocrm_runtime_app, custocrm_runtime_platform; + GRANT SELECT, INSERT, UPDATE, DELETE ON django_session + TO custocrm_runtime_app, custocrm_runtime_platform; + GRANT SELECT ON django_content_type, auth_permission, auth_group, + auth_group_permissions, identity_humanuser_groups, identity_humanuser_user_permissions + TO custocrm_runtime_app, custocrm_runtime_platform; + GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public + TO custocrm_runtime_app, custocrm_runtime_platform, custocrm_schema; + """ + ) + + +def disable_rls(apps, schema_editor): + for table in (*TENANT_TABLES, *INDIRECT_TABLE_POLICIES): + schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + +class Migration(migrations.Migration): + dependencies = [("tenancy", "0002_cross_tenant_constraints")] + operations = [migrations.RunPython(enable_rls, disable_rls)] diff --git a/apps/backend/hub_platform/tenancy/migrations/0004_ingress_directory.py b/apps/backend/hub_platform/tenancy/migrations/0004_ingress_directory.py new file mode 100644 index 0000000..1f2fb70 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0004_ingress_directory.py @@ -0,0 +1,97 @@ +from django.db import migrations + + +VIEWS = { + "membership_directory": """ + SELECT membership.id AS resource_id, + membership.organization_id, + membership.user_id, + membership.totp_required, + membership.blocked_at + FROM identity_employeeprofile membership + """, + "product_ingest_directory": """ + SELECT product.id AS resource_id, + product.organization_id, + product.ingest_token_hash AS lookup_key + FROM identity_product product + WHERE product.ingest_token_hash <> '' + """, + "sales_source_directory": """ + SELECT source.id AS resource_id, + source.organization_id, + source.credential_hash AS lookup_key + FROM sales_salessource source + WHERE source.credential_hash <> '' AND source.status = 'ACTIVE' + """, + "attachment_directory": """ + SELECT attachment.id AS resource_id, + attachment.organization_id, + attachment.public_id::text AS lookup_key + FROM ai_knowledgeattachment attachment + """, + "call_invite_directory": """ + SELECT invite.id::text AS resource_id, + invite.organization_id, + invite.token_hash AS lookup_key + FROM calls_callinvite invite + """, + "call_session_directory": """ + SELECT call.id::text AS resource_id, + call.organization_id, + call.id::text AS lookup_key + FROM calls_callsession call + """, + "web_session_directory": """ + SELECT session.id AS resource_id, + session.organization_id, + session.token_hash AS lookup_key + FROM webchat_websession session + """, + "web_channel_directory": """ + SELECT integration.id AS resource_id, + integration.organization_id, + channel.code AS lookup_key + FROM integrations_integration integration + JOIN channels_channel channel ON channel.id = integration.channel_id + WHERE integration.provider = 'WEB' AND channel.is_active + """, + "support_channel_directory": """ + SELECT channel.id AS resource_id, + channel.organization_id, + channel.code AS lookup_key, + product.support_token_secret + FROM channels_channel channel + JOIN identity_product product ON product.id = channel.product_id + WHERE channel.is_active + """, + "support_conversation_directory": """ + SELECT conversation.id AS resource_id, + conversation.organization_id, + conversation.support_identity_snapshot_id AS snapshot_id + FROM conversations_conversation conversation + WHERE conversation.support_identity_snapshot_id IS NOT NULL + """, +} + + +def create_views(apps, schema_editor): + for name, query in VIEWS.items(): + schema_editor.execute( + f"CREATE OR REPLACE VIEW custocrm.{name} WITH (security_barrier = true) AS {query}" + ) + schema_editor.execute(f"ALTER VIEW custocrm.{name} OWNER TO custocrm_schema") + schema_editor.execute(f"REVOKE ALL ON custocrm.{name} FROM PUBLIC") + schema_editor.execute( + f"GRANT SELECT ON custocrm.{name} TO custocrm_runtime_platform" + ) + + +def drop_views(apps, schema_editor): + for name in reversed(VIEWS): + schema_editor.execute(f"DROP VIEW IF EXISTS custocrm.{name}") + + +class Migration(migrations.Migration): + dependencies = [("tenancy", "0003_rls_policies")] + operations = [migrations.RunPython(create_views, drop_views)] diff --git a/apps/backend/hub_platform/tenancy/migrations/__init__.py b/apps/backend/hub_platform/tenancy/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/hub_platform/tenancy/models.py b/apps/backend/hub_platform/tenancy/models.py new file mode 100644 index 0000000..62b8445 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/models.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import ClassVar + +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import models + + +class TenantRelationModel(models.Model): + """Direct tenant key derived from, and checked against, parent relations.""" + + organization = models.ForeignKey( + "identity.Organization", + on_delete=models.PROTECT, + related_name="+", + ) + tenant_relation_fields: ClassVar[tuple[str, ...]] = () + + class Meta: + abstract = True + + def _related_organization_ids(self) -> set[int]: + organization_ids: set[int] = set() + for field_name in self.tenant_relation_fields: + try: + related = getattr(self, field_name) + except ObjectDoesNotExist: + continue + if related is None: + continue + organization_id = getattr(related, "organization_id", None) + if organization_id is not None: + organization_ids.add(int(organization_id)) + return organization_ids + + def validate_tenant_relations(self) -> None: + organization_ids = self._related_organization_ids() + if len(organization_ids) > 1: + raise ValidationError("Tenant relations must belong to one organization") + if self.organization_id is None: + if not organization_ids: + raise ValidationError("Tenant-owned row requires an organization") + self.organization_id = next(iter(organization_ids)) + elif organization_ids and organization_ids != {int(self.organization_id)}: + raise ValidationError("Tenant relation does not match organization") + + def clean(self) -> None: + super().clean() + self.validate_tenant_relations() + + def save(self, *args: object, **kwargs: object) -> None: + self.validate_tenant_relations() + super().save(*args, **kwargs) + + +class OrganizationStorageUsage(models.Model): + """Authoritative storage_bytes usage counter for one organization.""" + + organization = models.OneToOneField( + "identity.Organization", + on_delete=models.PROTECT, + related_name="storage_usage", + ) + bytes_used = models.PositiveBigIntegerField(default=0) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=models.Q(bytes_used__gte=0), + name="storage_usage_bytes_non_negative", + ) + ] diff --git a/apps/backend/hub_platform/tenancy/storage.py b/apps/backend/hub_platform/tenancy/storage.py new file mode 100644 index 0000000..0e21b05 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/storage.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.db import transaction + +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.models import OrganizationStorageUsage + +STORAGE_QUOTA_KEY = "storage_bytes" + + +@transaction.atomic +def adjust_storage_usage(*, context: TenantContext, delta_bytes: int) -> int: + usage, _ = OrganizationStorageUsage.objects.select_for_update().get_or_create( + organization=context.organization, + defaults={"bytes_used": 0}, + ) + next_value = usage.bytes_used + int(delta_bytes) + if next_value < 0: + raise ValidationError("Storage usage cannot become negative") + usage.bytes_used = next_value + usage.save(update_fields=["bytes_used", "updated_at"]) + return next_value diff --git a/apps/backend/hub_platform/tenancy/storage_backends.py b/apps/backend/hub_platform/tenancy/storage_backends.py new file mode 100644 index 0000000..7c0c6e7 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/storage_backends.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation +from django.core.files.storage import FileSystemStorage + +from hub_platform.tenancy.database import current_tenant_id + + +def _assert_tenant_key(name: str) -> None: + organization_id = current_tenant_id() + if organization_id is None: + raise SuspiciousFileOperation("Tenant storage access requires database context") + + from hub_platform.identity.models import Organization + + try: + public_id = Organization.objects.values_list("public_id", flat=True).get( + pk=organization_id + ) + except Organization.DoesNotExist as error: + raise SuspiciousFileOperation("Tenant storage organization does not exist") from error + normalized = str(name).replace("\\", "/").lstrip("/") + prefix = f"organizations/{public_id}/" + if not normalized.startswith(prefix): + raise SuspiciousFileOperation("Storage key belongs to another organization") + + +class TenantStorageGuardMixin: + def open(self, name, mode="rb"): + _assert_tenant_key(name) + return super().open(name, mode) + + def save(self, name, content, max_length=None): + _assert_tenant_key(name) + return super().save(name, content, max_length=max_length) + + def delete(self, name): + _assert_tenant_key(name) + return super().delete(name) + + def exists(self, name): + _assert_tenant_key(name) + return super().exists(name) + + def size(self, name): + _assert_tenant_key(name) + return super().size(name) + + def url(self, name, *args, **kwargs): + _assert_tenant_key(name) + return super().url(name, *args, **kwargs) + + def path(self, name): + _assert_tenant_key(name) + return super().path(name) + + +class TenantFileSystemStorage(TenantStorageGuardMixin, FileSystemStorage): + pass + + +try: + from storages.backends.s3 import S3Storage +except ImportError: # pragma: no cover - deployment configuration guard + S3Storage = None + + +if S3Storage is not None: + + class TenantS3Storage(TenantStorageGuardMixin, S3Storage): + pass + +else: + + class TenantS3Storage: + def __init__(self, *args, **kwargs) -> None: + raise ImproperlyConfigured("django-storages[s3] is required for S3 storage") diff --git a/apps/backend/hub_platform/tenancy/test_media_migration.py b/apps/backend/hub_platform/tenancy/test_media_migration.py new file mode 100644 index 0000000..9d23ffa --- /dev/null +++ b/apps/backend/hub_platform/tenancy/test_media_migration.py @@ -0,0 +1,83 @@ +import hashlib +import json +import tempfile +from pathlib import Path + +from django.core.management import call_command +from django.test import TestCase, override_settings + +from hub_platform.ai.models import Knowledge, KnowledgeAttachment +from hub_platform.identity.models import Organization +from hub_platform.tenancy.models import OrganizationStorageUsage + +_DESTINATION_ROOT = Path(tempfile.mkdtemp(prefix="c04-destination-media-")) + + +@override_settings(MEDIA_ROOT=_DESTINATION_ROOT) +class TenantMediaMigrationTests(TestCase): + def setUp(self) -> None: + self.source_root = Path(tempfile.mkdtemp(prefix="c04-source-media-")) + self.organization = Organization.objects.create(name="Media", slug="media") + knowledge = Knowledge.objects.create( + organization=self.organization, + title="Legacy", + ) + self.attachment = KnowledgeAttachment.objects.create( + organization=self.organization, + knowledge=knowledge, + file="legacy/price.txt", + original_name="price.txt", + size=0, + ) + source = self.source_root / self.attachment.file.name + source.parent.mkdir(parents=True, exist_ok=True) + source.write_bytes(b"legacy media") + + def test_dry_run_hashes_without_mutating_database_or_source(self) -> None: + manifest = self.source_root / "dry-run.json" + call_command( + "migrate_tenant_media", + "--organization-public-id", + str(self.organization.public_id), + "--source-root", + str(self.source_root), + "--manifest", + str(manifest), + ) + + self.attachment.refresh_from_db() + payload = json.loads(manifest.read_text(encoding="utf-8")) + self.assertEqual(self.attachment.file.name, "legacy/price.txt") + self.assertEqual( + payload["entries"][0]["sha256"], + hashlib.sha256(b"legacy media").hexdigest(), + ) + self.assertFalse(payload["apply"]) + self.assertTrue((self.source_root / "legacy/price.txt").exists()) + + def test_apply_copies_verifies_updates_usage_and_keeps_source(self) -> None: + manifest = self.source_root / "apply.json" + call_command( + "migrate_tenant_media", + "--organization-public-id", + str(self.organization.public_id), + "--source-root", + str(self.source_root), + "--manifest", + str(manifest), + "--apply", + ) + + self.attachment.refresh_from_db() + expected_prefix = f"organizations/{self.organization.public_id}/" + destination = _DESTINATION_ROOT / self.attachment.file.name + self.assertTrue(self.attachment.file.name.startswith(expected_prefix)) + self.assertEqual(destination.read_bytes(), b"legacy media") + self.assertTrue((self.source_root / "legacy/price.txt").exists()) + self.assertEqual( + OrganizationStorageUsage.objects.get( + organization=self.organization + ).bytes_used, + len(b"legacy media"), + ) + self.assertFalse(json.loads(manifest.read_text(encoding="utf-8"))["sourceDeleted"]) diff --git a/apps/backend/hub_platform/tenancy/test_rls.py b/apps/backend/hub_platform/tenancy/test_rls.py new file mode 100644 index 0000000..22ad976 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/test_rls.py @@ -0,0 +1,186 @@ +from django.db import DatabaseError, connection, transaction +from django.test import TransactionTestCase + +from hub_platform.ai.models import AIAgent, Knowledge +from hub_platform.channels.models import Channel +from hub_platform.identity.models import ( + Department, + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from hub_platform.products.models import Product +from hub_platform.tenancy.database import current_tenant_id, set_local_tenant +from hub_platform.testing import TenantAPIClient + + +class RowLevelSecurityTests(TransactionTestCase): + reset_sequences = True + + def setUp(self) -> None: + self.first = Organization.objects.create(name="First", slug="rls-first") + self.second = Organization.objects.create(name="Second", slug="rls-second") + self.first_product = Product.objects.create( + organization=self.first, + code="first", + name="First product", + ingest_token_hash="first-hash", + ) + self.second_product = Product.objects.create( + organization=self.second, + code="second", + name="Second product", + ingest_token_hash="second-hash", + ) + self.second_department = Department.objects.create( + organization=self.second, + code="foreign", + name="Foreign", + ) + self.user = HumanUser.objects.create_user(email="rls@example.test") + OrganizationMembership.objects.create( + organization=self.first, + user=self.user, + role=EmployeeRole.OWNER, + position_title="Owner", + ) + + @staticmethod + def _set_role(role: str) -> None: + if role not in {"custocrm_runtime_app", "custocrm_runtime_platform"}: + raise ValueError("Unexpected test role") + with connection.cursor() as cursor: + cursor.execute(f"SET LOCAL ROLE {role}") + + def test_runtime_roles_are_not_owners_or_bypassrls(self) -> None: + with connection.cursor() as cursor: + cursor.execute( + "SELECT rolname, rolsuper, rolbypassrls FROM pg_roles " + "WHERE rolname IN ('custocrm_runtime_app', 'custocrm_runtime_platform') " + "ORDER BY rolname" + ) + roles = cursor.fetchall() + cursor.execute( + "SELECT tableowner FROM pg_tables " + "WHERE schemaname = 'public' AND tablename = 'identity_product'" + ) + owner = cursor.fetchone()[0] + self.assertEqual(len(roles), 2) + self.assertTrue(all(not superuser and not bypass for _, superuser, bypass in roles)) + self.assertEqual(owner, "custocrm_schema") + + def test_app_role_is_fail_closed_and_scoped(self) -> None: + with transaction.atomic(): + self._set_role("custocrm_runtime_app") + self.assertEqual(Product.objects.count(), 0) + + with transaction.atomic(): + self._set_role("custocrm_runtime_app") + set_local_tenant(self.first.id) + self.assertEqual(list(Product.objects.values_list("code", flat=True)), ["first"]) + Product.objects.create( + organization_id=self.first.id, + code="created", + name="Created", + ) + self.assertEqual(Product.objects.filter(code="created").update(name="Updated"), 1) + self.assertEqual(Product.objects.filter(code="created").delete()[0], 1) + + with self.assertRaises(DatabaseError), transaction.atomic(): + self._set_role("custocrm_runtime_app") + set_local_tenant(self.first.id) + Product.objects.create( + organization_id=self.second.id, + code="forged", + name="Forged", + ) + + def test_cross_tenant_relation_is_rejected_by_database_trigger(self) -> None: + with self.assertRaises(DatabaseError), transaction.atomic(): + self._set_role("custocrm_runtime_app") + set_local_tenant(self.first.id) + with connection.cursor() as cursor: + cursor.execute( + "INSERT INTO products_productdepartment " + "(organization_id, product_id, department_id, created_at) " + "VALUES (%s, %s, %s, NOW())", + [self.first.id, self.first_product.id, self.second_department.id], + ) + + def test_cross_tenant_many_to_many_is_rejected(self) -> None: + channel = Channel.objects.create( + organization=self.first, + code="rls-agent", + name="RLS agent", + ) + agent = AIAgent.objects.create( + organization=self.first, + channel=channel, + name="RLS agent", + ) + foreign_knowledge = Knowledge.objects.create( + organization=self.second, + title="Foreign knowledge", + ) + + with self.assertRaises(DatabaseError), transaction.atomic(): + self._set_role("custocrm_runtime_app") + set_local_tenant(self.first.id) + with connection.cursor() as cursor: + cursor.execute( + "INSERT INTO ai_aiagent_knowledge_items (aiagent_id, knowledge_id) " + "VALUES (%s, %s)", + [agent.id, foreign_knowledge.id], + ) + + def test_http_request_runs_with_runtime_role_and_tenant_middleware(self) -> None: + client = TenantAPIClient() + client.force_authenticate(self.user) + with connection.cursor() as cursor: + cursor.execute("SET ROLE custocrm_runtime_app") + try: + own = client.get( + f"/api/v1/organizations/{self.first.public_id}/company/products/" + ) + foreign = client.get( + f"/api/v1/organizations/{self.second.public_id}/company/products/" + ) + finally: + with connection.cursor() as cursor: + cursor.execute("RESET ROLE") + self.assertEqual(own.status_code, 200) + self.assertEqual([item["code"] for item in own.json()["items"]], ["first"]) + self.assertEqual(foreign.status_code, 404) + + def test_platform_role_can_only_use_ingress_directory(self) -> None: + with self.assertRaises(DatabaseError), transaction.atomic(): + self._set_role("custocrm_runtime_platform") + with connection.cursor() as cursor: + cursor.execute("SELECT id FROM identity_product LIMIT 1") + + with transaction.atomic(): + self._set_role("custocrm_runtime_platform") + with connection.cursor() as cursor: + cursor.execute( + "SELECT organization_id, resource_id " + "FROM custocrm.product_ingest_directory WHERE lookup_key = %s", + ["first-hash"], + ) + row = cursor.fetchone() + self.assertEqual(row, (self.first.id, self.first_product.id)) + + def test_transaction_local_context_clears_after_commit_and_rollback(self) -> None: + with transaction.atomic(): + set_local_tenant(self.first.id) + self.assertEqual(current_tenant_id(), self.first.id) + self.assertIsNone(current_tenant_id()) + + try: + with transaction.atomic(): + set_local_tenant(self.second.id) + self.assertEqual(current_tenant_id(), self.second.id) + raise RuntimeError("rollback") + except RuntimeError: + pass + self.assertIsNone(current_tenant_id()) diff --git a/apps/backend/hub_platform/tenancy/tests.py b/apps/backend/hub_platform/tenancy/tests.py index 620e7ee..463262f 100644 --- a/apps/backend/hub_platform/tenancy/tests.py +++ b/apps/backend/hub_platform/tenancy/tests.py @@ -1,5 +1,6 @@ import json +from django.db import DatabaseError, IntegrityError, transaction from django.test import TestCase from rest_framework.test import APIClient as RawAPIClient @@ -160,10 +161,8 @@ class TenantEventBoundaryTests(TestCase): def test_tenant_event_rejects_cross_organization_membership(self) -> None: event = self._event() event.organization = self.second - event.save(update_fields=["organization"]) - - with self.assertRaises(OrganizationMembership.DoesNotExist): - tenant_context_for_event(event) + with self.assertRaises(DatabaseError), transaction.atomic(): + event.save(update_fields=["organization"]) def test_tenant_event_rejects_membership_blocked_after_enqueue(self) -> None: event = self._event() @@ -182,9 +181,5 @@ class TenantEventBoundaryTests(TestCase): ) ) event.organization = self.first - event.save(update_fields=["organization"]) - - with self.assertRaisesMessage( - ValueError, "Platform event cannot carry tenant ownership" - ): - tenant_context_for_event(event) + with self.assertRaises(IntegrityError), transaction.atomic(): + event.save(update_fields=["organization"]) diff --git a/apps/backend/hub_platform/webchat/migrations/0002_websession_organization.py b/apps/backend/hub_platform/webchat/migrations/0002_websession_organization.py new file mode 100644 index 0000000..4ec9f95 --- /dev/null +++ b/apps/backend/hub_platform/webchat/migrations/0002_websession_organization.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0013_accessprofilecapability_organization_and_more'), + ('webchat', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='websession', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/webchat/migrations/0003_alter_websession_organization.py b/apps/backend/hub_platform/webchat/migrations/0003_alter_websession_organization.py new file mode 100644 index 0000000..3d7af61 --- /dev/null +++ b/apps/backend/hub_platform/webchat/migrations/0003_alter_websession_organization.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.16 on 2026-07-15 00:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0014_alter_accessprofilecapability_organization_and_more'), + ('webchat', '0002_websession_organization'), + ] + + operations = [ + migrations.RunSQL( + sql=""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM webchat_websession session + JOIN integrations_integration connection ON connection.id = session.connection_id + JOIN conversations_connectionidentity identity ON identity.id = session.identity_id + JOIN conversations_contact contact ON contact.id = identity.contact_id + WHERE connection.organization_id <> contact.organization_id + ) THEN + RAISE EXCEPTION 'C04 preflight: cross-tenant web session exists'; + END IF; + END $$; + + UPDATE webchat_websession session + SET organization_id = connection.organization_id + FROM integrations_integration connection + WHERE session.connection_id = connection.id; + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AlterField( + model_name='websession', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + ] diff --git a/apps/backend/hub_platform/webchat/models.py b/apps/backend/hub_platform/webchat/models.py index 82d334a..b924f43 100644 --- a/apps/backend/hub_platform/webchat/models.py +++ b/apps/backend/hub_platform/webchat/models.py @@ -1,10 +1,13 @@ from django.db import models +from hub_platform.tenancy.models import TenantRelationModel + # Анонимная браузерная сессия Web Chat (SPEC-HUB-0003 §7). Храним только hash # токена; токен живёт в браузере и идентифицирует ConnectionIdentity канала. -class WebSession(models.Model): +class WebSession(TenantRelationModel): + tenant_relation_fields = ("connection", "identity") token_hash = models.CharField(max_length=64, unique=True, db_index=True) connection = models.ForeignKey("integrations.Integration", on_delete=models.CASCADE, related_name="web_sessions") identity = models.ForeignKey("conversations.ConnectionIdentity", on_delete=models.CASCADE, related_name="web_sessions") diff --git a/apps/backend/hub_platform/webchat/services.py b/apps/backend/hub_platform/webchat/services.py index 1628665..079fb27 100644 --- a/apps/backend/hub_platform/webchat/services.py +++ b/apps/backend/hub_platform/webchat/services.py @@ -14,6 +14,7 @@ from hub_platform.conversations.models import ( ) from hub_platform.conversations.transports.base import InboundMessage from hub_platform.integrations.models import Integration, IntegrationProvider +from hub_platform.tenancy.context import TenantContext from hub_platform.webchat.models import WebSession DEFAULT_GREETING = "Здравствуйте! Готов помочь и ответить на вопросы. Чем можем помочь?" @@ -24,16 +25,21 @@ _STATE = {ControlMode.AI: "ai", ControlMode.HUMAN: "operator", ControlMode.PAUSE _ROLE = {"CONTACT": "client", "AI": "ai", "OPERATOR": "operator", "SYSTEM": "system"} -def _hash(token: str) -> str: +def hash_session_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() -def web_connection_for_channel(channel_code: str) -> Integration | None: +def web_connection_for_channel( + context: TenantContext, + channel_code: str, +) -> Integration | None: matches = list( Integration.objects.select_related("channel") .filter( provider=IntegrationProvider.WEB, + organization=context.organization, channel__code=channel_code, + channel__organization=context.organization, channel__is_active=True, ) .order_by("id")[:2] @@ -51,8 +57,7 @@ def _host_allowed(integration: Integration, origin: str) -> bool: return any(host == d or host.endswith("." + d) for d in allowed) -def public_config(channel_code: str, origin: str) -> dict: - integration = web_connection_for_channel(channel_code) +def public_config(*, context: TenantContext, integration: Integration, origin: str) -> dict: if integration is None or integration.channel_id is None: return {"available": False} if not _host_allowed(integration, origin): @@ -79,22 +84,35 @@ def public_config(channel_code: str, origin: str) -> dict: @transaction.atomic -def issue_session(channel_code: str) -> dict | None: - integration = web_connection_for_channel(channel_code) +def issue_session(*, context: TenantContext, integration: Integration) -> dict | None: if integration is None or integration.channel_id is None: return None session_id = uuid.uuid4().hex guest_name = f"Веб-гость · {session_id[:6]}" contact = Contact.objects.create(organization=integration.channel.organization, name=guest_name) identity = ConnectionIdentity.objects.create( - contact=contact, connection=integration, external_user_id=session_id, display_name=guest_name + organization=context.organization, + contact=contact, + connection=integration, + external_user_id=session_id, + display_name=guest_name, ) token = secrets.token_urlsafe(32) - WebSession.objects.create(token_hash=_hash(token), connection=integration, identity=identity) + WebSession.objects.create( + organization=context.organization, + token_hash=hash_session_token(token), + connection=integration, + identity=identity, + ) return {"token": token, "sessionId": session_id} -def resolve_session(token: str) -> WebSession | None: +def resolve_session( + *, + context: TenantContext, + token: str, + session_id: int, +) -> WebSession | None: if not token: return None return ( @@ -106,7 +124,9 @@ def resolve_session(token: str) -> WebSession | None: "identity__contact", ) .filter( - token_hash=_hash(token), + token_hash=hash_session_token(token), + id=session_id, + organization=context.organization, connection__organization_id=models.F("identity__contact__organization_id"), connection__channel__organization_id=models.F("connection__organization_id"), ) diff --git a/apps/backend/hub_platform/webchat/views.py b/apps/backend/hub_platform/webchat/views.py index ccdb523..a132ad4 100644 --- a/apps/backend/hub_platform/webchat/views.py +++ b/apps/backend/hub_platform/webchat/views.py @@ -1,3 +1,5 @@ +from contextlib import contextmanager + from django.http import HttpResponse from django.views import View from rest_framework.permissions import AllowAny @@ -5,6 +7,11 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from hub_platform.identity.models import Organization +from hub_platform.integrations.models import Integration, IntegrationProvider +from hub_platform.tenancy.context import TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.ingress import web_channel_route, web_session_route from hub_platform.webchat import services from hub_platform.webchat.loader import LOADER_JS @@ -27,51 +34,111 @@ class _Public(APIView): permission_classes = [AllowAny] +@contextmanager +def _resolved_web_connection(channel_code: str): + route = web_channel_route(channel_code) + if route is None: + yield None, None + return + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist: + yield None, None + return + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + integration = Integration.objects.select_related("channel").filter( + id=route.resource_id, + organization=organization, + provider=IntegrationProvider.WEB, + channel__organization=organization, + channel__code=channel_code, + channel__is_active=True, + ).first() + yield context, integration + + +@contextmanager +def _resolved_web_session(request: Request): + token = _token(request) + route = web_session_route(services.hash_session_token(token)) if token else None + if route is None: + yield None, None + return + try: + organization = Organization.objects.get(pk=route.organization_id) + except Organization.DoesNotExist: + yield None, None + return + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + session = services.resolve_session( + context=context, + token=token, + session_id=int(route.resource_id), + ) + yield context, session + + class WebchatConfigView(_Public): def get(self, request: Request) -> Response: - return Response(services.public_config(request.GET.get("channel", ""), _origin(request))) + channel_code = request.GET.get("channel", "") + with _resolved_web_connection(channel_code) as (context, integration): + if context is None or integration is None: + return Response({"available": False}) + return Response( + services.public_config( + context=context, + integration=integration, + origin=_origin(request), + ) + ) class WebchatSessionView(_Public): def post(self, request: Request) -> Response: - result = services.issue_session(str(request.data.get("channel", ""))) - if result is None: - return Response({"detail": "Канал недоступен"}, status=404) - return Response(result, status=201) + channel_code = str(request.data.get("channel", "")) + with _resolved_web_connection(channel_code) as (context, integration): + if context is None or integration is None: + return Response({"detail": "Канал недоступен"}, status=404) + result = services.issue_session(context=context, integration=integration) + if result is None: + return Response({"detail": "Канал недоступен"}, status=404) + return Response(result, status=201) class WebchatMessagesView(_Public): def post(self, request: Request) -> Response: - session = services.resolve_session(_token(request)) - if session is None: - return Response({"detail": "Сессия не найдена"}, status=401) - text = str(request.data.get("text", "")).strip() - if not text: - return Response({"detail": "Пустое сообщение"}, status=400) - services.post_message(session, text[:4000]) - return Response({"ok": True}, status=201) + with _resolved_web_session(request) as (_context, session): + if session is None: + return Response({"detail": "Сессия не найдена"}, status=401) + text = str(request.data.get("text", "")).strip() + if not text: + return Response({"detail": "Пустое сообщение"}, status=400) + services.post_message(session, text[:4000]) + return Response({"ok": True}, status=201) def get(self, request: Request) -> Response: - session = services.resolve_session(_token(request)) - if session is None: - return Response({"detail": "Сессия не найдена"}, status=401) - try: - since = int(request.GET.get("since", "0") or 0) - except ValueError: - since = 0 - return Response(services.messages_payload(session, since)) + with _resolved_web_session(request) as (_context, session): + if session is None: + return Response({"detail": "Сессия не найдена"}, status=401) + try: + since = int(request.GET.get("since", "0") or 0) + except ValueError: + since = 0 + return Response(services.messages_payload(session, since)) class WebchatContactView(_Public): def post(self, request: Request) -> Response: - session = services.resolve_session(_token(request)) - if session is None: - return Response({"detail": "Сессия не найдена"}, status=401) - phone = services.normalize_phone(str(request.data.get("phone", ""))) - if not phone: - return Response({"detail": "Некорректный номер телефона"}, status=400) - services.post_contact(session, phone) - return Response({"ok": True}, status=201) + with _resolved_web_session(request) as (_context, session): + if session is None: + return Response({"detail": "Сессия не найдена"}, status=401) + phone = services.normalize_phone(str(request.data.get("phone", ""))) + if not phone: + return Response({"detail": "Некорректный номер телефона"}, status=400) + services.post_contact(session, phone) + return Response({"ok": True}, status=201) class WebchatCallOpenView(_Public): @@ -80,22 +147,22 @@ class WebchatCallOpenView(_Public): from hub_platform.calls.serializers import ice_servers_payload, public_invite_payload from hub_platform.calls.services import open_call_for_identity - session = services.resolve_session(_token(request)) - if session is None: - return Response({"detail": "Сессия не найдена"}, status=401) - try: - resolved = open_call_for_identity(identity=session.identity) - except CallTokenError: - return Response({"detail": "Активное приглашение не найдено"}, status=404) - response = Response( - { - "call": public_invite_payload(resolved.invite.call_session, resolved.invite.expires_at), - "accessToken": resolved.customer_access_token, - "iceServers": ice_servers_payload(), - } - ) - response["Cache-Control"] = "no-store" - return response + with _resolved_web_session(request) as (_context, session): + if session is None: + return Response({"detail": "Сессия не найдена"}, status=401) + try: + resolved = open_call_for_identity(identity=session.identity) + except CallTokenError: + return Response({"detail": "Активное приглашение не найдено"}, status=404) + response = Response( + { + "call": public_invite_payload(resolved.invite.call_session, resolved.invite.expires_at), + "accessToken": resolved.customer_access_token, + "iceServers": ice_servers_payload(), + } + ) + response["Cache-Control"] = "no-store" + return response class WebchatCallDeclineView(_Public): @@ -103,16 +170,16 @@ class WebchatCallDeclineView(_Public): from hub_platform.calls.errors import CallConflict, CallTokenError from hub_platform.calls.services import decline_call_for_identity - session = services.resolve_session(_token(request)) - if session is None: - return Response({"detail": "Сессия не найдена"}, status=401) - try: - decline_call_for_identity(identity=session.identity) - except CallTokenError: - return Response({"detail": "Активное приглашение не найдено"}, status=404) - except CallConflict as error: - return Response({"detail": str(error)}, status=409) - return Response({"ok": True}) + with _resolved_web_session(request) as (_context, session): + if session is None: + return Response({"detail": "Сессия не найдена"}, status=401) + try: + decline_call_for_identity(identity=session.identity) + except CallTokenError: + return Response({"detail": "Активное приглашение не найдено"}, status=404) + except CallConflict as error: + return Response({"detail": str(error)}, status=409) + return Response({"ok": True}) class WidgetLoaderView(View): diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index 7ebcdee..ee4ed85 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -1,5 +1,6 @@ Django>=5.2,<5.3 djangorestframework>=3.16,<3.17 +django-storages[s3]>=1.14,<1.15 channels>=4.2,<5 channels-redis>=4.2,<5 daphne>=4.1,<5 diff --git a/compose.yaml b/compose.yaml index 541830b..b4a5721 100644 --- a/compose.yaml +++ b/compose.yaml @@ -20,8 +20,15 @@ services: POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required} POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_APP_USER: ${POSTGRES_APP_USER:?POSTGRES_APP_USER is required} + POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required} + POSTGRES_PLATFORM_USER: ${POSTGRES_PLATFORM_USER:?POSTGRES_PLATFORM_USER is required} + POSTGRES_PLATFORM_PASSWORD: ${POSTGRES_PLATFORM_PASSWORD:?POSTGRES_PLATFORM_PASSWORD is required} + POSTGRES_MIGRATION_USER: ${POSTGRES_MIGRATION_USER:?POSTGRES_MIGRATION_USER is required} + POSTGRES_MIGRATION_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required} volumes: - ${CUSTOCRM_INSTANCE_DIR:-.}/data/postgres:/var/lib/postgresql/data + - ./deploy/postgres/init-runtime-roles.sh:/docker-entrypoint-initdb.d/20-custocrm-runtime-roles.sh:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s @@ -49,6 +56,8 @@ services: env_file: - ${CUSTOCRM_INSTANCE_DIR:-.}/.env command: ["python", "manage.py", "migrate", "--noinput"] + environment: + HUB_DB_ROLE: migration restart: "no" depends_on: postgres: @@ -67,8 +76,11 @@ services: --bind 0.0.0.0:8000 --workers $${HUB_GUNICORN_WORKERS:-3} --timeout $${HUB_GUNICORN_TIMEOUT:-60}" + environment: + HUB_DB_ROLE: app volumes: - # Файловые вложения знаний (ADR-HUB-0023). + # Legacy source media retained for the separately approved copy/hash + # migration. Production writes use the required S3 backend in C04. - ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media depends_on: init: @@ -101,6 +113,8 @@ services: --bind 0.0.0.0:8000 --workers $${HUB_GUNICORN_WORKERS:-3} --timeout $${HUB_GUNICORN_TIMEOUT:-60}" + environment: + HUB_DB_ROLE: platform depends_on: init: condition: service_completed_successfully @@ -134,6 +148,10 @@ services: --bind 0.0.0.0:8000 --workers $${HUB_GUNICORN_WORKERS:-2} --timeout $${HUB_GUNICORN_TIMEOUT:-60}" + # Break-glass technical surface only: schema credentials are never used by + # public app/platform runtimes and this service remains loopback-only. + environment: + HUB_DB_ROLE: migration ports: - "127.0.0.1:${CUSTOCRM_ADMIN_PORT:-18001}:8000" depends_on: @@ -150,6 +168,8 @@ services: env_file: - ${CUSTOCRM_INSTANCE_DIR:-.}/.env command: ["python", "manage.py", "run_worker"] + environment: + HUB_DB_ROLE: app volumes: - ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media depends_on: diff --git a/deploy/postgres/init-runtime-roles.sh b/deploy/postgres/init-runtime-roles.sh new file mode 100644 index 0000000..2882772 --- /dev/null +++ b/deploy/postgres/init-runtime-roles.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +: "${POSTGRES_APP_USER:?POSTGRES_APP_USER is required}" +: "${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required}" +: "${POSTGRES_PLATFORM_USER:?POSTGRES_PLATFORM_USER is required}" +: "${POSTGRES_PLATFORM_PASSWORD:?POSTGRES_PLATFORM_PASSWORD is required}" +: "${POSTGRES_MIGRATION_USER:?POSTGRES_MIGRATION_USER is required}" +: "${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}" + +psql --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + --set=app_user="$POSTGRES_APP_USER" \ + --set=app_password="$POSTGRES_APP_PASSWORD" \ + --set=platform_user="$POSTGRES_PLATFORM_USER" \ + --set=platform_password="$POSTGRES_PLATFORM_PASSWORD" \ + --set=migration_user="$POSTGRES_MIGRATION_USER" \ + --set=migration_password="$POSTGRES_MIGRATION_PASSWORD" <<'SQL' +CREATE EXTENSION IF NOT EXISTS vector; + +SELECT 'CREATE ROLE custocrm_runtime_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS' +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_runtime_app') +\gexec +SELECT 'CREATE ROLE custocrm_runtime_platform NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS' +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_runtime_platform') +\gexec +SELECT 'CREATE ROLE custocrm_schema NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS' +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_schema') +\gexec + +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS', :'app_user', :'app_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') +\gexec +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS', :'platform_user', :'platform_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'platform_user') +\gexec +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS', :'migration_user', :'migration_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'migration_user') +\gexec + +SELECT format('GRANT custocrm_runtime_app TO %I', :'app_user') \gexec +SELECT format('GRANT custocrm_runtime_platform TO %I', :'platform_user') \gexec +SELECT format('GRANT custocrm_schema TO %I', :'migration_user') \gexec +SELECT format('GRANT CONNECT ON DATABASE %I TO %I', current_database(), :'app_user') \gexec +SELECT format('GRANT CONNECT ON DATABASE %I TO %I', current_database(), :'platform_user') \gexec +SELECT format('GRANT CONNECT, CREATE, TEMPORARY ON DATABASE %I TO %I', current_database(), :'migration_user') \gexec +SELECT format('GRANT USAGE, CREATE ON SCHEMA public TO %I', :'migration_user') \gexec +GRANT USAGE, CREATE ON SCHEMA public TO custocrm_schema; +SQL