🔒 feat(tenancy): enforce RLS and storage isolation

This commit is contained in:
Andrey committed 2026-07-15 05:23:08 +03:00
1 parent 30f9eb3d1b
commit 48ece587a9
98 files changed
+3759 -682

No files matched your search

+19 -2
View File
@@ -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
+11 -24
View File
@@ -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"
# Лимиты на чувствительные эндпоинты (брутфорс/злоупотребление). В тестах отключены.
@@ -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
@@ -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
@@ -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)
+13 -2
View File
@@ -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)
+12 -1
View File
@@ -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,
@@ -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'),
),
]
@@ -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'),
),
]
+15 -5
View File
@@ -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)
@@ -1,7 +1,7 @@
from __future__ import annotations
import abc
from dataclasses import dataclass, field
from dataclasses import dataclass
@dataclass(frozen=True)
+2 -2
View File
@@ -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/<uuid:public_id>/", views.AttachmentDownloadView.as_view(), name="ai-attachment-download"),
path("files/<uuid:public_id>/", AttachmentDownloadView.as_view(), name="ai-attachment-download"),
]
+15 -1
View File
@@ -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)
@@ -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,
)
+1 -22
View File
@@ -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)
+29 -22
View File
@@ -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
@@ -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,
},
)
@@ -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'),
),
]
@@ -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'),
),
]
+9 -3
View File
@@ -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(
@@ -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,
)
+28 -187
View File
@@ -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(
@@ -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"),
@@ -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:
@@ -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,
@@ -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
# Явный шаринг контакта: сообщение без текста, но с телефоном.
@@ -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'),
),
]
@@ -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'),
),
]
@@ -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="+")
+7 -1
View File
@@ -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)
@@ -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)
@@ -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'),
),
]
+30 -1
View File
@@ -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:
+37 -24
View File
@@ -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
@@ -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)
@@ -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
]
)
@@ -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,
@@ -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 = ""
@@ -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)
@@ -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(
@@ -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'),
),
]
@@ -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'),
),
]
@@ -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:
@@ -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)
@@ -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'),
),
]
@@ -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'),
),
]
@@ -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=<code>); 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")
@@ -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)
@@ -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)
@@ -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
@@ -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'),
),
]
@@ -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'),
),
]
+4 -1
View File
@@ -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")
+20 -8
View File
@@ -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
]
)
+28 -4
View File
@@ -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)
@@ -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():
@@ -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'),
),
]
@@ -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'),
),
]
+9 -4
View File
@@ -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(
@@ -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,
@@ -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:
@@ -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:"))
@@ -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)
@@ -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",
),
]
@@ -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,
)
@@ -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) ---
+11 -2
View File
@@ -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)
-47
View File
@@ -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
@@ -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",
),
]
@@ -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,
)
-115
View File
@@ -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,
)
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TenancyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "hub_platform.tenancy"
@@ -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)
@@ -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]))
Whitespace-only changes.
Whitespace-only changes.
@@ -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}"
)
)
@@ -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
+43 -21
View File
@@ -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<public_id>[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
@@ -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')],
},
),
]
@@ -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)]
@@ -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)]
@@ -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)]
Whitespace-only changes.
@@ -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",
)
]
@@ -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
@@ -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")
@@ -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"])
@@ -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())
+5 -10
View File
@@ -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"])
@@ -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'),
),
]
@@ -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'),
),
]
+4 -1
View File
@@ -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")
+30 -10
View File
@@ -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"),
)
+122 -55
View File
@@ -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):
+1
View File
@@ -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
+21 -1
View File
@@ -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:
+48
View File
@@ -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