mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🔒 feat(tenancy): enforce RLS and storage isolation
This commit is contained in:
1 parent
30f9eb3d1b
commit
48ece587a9
98 files changed
+3759
-682
No files matched your search
+19
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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,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)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
+30
@@ -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'),
|
||||
),
|
||||
]
|
||||
+71
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
# Явный шаринг контакта: сообщение без текста, но с телефоном.
|
||||
|
||||
+30
@@ -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'),
|
||||
),
|
||||
]
|
||||
+62
@@ -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="+")
|
||||
|
||||
@@ -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)
|
||||
+35
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
+24
@@ -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'),
|
||||
),
|
||||
]
|
||||
+55
@@ -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)
|
||||
|
||||
+30
@@ -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'),
|
||||
),
|
||||
]
|
||||
+49
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
+35
@@ -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'),
|
||||
),
|
||||
]
|
||||
+85
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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:"))
|
||||
|
||||
+19
-12
@@ -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) ---
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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,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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user