diff --git a/.gitattributes b/.gitattributes index db549ba..1a97660 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,13 @@ chatballs text eol=lf # Dockerfile и манифесты стека читают Linux-инструменты: CR в них ломает # RUN-строки и heredoc'и ровно так же, как шебанг. +# Python читают и переписывают линуксовые инструменты (ruff в контейнере, CI). +# На CRLF-чекауте ruff принимал за перевод строки одиночный CR и писал его в +# вывод — файлы оставались рабочими, но с + внутри; 21 такой файл уже +# доехал до репозитория. +*.py text eol=lf + Dockerfile text eol=lf Dockerfile.* text eol=lf *.Dockerfile text eol=lf diff --git a/apps/backend/chatballs/ai/limits.py b/apps/backend/chatballs/ai/limits.py index 045fcd2..7853692 100644 --- a/apps/backend/chatballs/ai/limits.py +++ b/apps/backend/chatballs/ai/limits.py @@ -1,32 +1,32 @@ -from django.conf import settings -from django.db.models import Sum -from django.utils import timezone - -from chatballs.ai.models import LlmInvocation, LlmInvocationStatus - - -class LimitExceeded(Exception): - pass - - -def _day_start(): - now = timezone.localtime() - return now.replace(hour=0, minute=0, second=0, microsecond=0) - - -def daily_cost_micros(channel=None) -> int: - queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS) - if channel is not None: - queryset = queryset.filter(channel=channel) - return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0 - - -def assert_within_limits(channel, agent) -> None: - global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS - if global_limit and daily_cost_micros() >= global_limit: - raise LimitExceeded("Global daily AI cost limit reached") - # Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается - # в micro-USD. 1 цент = 10 000 micro-USD. - channel_limit = (agent.limits or {}).get("dailyCostUsd") - if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000: - raise LimitExceeded("Channel daily AI cost limit reached") +from django.conf import settings +from django.db.models import Sum +from django.utils import timezone + +from chatballs.ai.models import LlmInvocation, LlmInvocationStatus + + +class LimitExceeded(Exception): + pass + + +def _day_start(): + now = timezone.localtime() + return now.replace(hour=0, minute=0, second=0, microsecond=0) + + +def daily_cost_micros(channel=None) -> int: + queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS) + if channel is not None: + queryset = queryset.filter(channel=channel) + return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0 + + +def assert_within_limits(channel, agent) -> None: + global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS + if global_limit and daily_cost_micros() >= global_limit: + raise LimitExceeded("Global daily AI cost limit reached") + # Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается + # в micro-USD. 1 цент = 10 000 micro-USD. + channel_limit = (agent.limits or {}).get("dailyCostUsd") + if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000: + raise LimitExceeded("Channel daily AI cost limit reached") diff --git a/apps/backend/chatballs/ai/pricing.py b/apps/backend/chatballs/ai/pricing.py index ac92a5a..e100291 100644 --- a/apps/backend/chatballs/ai/pricing.py +++ b/apps/backend/chatballs/ai/pricing.py @@ -1,17 +1,17 @@ -from django.conf import settings - -# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов. -# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost). -# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING. -DEFAULT_PRICING = { - "openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60}, - "anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0}, -} - - -def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int: - table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})} - price = table.get(model) - if not price: - return 0 - return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"]) +from django.conf import settings + +# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов. +# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost). +# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING. +DEFAULT_PRICING = { + "openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60}, + "anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0}, +} + + +def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int: + table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})} + price = table.get(model) + if not price: + return 0 + return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"]) diff --git a/apps/backend/chatballs/channels/apps.py b/apps/backend/chatballs/channels/apps.py index 1cec473..a478e30 100644 --- a/apps/backend/chatballs/channels/apps.py +++ b/apps/backend/chatballs/channels/apps.py @@ -1,8 +1,8 @@ -from django.apps import AppConfig - - -class ChannelsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - label = "channels" - name = "chatballs.channels" - verbose_name = "Processing channels (AI context)" +from django.apps import AppConfig + + +class ChannelsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + label = "channels" + name = "chatballs.channels" + verbose_name = "Processing channels (AI context)" diff --git a/apps/backend/chatballs/conversations/admin.py b/apps/backend/chatballs/conversations/admin.py index ed848a6..5eb0280 100644 --- a/apps/backend/chatballs/conversations/admin.py +++ b/apps/backend/chatballs/conversations/admin.py @@ -1,21 +1,21 @@ -from django.contrib import admin - -from chatballs.conversations.models import Contact, Conversation, Message - - -@admin.register(Conversation) -class ConversationAdmin(admin.ModelAdmin): - list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at") - list_filter = ("lifecycle", "control_mode") - - -@admin.register(Contact) -class ContactAdmin(admin.ModelAdmin): - list_display = ("id", "name", "organization", "created_at") - search_fields = ("name",) - - -@admin.register(Message) -class MessageAdmin(admin.ModelAdmin): - list_display = ("id", "conversation", "author_type", "created_at") - list_filter = ("author_type",) +from django.contrib import admin + +from chatballs.conversations.models import Contact, Conversation, Message + + +@admin.register(Conversation) +class ConversationAdmin(admin.ModelAdmin): + list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at") + list_filter = ("lifecycle", "control_mode") + + +@admin.register(Contact) +class ContactAdmin(admin.ModelAdmin): + list_display = ("id", "name", "organization", "created_at") + search_fields = ("name",) + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ("id", "conversation", "author_type", "created_at") + list_filter = ("author_type",) diff --git a/apps/backend/chatballs/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py b/apps/backend/chatballs/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py index 252103b..3d82a8c 100644 --- a/apps/backend/chatballs/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py +++ b/apps/backend/chatballs/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py @@ -1,44 +1,44 @@ -# Generated by Django 5.2.15 on 2026-09-04 20:40 - -import chatballs.conversations.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('conversations', '0011_conversation_archived_at_conversation_note_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='message', - name='audio', - field=models.FileField(blank=True, max_length=512, upload_to=chatballs.conversations.models.message_audio_upload_path), - ), - migrations.AddField( - model_name='message', - name='audio_content_type', - field=models.CharField(blank=True, max_length=64), - ), - migrations.AddField( - model_name='message', - name='duration_seconds', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='message', - name='transcript', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='message', - name='transcript_status', - field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8), - ), - migrations.AlterField( - model_name='message', - name='kind', - field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32), - ), - ] +# Generated by Django 5.2.15 on 2026-09-04 20:40 + +import chatballs.conversations.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0011_conversation_archived_at_conversation_note_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='audio', + field=models.FileField(blank=True, max_length=512, upload_to=chatballs.conversations.models.message_audio_upload_path), + ), + migrations.AddField( + model_name='message', + name='audio_content_type', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='message', + name='duration_seconds', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='message', + name='transcript', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='message', + name='transcript_status', + field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8), + ), + migrations.AlterField( + model_name='message', + name='kind', + field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32), + ), + ] diff --git a/apps/backend/chatballs/conversations/poller.py b/apps/backend/chatballs/conversations/poller.py index 23a6d72..8d58f54 100644 --- a/apps/backend/chatballs/conversations/poller.py +++ b/apps/backend/chatballs/conversations/poller.py @@ -1,38 +1,38 @@ -import logging - -from chatballs.conversations import transports -from chatballs.conversations.ingest import ingest_inbound -from chatballs.integrations.models import Integration - -logger = logging.getLogger(__name__) - - -def poll_all_messengers(context) -> int: - """Poll every messenger connection bound to a channel; ingest inbound. Returns count.""" - # Сервисные боты уведомлений поллятся отдельно (notifications.binding). - # Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки - # без ключа purpose (NULL в SQL), т.е. все клиентские боты. - integrations = [ - integration - for integration in Integration.objects.filter( - organization=context.organization, - provider__in=transports.SUPPORTED_PROVIDERS, - is_active=True, - channel__isnull=False, - channel__is_active=True, - ).exclude(secret="") - if integration.config.get("purpose") != "notifications" - ] - total = 0 - for integration in integrations: - messages, new_marker = transports.poll(integration) - for inbound in messages: - try: - ingest_inbound(integration, inbound) - total += 1 - except Exception: # pragma: no cover - logger.exception("Ingest failed for integration %s", integration.id) - if new_marker and new_marker != integration.poll_marker: - integration.poll_marker = new_marker - integration.save(update_fields=["poll_marker", "updated_at"]) - return total +import logging + +from chatballs.conversations import transports +from chatballs.conversations.ingest import ingest_inbound +from chatballs.integrations.models import Integration + +logger = logging.getLogger(__name__) + + +def poll_all_messengers(context) -> int: + """Poll every messenger connection bound to a channel; ingest inbound. Returns count.""" + # Сервисные боты уведомлений поллятся отдельно (notifications.binding). + # Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки + # без ключа purpose (NULL в SQL), т.е. все клиентские боты. + integrations = [ + integration + for integration in Integration.objects.filter( + organization=context.organization, + provider__in=transports.SUPPORTED_PROVIDERS, + is_active=True, + channel__isnull=False, + channel__is_active=True, + ).exclude(secret="") + if integration.config.get("purpose") != "notifications" + ] + total = 0 + for integration in integrations: + messages, new_marker = transports.poll(integration) + for inbound in messages: + try: + ingest_inbound(integration, inbound) + total += 1 + except Exception: # pragma: no cover + logger.exception("Ingest failed for integration %s", integration.id) + if new_marker and new_marker != integration.poll_marker: + integration.poll_marker = new_marker + integration.save(update_fields=["poll_marker", "updated_at"]) + return total diff --git a/apps/backend/chatballs/conversations/view_base.py b/apps/backend/chatballs/conversations/view_base.py index ffa6346..ee63044 100644 --- a/apps/backend/chatballs/conversations/view_base.py +++ b/apps/backend/chatballs/conversations/view_base.py @@ -1,43 +1,43 @@ -from rest_framework.request import Request -from rest_framework.views import APIView - -from chatballs.api.permissions import HasCapability -from chatballs.conversations.models import Conversation -from chatballs.conversations.selectors import conversation_for_context -from chatballs.identity.audit import record_audit_event -from chatballs.identity.policy import require_capability - - -class ConversationViewBase(APIView): - permission_classes = [HasCapability] - required_capability = "conversations.view" - - def _org(self, request: Request): - return request.tenant_context.organization - - def _conversation( - self, - request: Request, - conversation_id: int, - capability: str = "conversations.view", - ) -> Conversation: - conversation = conversation_for_context( - context=request.tenant_context, conversation_id=conversation_id - ) - if not require_capability( - request.tenant_context.membership, capability, conversation - ): - raise Conversation.DoesNotExist - return conversation - - def _audit( - self, request: Request, action: str, conversation: Conversation - ) -> None: - record_audit_event( - action=f"conversations.{action}", - actor=request.user, - organization=self._org(request), - object_type="Conversation", - object_id=str(conversation.id), - request=request, - ) +from rest_framework.request import Request +from rest_framework.views import APIView + +from chatballs.api.permissions import HasCapability +from chatballs.conversations.models import Conversation +from chatballs.conversations.selectors import conversation_for_context +from chatballs.identity.audit import record_audit_event +from chatballs.identity.policy import require_capability + + +class ConversationViewBase(APIView): + permission_classes = [HasCapability] + required_capability = "conversations.view" + + def _org(self, request: Request): + return request.tenant_context.organization + + def _conversation( + self, + request: Request, + conversation_id: int, + capability: str = "conversations.view", + ) -> Conversation: + conversation = conversation_for_context( + context=request.tenant_context, conversation_id=conversation_id + ) + if not require_capability( + request.tenant_context.membership, capability, conversation + ): + raise Conversation.DoesNotExist + return conversation + + def _audit( + self, request: Request, action: str, conversation: Conversation + ) -> None: + record_audit_event( + action=f"conversations.{action}", + actor=request.user, + organization=self._org(request), + object_type="Conversation", + object_id=str(conversation.id), + request=request, + ) diff --git a/apps/backend/chatballs/identity/migrations/0016_channel_capabilities.py b/apps/backend/chatballs/identity/migrations/0016_channel_capabilities.py index 4f7abe2..19cfbc0 100644 --- a/apps/backend/chatballs/identity/migrations/0016_channel_capabilities.py +++ b/apps/backend/chatballs/identity/migrations/0016_channel_capabilities.py @@ -1,82 +1,82 @@ -# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1. -# -# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям -# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в -# момент выката. -# -# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на -# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не -# трогаем назначения: каждое действующее назначение профиля продолжает работать -# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так -# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается. -from django.db import migrations, models - -AI_VIEW = "ai.view" -AI_MANAGE = "ai.manage" -CHANNELS_VIEW = "channels.view" -CHANNELS_MANAGE = "channels.manage" - - -def _grant(apps, *, source: str, target: str) -> None: - AccessProfile = apps.get_model("identity", "AccessProfile") - AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability") - - already_granted = set( - AccessProfileCapability.objects.filter(capability_code=target).values_list( - "access_profile_id", flat=True - ) - ) - # organization_id берётся у профиля, а не выводится: триггер - # chatballs.enforce_tenant_fk требует совпадения с владельцем профиля. - profiles = ( - AccessProfile.objects.filter(capability_links__capability_code=source) - .values_list("id", "organization_id") - .distinct() - ) - AccessProfileCapability.objects.bulk_create( - [ - AccessProfileCapability( - access_profile_id=profile_id, - organization_id=organization_id, - capability_code=target, - ) - for profile_id, organization_id in profiles - if profile_id not in already_granted - ] - ) - - -def grant_channel_capabilities(apps, schema_editor): - _grant(apps, source=AI_VIEW, target=CHANNELS_VIEW) - _grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE) - - -def revoke_channel_capabilities(apps, schema_editor): - # В отличие от 0010 откат обязан удалить выданные строки: следом - # восстанавливается прежний check-constraint реестра, и строки с кодом вне - # списка сделали бы обратную миграцию невыполнимой. - AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability") - AccessProfileCapability.objects.filter( - capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE) - ).delete() - - -class Migration(migrations.Migration): - - dependencies = [ - ('identity', '0015_organization_status'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='accessprofilecapability', - name='access_profile_capability_registry', - ), - migrations.AddConstraint( - model_name='accessprofilecapability', - constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'), - ), - # Порядок важен: при откате Django исполняет операции в обратном порядке, - # поэтому строки удаляются до восстановления старого constraint. - migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities), - ] +# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1. +# +# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям +# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в +# момент выката. +# +# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на +# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не +# трогаем назначения: каждое действующее назначение профиля продолжает работать +# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так +# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается. +from django.db import migrations, models + +AI_VIEW = "ai.view" +AI_MANAGE = "ai.manage" +CHANNELS_VIEW = "channels.view" +CHANNELS_MANAGE = "channels.manage" + + +def _grant(apps, *, source: str, target: str) -> None: + AccessProfile = apps.get_model("identity", "AccessProfile") + AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability") + + already_granted = set( + AccessProfileCapability.objects.filter(capability_code=target).values_list( + "access_profile_id", flat=True + ) + ) + # organization_id берётся у профиля, а не выводится: триггер + # chatballs.enforce_tenant_fk требует совпадения с владельцем профиля. + profiles = ( + AccessProfile.objects.filter(capability_links__capability_code=source) + .values_list("id", "organization_id") + .distinct() + ) + AccessProfileCapability.objects.bulk_create( + [ + AccessProfileCapability( + access_profile_id=profile_id, + organization_id=organization_id, + capability_code=target, + ) + for profile_id, organization_id in profiles + if profile_id not in already_granted + ] + ) + + +def grant_channel_capabilities(apps, schema_editor): + _grant(apps, source=AI_VIEW, target=CHANNELS_VIEW) + _grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE) + + +def revoke_channel_capabilities(apps, schema_editor): + # В отличие от 0010 откат обязан удалить выданные строки: следом + # восстанавливается прежний check-constraint реестра, и строки с кодом вне + # списка сделали бы обратную миграцию невыполнимой. + AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability") + AccessProfileCapability.objects.filter( + capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE) + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0015_organization_status'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='accessprofilecapability', + name='access_profile_capability_registry', + ), + migrations.AddConstraint( + model_name='accessprofilecapability', + constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'), + ), + # Порядок важен: при откате Django исполняет операции в обратном порядке, + # поэтому строки удаляются до восстановления старого constraint. + migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities), + ] diff --git a/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py b/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py index 1deb3eb..cd522ca 100644 --- a/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py +++ b/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py @@ -1,173 +1,173 @@ -# Generated by Django 5.2.15 on 2026-09-03 22:23 - -import django.db.models.deletion -import django.db.models.functions.text -from django.db import migrations, models - -# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и -# грантов, которые исходно раздавала tenancy/0003 (она при откате не -# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут -# читать identity_department и migration-тесты падают на старых состояниях. -# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при -# откате (операции разворачиваются в обратном порядке), когда таблицы уже -# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях -# тестами не используется, поэтому политики не восстанавливаем. -_RESTORE_GRANTS_SQL = "\n".join( - f""" -ALTER TABLE {table} OWNER TO chatballs_schema; -GRANT ALL ON {table} TO chatballs_schema; -GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app; -""" - for table in ( - "identity_department", - "identity_accessprofile", - "identity_accessprofilecapability", - "identity_employeeaccessassignment", - ) -) - - -class Migration(migrations.Migration): - - dependencies = [ - ('ai', '0014_remove_knowledgedepartment_department_and_more'), - ('channels', '0006_remove_channel_department'), - ('identity', '0019_drop_sales_capabilities'), - ('notifications', '0008_remove_notification_department'), - ('support_portals', '0008_remove_supportportal_department'), - ] - - operations = [ - migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL), - migrations.CreateModel( - name='EmployeeGroup', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=120)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ], - options={ - 'ordering': ['name'], - }, - ), - migrations.CreateModel( - name='EmployeeGroupMember', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ], - ), - # Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе - # state хранит constraint на несуществующее поле и обратная миграция - # (reverse DeleteModel в migration-тестах) падает при create_model. - migrations.RemoveConstraint( - model_name='accessprofile', - name='uniq_access_profile_org_name_ci', - ), - migrations.RemoveConstraint( - model_name='accessprofilecapability', - name='uniq_access_profile_capability', - ), - migrations.RemoveConstraint( - model_name='department', - name='uniq_department_org_code', - ), - migrations.RemoveConstraint( - model_name='employeeaccessassignment', - name='access_assignment_scope_department', - ), - migrations.RemoveConstraint( - model_name='employeeaccessassignment', - name='uniq_active_org_access_assignment', - ), - migrations.RemoveConstraint( - model_name='employeeaccessassignment', - name='uniq_active_dept_access_assignment', - ), - migrations.RemoveField( - model_name='accessprofile', - name='organization', - ), - migrations.RemoveField( - model_name='accessprofilecapability', - name='access_profile', - ), - migrations.RemoveField( - model_name='employeeaccessassignment', - name='access_profile', - ), - migrations.RemoveField( - model_name='accessprofilecapability', - name='organization', - ), - migrations.RemoveField( - model_name='department', - name='organization', - ), - # Сначала снимаем constraint, зависящий от primary_department: дроп - # колонки удалил бы его каскадно и RemoveConstraint ниже упал бы. - migrations.RemoveConstraint( - model_name='organizationmembership', - name='owner_is_company_level', - ), - migrations.RemoveField( - model_name='organizationmembership', - name='primary_department', - ), - migrations.RemoveField( - model_name='employeeaccessassignment', - name='department', - ), - migrations.RemoveField( - model_name='employeeaccessassignment', - name='assigned_by', - ), - migrations.RemoveField( - model_name='employeeaccessassignment', - name='employee', - ), - migrations.RemoveField( - model_name='employeeaccessassignment', - name='organization', - ), - migrations.AddField( - model_name='employeegroup', - name='organization', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'), - ), - migrations.AddField( - model_name='employeegroupmember', - name='employee', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'), - ), - migrations.AddField( - model_name='employeegroupmember', - name='group', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'), - ), - migrations.AddField( - model_name='employeegroupmember', - name='organization', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), - ), - migrations.DeleteModel( - name='AccessProfile', - ), - migrations.DeleteModel( - name='AccessProfileCapability', - ), - migrations.DeleteModel( - name='Department', - ), - migrations.DeleteModel( - name='EmployeeAccessAssignment', - ), - migrations.AddConstraint( - model_name='employeegroup', - constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'), - ), - migrations.AddConstraint( - model_name='employeegroupmember', - constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'), - ), - ] +# Generated by Django 5.2.15 on 2026-09-03 22:23 + +import django.db.models.deletion +import django.db.models.functions.text +from django.db import migrations, models + +# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и +# грантов, которые исходно раздавала tenancy/0003 (она при откате не +# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут +# читать identity_department и migration-тесты падают на старых состояниях. +# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при +# откате (операции разворачиваются в обратном порядке), когда таблицы уже +# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях +# тестами не используется, поэтому политики не восстанавливаем. +_RESTORE_GRANTS_SQL = "\n".join( + f""" +ALTER TABLE {table} OWNER TO chatballs_schema; +GRANT ALL ON {table} TO chatballs_schema; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app; +""" + for table in ( + "identity_department", + "identity_accessprofile", + "identity_accessprofilecapability", + "identity_employeeaccessassignment", + ) +) + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai', '0014_remove_knowledgedepartment_department_and_more'), + ('channels', '0006_remove_channel_department'), + ('identity', '0019_drop_sales_capabilities'), + ('notifications', '0008_remove_notification_department'), + ('support_portals', '0008_remove_supportportal_department'), + ] + + operations = [ + migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL), + migrations.CreateModel( + name='EmployeeGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='EmployeeGroupMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + # Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе + # state хранит constraint на несуществующее поле и обратная миграция + # (reverse DeleteModel в migration-тестах) падает при create_model. + migrations.RemoveConstraint( + model_name='accessprofile', + name='uniq_access_profile_org_name_ci', + ), + migrations.RemoveConstraint( + model_name='accessprofilecapability', + name='uniq_access_profile_capability', + ), + migrations.RemoveConstraint( + model_name='department', + name='uniq_department_org_code', + ), + migrations.RemoveConstraint( + model_name='employeeaccessassignment', + name='access_assignment_scope_department', + ), + migrations.RemoveConstraint( + model_name='employeeaccessassignment', + name='uniq_active_org_access_assignment', + ), + migrations.RemoveConstraint( + model_name='employeeaccessassignment', + name='uniq_active_dept_access_assignment', + ), + migrations.RemoveField( + model_name='accessprofile', + name='organization', + ), + migrations.RemoveField( + model_name='accessprofilecapability', + name='access_profile', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='access_profile', + ), + migrations.RemoveField( + model_name='accessprofilecapability', + name='organization', + ), + migrations.RemoveField( + model_name='department', + name='organization', + ), + # Сначала снимаем constraint, зависящий от primary_department: дроп + # колонки удалил бы его каскадно и RemoveConstraint ниже упал бы. + migrations.RemoveConstraint( + model_name='organizationmembership', + name='owner_is_company_level', + ), + migrations.RemoveField( + model_name='organizationmembership', + name='primary_department', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='department', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='assigned_by', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='employee', + ), + migrations.RemoveField( + model_name='employeeaccessassignment', + name='organization', + ), + migrations.AddField( + model_name='employeegroup', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'), + ), + migrations.AddField( + model_name='employeegroupmember', + name='employee', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'), + ), + migrations.AddField( + model_name='employeegroupmember', + name='group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'), + ), + migrations.AddField( + model_name='employeegroupmember', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), + ), + migrations.DeleteModel( + name='AccessProfile', + ), + migrations.DeleteModel( + name='AccessProfileCapability', + ), + migrations.DeleteModel( + name='Department', + ), + migrations.DeleteModel( + name='EmployeeAccessAssignment', + ), + migrations.AddConstraint( + model_name='employeegroup', + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'), + ), + migrations.AddConstraint( + model_name='employeegroupmember', + constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'), + ), + ] diff --git a/apps/backend/chatballs/integrations/admin.py b/apps/backend/chatballs/integrations/admin.py index f00b223..7af71b3 100644 --- a/apps/backend/chatballs/integrations/admin.py +++ b/apps/backend/chatballs/integrations/admin.py @@ -1,11 +1,11 @@ -from django.contrib import admin - -from chatballs.integrations.models import Integration - - -@admin.register(Integration) -class IntegrationAdmin(admin.ModelAdmin): - list_display = ("name", "provider", "kind", "status", "last_checked_at") - list_filter = ("provider", "kind", "status") - search_fields = ("name",) - readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at") +from django.contrib import admin + +from chatballs.integrations.models import Integration + + +@admin.register(Integration) +class IntegrationAdmin(admin.ModelAdmin): + list_display = ("name", "provider", "kind", "status", "last_checked_at") + list_filter = ("provider", "kind", "status") + search_fields = ("name",) + readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at") diff --git a/apps/backend/chatballs/integrations/apps.py b/apps/backend/chatballs/integrations/apps.py index b19ef09..e8e5a56 100644 --- a/apps/backend/chatballs/integrations/apps.py +++ b/apps/backend/chatballs/integrations/apps.py @@ -1,8 +1,8 @@ -from django.apps import AppConfig - - -class IntegrationsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - label = "integrations" - name = "chatballs.integrations" - verbose_name = "Integrations: providers and connections" +from django.apps import AppConfig + + +class IntegrationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + label = "integrations" + name = "chatballs.integrations" + verbose_name = "Integrations: providers and connections" diff --git a/apps/backend/chatballs/integrations/migrations/0001_initial.py b/apps/backend/chatballs/integrations/migrations/0001_initial.py index 9237772..c2113d5 100644 --- a/apps/backend/chatballs/integrations/migrations/0001_initial.py +++ b/apps/backend/chatballs/integrations/migrations/0001_initial.py @@ -1,38 +1,38 @@ -# Generated by Django 5.2.15 on 2026-06-27 21:12 - -import django.db.models.deletion -import chatballs.identity.crypto -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('identity', '0006_alter_employeeprofile_totp_secret'), - ] - - operations = [ - migrations.CreateModel( - name='Integration', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)), - ('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)), - ('name', models.CharField(max_length=255)), - ('secret', chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1024)), - ('config', models.JSONField(blank=True, default=dict)), - ('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)), - ('last_checked_at', models.DateTimeField(blank=True, null=True)), - ('last_error', models.TextField(blank=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')), - ], - options={ - 'ordering': ['provider', 'name'], - 'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')], - }, - ), - ] +# Generated by Django 5.2.15 on 2026-06-27 21:12 + +import django.db.models.deletion +import chatballs.identity.crypto +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('identity', '0006_alter_employeeprofile_totp_secret'), + ] + + operations = [ + migrations.CreateModel( + name='Integration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)), + ('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)), + ('name', models.CharField(max_length=255)), + ('secret', chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1024)), + ('config', models.JSONField(blank=True, default=dict)), + ('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)), + ('last_checked_at', models.DateTimeField(blank=True, null=True)), + ('last_error', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')), + ], + options={ + 'ordering': ['provider', 'name'], + 'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')], + }, + ), + ] diff --git a/apps/backend/chatballs/integrations/urls.py b/apps/backend/chatballs/integrations/urls.py index 17cb7ac..e88653e 100644 --- a/apps/backend/chatballs/integrations/urls.py +++ b/apps/backend/chatballs/integrations/urls.py @@ -1,9 +1,9 @@ -from django.urls import path - -from chatballs.integrations import views - -urlpatterns = [ - path("", views.IntegrationListView.as_view(), name="integration-list"), - path("/", views.IntegrationDetailView.as_view(), name="integration-detail"), - path("/test/", views.IntegrationTestView.as_view(), name="integration-test"), -] +from django.urls import path + +from chatballs.integrations import views + +urlpatterns = [ + path("", views.IntegrationListView.as_view(), name="integration-list"), + path("/", views.IntegrationDetailView.as_view(), name="integration-detail"), + path("/test/", views.IntegrationTestView.as_view(), name="integration-test"), +] diff --git a/apps/backend/chatballs/notifications/admin.py b/apps/backend/chatballs/notifications/admin.py index c2428ba..2fae084 100644 --- a/apps/backend/chatballs/notifications/admin.py +++ b/apps/backend/chatballs/notifications/admin.py @@ -1,15 +1,15 @@ -from django.contrib import admin - -from chatballs.notifications.models import Notification, NotificationRead - - -@admin.register(Notification) -class NotificationAdmin(admin.ModelAdmin): - list_display = ("type", "audience", "level", "title", "organization", "created_at") - list_filter = ("type", "audience", "level") - search_fields = ("title", "body", "dedup_key") - - -@admin.register(NotificationRead) -class NotificationReadAdmin(admin.ModelAdmin): - list_display = ("notification", "user", "read_at") +from django.contrib import admin + +from chatballs.notifications.models import Notification, NotificationRead + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ("type", "audience", "level", "title", "organization", "created_at") + list_filter = ("type", "audience", "level") + search_fields = ("title", "body", "dedup_key") + + +@admin.register(NotificationRead) +class NotificationReadAdmin(admin.ModelAdmin): + list_display = ("notification", "user", "read_at") diff --git a/apps/backend/chatballs/notifications/apps.py b/apps/backend/chatballs/notifications/apps.py index 8b1cc0f..4f25ddd 100644 --- a/apps/backend/chatballs/notifications/apps.py +++ b/apps/backend/chatballs/notifications/apps.py @@ -1,11 +1,11 @@ -from django.apps import AppConfig - - -class NotificationsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - label = "notifications" - name = "chatballs.notifications" - verbose_name = "Notifications" - - def ready(self) -> None: - from chatballs.notifications import event_handlers # noqa: F401 (register outbox handlers) +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + label = "notifications" + name = "chatballs.notifications" + verbose_name = "Notifications" + + def ready(self) -> None: + from chatballs.notifications import event_handlers # noqa: F401 (register outbox handlers) diff --git a/apps/backend/chatballs/notifications/serializers.py b/apps/backend/chatballs/notifications/serializers.py index 07b9b90..59f11e2 100644 --- a/apps/backend/chatballs/notifications/serializers.py +++ b/apps/backend/chatballs/notifications/serializers.py @@ -1,15 +1,15 @@ -from chatballs.notifications.models import Notification - - -def notification_payload(notification: Notification, *, unread: bool) -> dict[str, object]: - return { - "id": notification.id, - "type": notification.type, - "level": notification.level, - "title": notification.title, - "body": notification.body, - "targetRoute": notification.target_route, - "targetId": notification.target_id, - "createdAt": notification.created_at.isoformat(), - "unread": unread, - } +from chatballs.notifications.models import Notification + + +def notification_payload(notification: Notification, *, unread: bool) -> dict[str, object]: + return { + "id": notification.id, + "type": notification.type, + "level": notification.level, + "title": notification.title, + "body": notification.body, + "targetRoute": notification.target_route, + "targetId": notification.target_id, + "createdAt": notification.created_at.isoformat(), + "unread": unread, + } diff --git a/apps/backend/chatballs/notifications/urls.py b/apps/backend/chatballs/notifications/urls.py index 2110bb7..d9d6bce 100644 --- a/apps/backend/chatballs/notifications/urls.py +++ b/apps/backend/chatballs/notifications/urls.py @@ -1,10 +1,10 @@ -from django.urls import path - -from chatballs.notifications import views - -urlpatterns = [ - path("", views.NotificationListView.as_view(), name="notification-list"), - path("read/", views.NotificationReadView.as_view(), name="notification-read"), - path("messenger-bindings/", views.MessengerBindingListView.as_view(), name="messenger-binding-list"), - path("messenger-bindings//", views.MessengerBindingDetailView.as_view(), name="messenger-binding-detail"), -] +from django.urls import path + +from chatballs.notifications import views + +urlpatterns = [ + path("", views.NotificationListView.as_view(), name="notification-list"), + path("read/", views.NotificationReadView.as_view(), name="notification-read"), + path("messenger-bindings/", views.MessengerBindingListView.as_view(), name="messenger-binding-list"), + path("messenger-bindings//", views.MessengerBindingDetailView.as_view(), name="messenger-binding-detail"), +] diff --git a/apps/backend/chatballs/tenancy/migrations/0007_platform_row_select_policies.py b/apps/backend/chatballs/tenancy/migrations/0007_platform_row_select_policies.py index 4b47347..9ed42ae 100644 --- a/apps/backend/chatballs/tenancy/migrations/0007_platform_row_select_policies.py +++ b/apps/backend/chatballs/tenancy/migrations/0007_platform_row_select_policies.py @@ -1,27 +1,27 @@ -from django.db import migrations - -# Django ORM inserts use INSERT ... RETURNING id; PostgreSQL applies SELECT -# policies to rows returned by RETURNING, so the app role needs SELECT -# visibility of the platform-scope rows it is allowed to insert -# (chatballs_app_platform_audit_insert / chatballs_app_platform_outbox_insert). -# Without these policies a login-failed audit write fails with -# "new row violates row-level security policy". -CREATE_POLICIES = """ -DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent; -CREATE POLICY chatballs_app_platform_audit_select ON identity_auditevent - FOR SELECT TO chatballs_runtime_app USING (organization_id IS NULL); -DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent; -CREATE POLICY chatballs_app_platform_outbox_select ON events_outboxevent - FOR SELECT TO chatballs_runtime_app - USING (ownership = 'PLATFORM' AND organization_id IS NULL); -""" - -DROP_POLICIES = """ -DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent; -DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent; -""" - - -class Migration(migrations.Migration): - dependencies = [("tenancy", "0006_storage_reserved_bytes")] - operations = [migrations.RunSQL(CREATE_POLICIES, DROP_POLICIES)] +from django.db import migrations + +# Django ORM inserts use INSERT ... RETURNING id; PostgreSQL applies SELECT +# policies to rows returned by RETURNING, so the app role needs SELECT +# visibility of the platform-scope rows it is allowed to insert +# (chatballs_app_platform_audit_insert / chatballs_app_platform_outbox_insert). +# Without these policies a login-failed audit write fails with +# "new row violates row-level security policy". +CREATE_POLICIES = """ +DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent; +CREATE POLICY chatballs_app_platform_audit_select ON identity_auditevent + FOR SELECT TO chatballs_runtime_app USING (organization_id IS NULL); +DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent; +CREATE POLICY chatballs_app_platform_outbox_select ON events_outboxevent + FOR SELECT TO chatballs_runtime_app + USING (ownership = 'PLATFORM' AND organization_id IS NULL); +""" + +DROP_POLICIES = """ +DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent; +DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent; +""" + + +class Migration(migrations.Migration): + dependencies = [("tenancy", "0006_storage_reserved_bytes")] + operations = [migrations.RunSQL(CREATE_POLICIES, DROP_POLICIES)] diff --git a/apps/backend/chatballs/tenancy/migrations/0009_tenant_guards_security_definer.py b/apps/backend/chatballs/tenancy/migrations/0009_tenant_guards_security_definer.py index 4010764..20851e0 100644 --- a/apps/backend/chatballs/tenancy/migrations/0009_tenant_guards_security_definer.py +++ b/apps/backend/chatballs/tenancy/migrations/0009_tenant_guards_security_definer.py @@ -1,43 +1,43 @@ -"""Guard-функции C04 должны видеть строки поверх RLS (SECURITY DEFINER). - -Функции проверяют целостность связей между тенантами и обязаны читать -родительскую строку независимо от политик вызывающей роли. Как SECURITY -INVOKER их внутренний SELECT подчинялся RLS: из кросс-тенантного пути без -выставленного `chatballs.organization_id` (outbox-воркер claim'ит событие -любой организации) родитель не виден, parent_org = NULL, и проверка -`IS DISTINCT FROM` ложно срабатывала — легитимный UPDATE падал с -`cross-tenant relation`, что уводило воркер в краш-петлю. - -SECURITY DEFINER исполняет их от chatballs_schema (policy USING (true)), -поэтому сравнение идёт по фактическим данным. Проверка не ослабляется: -настоящее нарушение по-прежнему приводит к RAISE. search_path закреплён, -как того требует безопасность SECURITY DEFINER-функций. -""" - -from django.db import migrations - - -GUARD_FUNCTIONS = ( - "chatballs.enforce_tenant_fk()", - "chatballs.enforce_tenant_user()", - "chatballs.enforce_tenant_pair()", -) - - -def set_security_definer(apps, schema_editor): - for function in GUARD_FUNCTIONS: - schema_editor.execute( - f"ALTER FUNCTION {function} SECURITY DEFINER SET search_path = public, pg_temp" - ) - - -def set_security_invoker(apps, schema_editor): - for function in GUARD_FUNCTIONS: - schema_editor.execute( - f"ALTER FUNCTION {function} SECURITY INVOKER RESET search_path" - ) - - -class Migration(migrations.Migration): - dependencies = [("tenancy", "0008_ai_knowledge_scope_guards")] - operations = [migrations.RunPython(set_security_definer, set_security_invoker)] +"""Guard-функции C04 должны видеть строки поверх RLS (SECURITY DEFINER). + +Функции проверяют целостность связей между тенантами и обязаны читать +родительскую строку независимо от политик вызывающей роли. Как SECURITY +INVOKER их внутренний SELECT подчинялся RLS: из кросс-тенантного пути без +выставленного `chatballs.organization_id` (outbox-воркер claim'ит событие +любой организации) родитель не виден, parent_org = NULL, и проверка +`IS DISTINCT FROM` ложно срабатывала — легитимный UPDATE падал с +`cross-tenant relation`, что уводило воркер в краш-петлю. + +SECURITY DEFINER исполняет их от chatballs_schema (policy USING (true)), +поэтому сравнение идёт по фактическим данным. Проверка не ослабляется: +настоящее нарушение по-прежнему приводит к RAISE. search_path закреплён, +как того требует безопасность SECURITY DEFINER-функций. +""" + +from django.db import migrations + + +GUARD_FUNCTIONS = ( + "chatballs.enforce_tenant_fk()", + "chatballs.enforce_tenant_user()", + "chatballs.enforce_tenant_pair()", +) + + +def set_security_definer(apps, schema_editor): + for function in GUARD_FUNCTIONS: + schema_editor.execute( + f"ALTER FUNCTION {function} SECURITY DEFINER SET search_path = public, pg_temp" + ) + + +def set_security_invoker(apps, schema_editor): + for function in GUARD_FUNCTIONS: + schema_editor.execute( + f"ALTER FUNCTION {function} SECURITY INVOKER RESET search_path" + ) + + +class Migration(migrations.Migration): + dependencies = [("tenancy", "0008_ai_knowledge_scope_guards")] + operations = [migrations.RunPython(set_security_definer, set_security_invoker)] diff --git a/apps/backend/chatballs/webchat/apps.py b/apps/backend/chatballs/webchat/apps.py index 7de75ba..f0a65a6 100644 --- a/apps/backend/chatballs/webchat/apps.py +++ b/apps/backend/chatballs/webchat/apps.py @@ -1,8 +1,8 @@ -from django.apps import AppConfig - - -class WebchatConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - label = "webchat" - name = "chatballs.webchat" - verbose_name = "Web chat widget" +from django.apps import AppConfig + + +class WebchatConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + label = "webchat" + name = "chatballs.webchat" + verbose_name = "Web chat widget" diff --git a/apps/backend/chatballs_backend/settings_database.py b/apps/backend/chatballs_backend/settings_database.py index c902b96..1e251a9 100644 --- a/apps/backend/chatballs_backend/settings_database.py +++ b/apps/backend/chatballs_backend/settings_database.py @@ -1,93 +1,93 @@ -import os - -from django.core.exceptions import ImproperlyConfigured - -from chatballs_backend.settings_env import env_secret - - -def _credentials() -> tuple[dict[str, str], dict[str, str]]: - # Имена ролей — константы продукта, а не настройка установки: их создаёт - # deploy/postgres/init-runtime-roles.sh при первом старте. Переменные - # окружения остаются переопределением для нестандартных установок. - users = { - "app": os.environ.get("POSTGRES_APP_USER", "chatballs_app"), - "platform": os.environ.get("POSTGRES_PLATFORM_USER", "chatballs_platform"), - "migration": os.environ.get( - "POSTGRES_MIGRATION_USER", "chatballs_migration" - ), - } - # Пароли ролей генерирует первый старт стека в том с секретами; человек их - # не вводит и не хранит. Переменные окружения остаются переопределением. - fallback = env_secret("POSTGRES_PASSWORD", "postgres_password", "chatballs") - passwords = { - "app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback), - "platform": env_secret( - "POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback - ), - "migration": env_secret( - "POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback - ), - } - return users, passwords - - -def _pool_options(*, testing: bool) -> dict | None: - # ASGI-серверы исполняют ORM в короткоживущих потоках sync_to_async; - # persistent-соединения (CONN_MAX_AGE > 0) в таких потоках осиротевают и - # исчерпывают max_connections Postgres. Вместо них — psycopg pool на процесс: - # соединения возвращаются в пул независимо от потока и ограничены сверху. - # CHATBALLS_DB_POOL_MAX=0 отключает пул (короткоживущие соединения на запрос). - if testing: - return None - max_size = int(os.environ.get("CHATBALLS_DB_POOL_MAX", "4")) - if max_size <= 0: - return None - return { - "min_size": int(os.environ.get("CHATBALLS_DB_POOL_MIN", "1")), - "max_size": max_size, - "timeout": float(os.environ.get("CHATBALLS_DB_POOL_TIMEOUT", "10")), - } - - -def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]: - role = os.environ.get("CHATBALLS_DB_ROLE", "app").lower() - if role not in {"app", "platform", "migration"}: - raise ImproperlyConfigured("CHATBALLS_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" - ) - pool = _pool_options(testing=testing) - - def config(selected_role: str) -> dict: - return { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("POSTGRES_DB", "chatballs"), - "USER": users[selected_role], - "PASSWORD": passwords[selected_role], - "HOST": os.environ.get("POSTGRES_HOST", "postgres"), - "PORT": os.environ.get("POSTGRES_PORT", "5432"), - # Пул несовместим с persistent-соединениями: с ним CONN_MAX_AGE - # обязан быть 0, а без пула persistent-режим возвращать нельзя - # (см. _pool_options). - "CONN_MAX_AGE": 0, - "OPTIONS": {"pool": dict(pool)} if pool else {}, - } - - databases = { - "default": config("migration" if testing else role), - "platform": config("platform"), - } - if testing: - # Тесты создают свою БД и подключаются владельцем кластера. Его пароль - # приходит оттуда же, откуда у остальных ролей: файл секрета инстанса, - # переменная окружения — переопределение. - databases["default"]["USER"] = os.environ.get( - "POSTGRES_USER", "chatballs_bootstrap" - ) - databases["default"]["PASSWORD"] = env_secret( - "POSTGRES_PASSWORD", "postgres_password", "chatballs" - ) - databases["platform"]["TEST"] = {"MIRROR": "default"} - return databases +import os + +from django.core.exceptions import ImproperlyConfigured + +from chatballs_backend.settings_env import env_secret + + +def _credentials() -> tuple[dict[str, str], dict[str, str]]: + # Имена ролей — константы продукта, а не настройка установки: их создаёт + # deploy/postgres/init-runtime-roles.sh при первом старте. Переменные + # окружения остаются переопределением для нестандартных установок. + users = { + "app": os.environ.get("POSTGRES_APP_USER", "chatballs_app"), + "platform": os.environ.get("POSTGRES_PLATFORM_USER", "chatballs_platform"), + "migration": os.environ.get( + "POSTGRES_MIGRATION_USER", "chatballs_migration" + ), + } + # Пароли ролей генерирует первый старт стека в том с секретами; человек их + # не вводит и не хранит. Переменные окружения остаются переопределением. + fallback = env_secret("POSTGRES_PASSWORD", "postgres_password", "chatballs") + passwords = { + "app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback), + "platform": env_secret( + "POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback + ), + "migration": env_secret( + "POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback + ), + } + return users, passwords + + +def _pool_options(*, testing: bool) -> dict | None: + # ASGI-серверы исполняют ORM в короткоживущих потоках sync_to_async; + # persistent-соединения (CONN_MAX_AGE > 0) в таких потоках осиротевают и + # исчерпывают max_connections Postgres. Вместо них — psycopg pool на процесс: + # соединения возвращаются в пул независимо от потока и ограничены сверху. + # CHATBALLS_DB_POOL_MAX=0 отключает пул (короткоживущие соединения на запрос). + if testing: + return None + max_size = int(os.environ.get("CHATBALLS_DB_POOL_MAX", "4")) + if max_size <= 0: + return None + return { + "min_size": int(os.environ.get("CHATBALLS_DB_POOL_MIN", "1")), + "max_size": max_size, + "timeout": float(os.environ.get("CHATBALLS_DB_POOL_TIMEOUT", "10")), + } + + +def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]: + role = os.environ.get("CHATBALLS_DB_ROLE", "app").lower() + if role not in {"app", "platform", "migration"}: + raise ImproperlyConfigured("CHATBALLS_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" + ) + pool = _pool_options(testing=testing) + + def config(selected_role: str) -> dict: + return { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB", "chatballs"), + "USER": users[selected_role], + "PASSWORD": passwords[selected_role], + "HOST": os.environ.get("POSTGRES_HOST", "postgres"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + # Пул несовместим с persistent-соединениями: с ним CONN_MAX_AGE + # обязан быть 0, а без пула persistent-режим возвращать нельзя + # (см. _pool_options). + "CONN_MAX_AGE": 0, + "OPTIONS": {"pool": dict(pool)} if pool else {}, + } + + databases = { + "default": config("migration" if testing else role), + "platform": config("platform"), + } + if testing: + # Тесты создают свою БД и подключаются владельцем кластера. Его пароль + # приходит оттуда же, откуда у остальных ролей: файл секрета инстанса, + # переменная окружения — переопределение. + databases["default"]["USER"] = os.environ.get( + "POSTGRES_USER", "chatballs_bootstrap" + ) + databases["default"]["PASSWORD"] = env_secret( + "POSTGRES_PASSWORD", "postgres_password", "chatballs" + ) + databases["platform"]["TEST"] = {"MIRROR": "default"} + return databases