From 1390ccc426a482663602a052fa1e7e0348e5a995 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 12 Sep 2026 14:37:10 +0300 Subject: [PATCH] =?UTF-8?q?:lock:=20fix(tenancy):=20=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20=D0=BF=D0=BB=D0=B0=D1=82=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20=D1=80=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?,=20backend-app=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D1=8E=20app,=20=D0=BE=D1=80=D0=B3=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D0=B8=D0=B4?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=20=D1=81=D0=B2=D0=BE=D1=91=D0=BC=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Провижининг организаций через платформенный API работал только в тестах: роль chatballs_platform не имела прав на таблицы платформы, и уже проверка токена падала с permission denied. Тесты ходят в базу владельцем кластера и этого не видели — новый тест выполняет запрос под реальной runtime-ролью. Публичный процесс backend-app держал platform-соединение ради каталогов входа и мастера первого запуска. Теперь каталоги читаются ролью app (SELECT на security-barrier вьюхи), а первую организацию мастер создаёт под политикой «пока организаций нет» через SECURITY DEFINER-функцию; алиас platform поднимается только у платформенной поверхности и воркера. Роль app читала таблицу организаций целиком. Политика сужена до текущей организации: входы без контекста находят id через каталог организаций и читают строку уже внутри tenant_atomic (tenancy/lookup), воркер обходит организации по каталогу, мастер выделяет id из последовательности заранее. Должность владельца при провижининге берётся из словаря на языке организации. ADR-CHATBALLS-0048. Co-Authored-By: Claude Fable 5.1 --- apps/backend/chatballs/ai/attachment_views.py | 9 +- apps/backend/chatballs/calls/public_access.py | 17 ++- .../chatballs/conversations/consumers.py | 9 +- .../events/management/commands/run_worker.py | 4 +- apps/backend/chatballs/events/services.py | 7 +- apps/backend/chatballs/identity/avatars.py | 7 +- .../identity/management/commands/seed_demo.py | 9 +- apps/backend/chatballs/identity/setup.py | 48 +++++---- .../platform/provisioning_service.py | 10 +- .../platform/tests/test_platform_role.py | 100 ++++++++++++++++++ apps/backend/chatballs/platform/views.py | 10 +- .../chatballs/support_portals/file_views.py | 9 +- .../chatballs/support_portals/public_views.py | 7 +- apps/backend/chatballs/tenancy/ingress.py | 48 ++++++++- apps/backend/chatballs/tenancy/lookup.py | 74 +++++++++++++ .../commands/migrate_tenant_media.py | 11 +- apps/backend/chatballs/tenancy/middleware.py | 11 +- .../tenancy/migrations/0003_rls_policies.py | 3 + ...0031_platform_provisioning_table_grants.py | 53 ++++++++++ .../0032_app_role_ingress_and_bootstrap.py | 72 +++++++++++++ .../0033_organization_select_scope.py | 30 ++++++ apps/backend/chatballs/tenancy/routing.py | 12 ++- .../tenancy/test_bootstrap_policy.py | 53 ++++++++++ apps/backend/chatballs/tenancy/test_rls.py | 63 ++++++++++- apps/backend/chatballs/webchat/views.py | 12 +-- .../chatballs_backend/settings_database.py | 28 +++-- 26 files changed, 617 insertions(+), 99 deletions(-) create mode 100644 apps/backend/chatballs/platform/tests/test_platform_role.py create mode 100644 apps/backend/chatballs/tenancy/lookup.py create mode 100644 apps/backend/chatballs/tenancy/migrations/0031_platform_provisioning_table_grants.py create mode 100644 apps/backend/chatballs/tenancy/migrations/0032_app_role_ingress_and_bootstrap.py create mode 100644 apps/backend/chatballs/tenancy/migrations/0033_organization_select_scope.py create mode 100644 apps/backend/chatballs/tenancy/test_bootstrap_policy.py diff --git a/apps/backend/chatballs/ai/attachment_views.py b/apps/backend/chatballs/ai/attachment_views.py index a275b05..50c2e3a 100644 --- a/apps/backend/chatballs/ai/attachment_views.py +++ b/apps/backend/chatballs/ai/attachment_views.py @@ -4,10 +4,10 @@ from rest_framework.request import Request from rest_framework.views import APIView from chatballs.ai.models import KnowledgeAttachment -from chatballs.identity.models import Organization from chatballs.tenancy.context import TenantContext from chatballs.tenancy.database import tenant_atomic from chatballs.tenancy.ingress import attachment_route +from chatballs.tenancy.lookup import load_organization class AttachmentDownloadView(APIView): @@ -19,10 +19,9 @@ class AttachmentDownloadView(APIView): 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 + organization = load_organization(route.organization_id) + if organization is None: + raise Http404 context = TenantContext.for_resource(organization) with tenant_atomic(context): attachment = KnowledgeAttachment.objects.filter( diff --git a/apps/backend/chatballs/calls/public_access.py b/apps/backend/chatballs/calls/public_access.py index 1430650..0c7a674 100644 --- a/apps/backend/chatballs/calls/public_access.py +++ b/apps/backend/chatballs/calls/public_access.py @@ -27,10 +27,11 @@ from chatballs.calls.tokens import ( verify_call_access_token, ) from chatballs.i18n import t -from chatballs.identity.models import Organization, OrganizationMembership +from chatballs.identity.models import OrganizationMembership from chatballs.tenancy.context import TenantContext from chatballs.tenancy.database import tenant_atomic from chatballs.tenancy.ingress import call_invite_route, call_session_route +from chatballs.tenancy.lookup import load_organization @dataclass(frozen=True) @@ -45,10 +46,9 @@ def resolve_invite(*, token: str) -> ResolvedInvite: 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 + organization = load_organization(route.organization_id) + if organization is None: + raise CallTokenError(message) context = TenantContext.for_resource(organization) with tenant_atomic(context): invite = ( @@ -92,10 +92,9 @@ def _authorize_call_access( route = call_session_route(str(claims.call_session_id)) if route is None: raise CallTokenError(t("calls.token_invalid")) - try: - organization = Organization.objects.get(pk=route.organization_id) - except Organization.DoesNotExist: - raise CallTokenError(t("calls.token_invalid")) from None + organization = load_organization(route.organization_id) + if organization is None: + raise CallTokenError(t("calls.token_invalid")) resource_context = TenantContext.for_resource(organization) with tenant_atomic(resource_context): try: diff --git a/apps/backend/chatballs/conversations/consumers.py b/apps/backend/chatballs/conversations/consumers.py index f43b760..648a2cf 100644 --- a/apps/backend/chatballs/conversations/consumers.py +++ b/apps/backend/chatballs/conversations/consumers.py @@ -22,8 +22,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer from chatballs.conversations.models import Conversation from chatballs.conversations.realtime import conversation_group, inbox_group from chatballs.conversations.selectors import conversation_is_visible -from chatballs.identity.models import Organization, OrganizationMembership +from chatballs.identity.models import OrganizationMembership from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.lookup import organization_by_public_id logger = logging.getLogger(__name__) @@ -82,8 +83,10 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer): @database_sync_to_async def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None: try: - organization = Organization.objects.get(public_id=uuid.UUID(str(raw_public_id))) - except (ValueError, Organization.DoesNotExist): + organization = organization_by_public_id(uuid.UUID(str(raw_public_id))) + except ValueError: + return None + if organization is None: return None with tenant_atomic(organization.pk): membership = ( diff --git a/apps/backend/chatballs/events/management/commands/run_worker.py b/apps/backend/chatballs/events/management/commands/run_worker.py index 555255a..88bd4c3 100644 --- a/apps/backend/chatballs/events/management/commands/run_worker.py +++ b/apps/backend/chatballs/events/management/commands/run_worker.py @@ -11,10 +11,10 @@ from chatballs.conversations.poller import poll_all_messengers from chatballs.events.handlers import dispatch from chatballs.events.models import OutboxStatus from chatballs.events.services import claim_next_outbox_event, mark_retry -from chatballs.identity.models import Organization from chatballs.notifications.binding import poll_notifier_bots from chatballs.tenancy.context import TenantActorKind, TenantContext from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.lookup import iter_organizations logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ class Command(BaseCommand): @staticmethod def _tenant_contexts(): - for organization in Organization.objects.order_by("id").iterator(): + for organization in iter_organizations(): yield TenantContext.for_resource( organization, actor_kind=TenantActorKind.SYSTEM ) diff --git a/apps/backend/chatballs/events/services.py b/apps/backend/chatballs/events/services.py index 2398189..80ec17b 100644 --- a/apps/backend/chatballs/events/services.py +++ b/apps/backend/chatballs/events/services.py @@ -7,8 +7,9 @@ from django.utils import timezone from chatballs.events.context import get_correlation_id from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus -from chatballs.identity.models import Organization, OrganizationMembership +from chatballs.identity.models import OrganizationMembership from chatballs.tenancy.context import TenantActorKind, TenantContext +from chatballs.tenancy.lookup import load_organization @dataclass(frozen=True) @@ -43,7 +44,9 @@ def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None: return None if event.organization_id is None: raise ValueError("Tenant event has no organization") - organization = Organization.objects.get(pk=event.organization_id) + organization = load_organization(event.organization_id) + if organization is None: + raise ValueError("Tenant event organization does not exist") try: actor_kind = TenantActorKind(event.actor_kind) except ValueError as error: diff --git a/apps/backend/chatballs/identity/avatars.py b/apps/backend/chatballs/identity/avatars.py index 737a742..80b6efb 100644 --- a/apps/backend/chatballs/identity/avatars.py +++ b/apps/backend/chatballs/identity/avatars.py @@ -38,9 +38,12 @@ def user_avatar_url(user: HumanUser | None, organization_public_id) -> str | Non @functools.lru_cache(maxsize=4096) def organization_public_id(organization_id: int) -> str: """public_id организации по id — неизменяем, поэтому кэшируется.""" - from chatballs.identity.models import Organization + from chatballs.tenancy.ingress import organization_public_id_of - return str(Organization.objects.values_list("public_id", flat=True).get(pk=organization_id)) + public_id = organization_public_id_of(organization_id) + if public_id is None: + raise LookupError(f"Organization {organization_id} does not exist") + return public_id def user_avatar_url_in(user: HumanUser | None, organization_id: int) -> str | None: diff --git a/apps/backend/chatballs/identity/management/commands/seed_demo.py b/apps/backend/chatballs/identity/management/commands/seed_demo.py index e1d3e3a..607e51a 100644 --- a/apps/backend/chatballs/identity/management/commands/seed_demo.py +++ b/apps/backend/chatballs/identity/management/commands/seed_demo.py @@ -11,9 +11,9 @@ from django.core.management.base import BaseCommand, CommandError from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus from chatballs.identity.demo_seed import service -from chatballs.identity.models import Organization from chatballs.tenancy.context import TenantActorKind, TenantContext from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.lookup import organization_by_slug class Command(BaseCommand): @@ -26,10 +26,9 @@ class Command(BaseCommand): group.add_argument("--remove", action="store_true", help="Remove the installed demo dataset") def handle(self, *args: object, **options: object) -> None: - try: - organization = Organization.objects.get(slug=options["organization"]) - except Organization.DoesNotExist as error: - raise CommandError(f"Organization {options['organization']!r} not found") from error + organization = organization_by_slug(str(options["organization"])) + if organization is None: + raise CommandError(f"Organization {options['organization']!r} not found") context = TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM) if not options["apply"] and not options["remove"]: diff --git a/apps/backend/chatballs/identity/setup.py b/apps/backend/chatballs/identity/setup.py index 62a73e1..34fd52d 100644 --- a/apps/backend/chatballs/identity/setup.py +++ b/apps/backend/chatballs/identity/setup.py @@ -5,16 +5,16 @@ Никаких параметров в .env и CLI: всё задаёт человек в браузере. После создания владельца мастер закрывается навсегда (409). -Запись идёт на соединении ``platform`` — единственной runtime-роли с правом -создавать организации (SPEC-HUB-0021 §10); RLS-контекст и транзакция живут -на том же соединении. +Запись идёт по основному соединению процесса. Роль app вправе вставить +организацию только пока их нет (политика tenancy/0032); дальше создавать +организации может только роль platform (SPEC-HUB-0021 §10). Так пароль +platform-роли не нужен процессу backend-app. """ from __future__ import annotations from dataclasses import dataclass -from django.conf import settings from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError from django.core.validators import validate_email @@ -35,14 +35,15 @@ from chatballs.identity.models import ( ) from chatballs.tenancy.context import TenantActorKind, TenantContext from chatballs.tenancy.database import tenant_atomic -from chatballs.tenancy.routing import use_database +from chatballs.tenancy.ingress import organization_route_by_slug +from chatballs.tenancy.lookup import instance_has_organizations, reserve_organization_id ORGANIZATION_NAME_MAX_LENGTH = 255 FULL_NAME_MAX_LENGTH = 255 -# Алиас соединения для операций уровня инстанса. В тестах оба алиаса — -# зеркала одной тестовой БД под ролью-владельцем кластера. -INSTANCE_DB_ALIAS = "default" if settings.TESTING else "platform" +# Мастер работает по основному соединению процесса: отдельный алиас с ролью +# platform ему больше не нужен. +INSTANCE_DB_ALIAS = "default" class SetupAlreadyCompleted(Exception): @@ -70,11 +71,10 @@ class SetupResult: def instance_needs_setup() -> bool: """Мастер нужен, пока не создана ни одна организация. - Организации видны роли app целиком (RLS SELECT USING true), поэтому - проверка не требует tenant-контекста. + Строки организаций роли app без контекста не видны (tenancy/0033): + наличие хотя бы одной проверяет SECURITY DEFINER-функция. """ - with use_database(INSTANCE_DB_ALIAS): - return not Organization.objects.exists() + return not instance_has_organizations() def _clean(data: SetupInput) -> SetupInput: @@ -110,7 +110,7 @@ def _unique_slug(name: str) -> str: base = slugify(name)[:40].strip("-") or "organization" candidate = base suffix = 2 - while Organization.objects.filter(slug=candidate).exists(): + while organization_route_by_slug(candidate) is not None: candidate = f"{base}-{suffix}" suffix += 1 return candidate @@ -138,7 +138,7 @@ def _complete_setup( data: SetupInput, language: str, public_host: str, public_scheme: str ) -> SetupResult: clean = _clean(data) - with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS): + with transaction.atomic(using=INSTANCE_DB_ALIAS): # Адрес, на котором человек прошёл мастер, и есть публичный адрес # установки: другого источника у коробки нет. if public_host: @@ -148,7 +148,7 @@ def _complete_setup( # увидит созданную организацию. with connections[INSTANCE_DB_ALIAS].cursor() as cursor: cursor.execute("SELECT pg_advisory_xact_lock(hashtext('chatballs.instance_setup'))") - if Organization.objects.exists(): + if instance_has_organizations(using=INSTANCE_DB_ALIAS): raise SetupAlreadyCompleted() # Пароль проверяется против атрибутов будущего пользователя (схожесть @@ -156,12 +156,17 @@ def _complete_setup( probe = HumanUser(email=clean.email, full_name=clean.full_name) validate_password(clean.password, user=probe) - organization = Organization.objects.create( - name=clean.organization_name, - slug=_unique_slug(clean.organization_name), - status=OrganizationStatus.ACTIVE, - ) - with tenant_atomic(organization.id, using=INSTANCE_DB_ALIAS): + # id выделяется заранее: строка организации видна роли app только в + # её контексте, и вставка идёт уже внутри него (tenancy/0033). + organization_id = reserve_organization_id(using=INSTANCE_DB_ALIAS) + with tenant_atomic(organization_id, using=INSTANCE_DB_ALIAS): + organization = Organization( + id=organization_id, + name=clean.organization_name, + slug=_unique_slug(clean.organization_name), + status=OrganizationStatus.ACTIVE, + ) + organization.save(force_insert=True) ensure_uncategorized_category(organization) owner = HumanUser.objects.create_user( email=clean.email, @@ -169,6 +174,7 @@ def _complete_setup( full_name=clean.full_name, is_staff=True, is_superuser=True, + is_instance_admin=True, ) OrganizationMembership.objects.create( user=owner, diff --git a/apps/backend/chatballs/platform/provisioning_service.py b/apps/backend/chatballs/platform/provisioning_service.py index 9f14186..c8f7cf6 100644 --- a/apps/backend/chatballs/platform/provisioning_service.py +++ b/apps/backend/chatballs/platform/provisioning_service.py @@ -8,6 +8,8 @@ from django.utils import timezone from chatballs.ai.knowledge_categories import ensure_uncategorized_category from chatballs.events.services import DomainEvent, enqueue_event +from chatballs.i18n import t +from chatballs.i18n.audience import customer_language from chatballs.identity.audit import record_audit_event from chatballs.identity.invitation_service import issue_invitation from chatballs.identity.models import ( @@ -173,7 +175,7 @@ def _provision_active_owner( user=owner_user, organization=org, role=EmployeeRole.OWNER, - position_title=_owner_position_title(), + position_title=_owner_position_title(org), ) record_audit_event( action="organization.provisioned", @@ -228,8 +230,10 @@ def _provision_pending_owner( ) -def _owner_position_title() -> str: - return "Владелец" +def _owner_position_title(org: Organization) -> str: + # Должность хранится текстом, поэтому пишется сразу на языке организации, + # а не оператора платформы: переводить её потом будет нечем. + return t("setup.owner_position", language=customer_language(org)) def _safe_message(error: Exception) -> str: diff --git a/apps/backend/chatballs/platform/tests/test_platform_role.py b/apps/backend/chatballs/platform/tests/test_platform_role.py new file mode 100644 index 0000000..b6e6005 --- /dev/null +++ b/apps/backend/chatballs/platform/tests/test_platform_role.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from django.db import connection +from django.test import TransactionTestCase, override_settings +from rest_framework.test import APIClient + +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + OrganizationInvitation, + OrganizationMembership, +) +from chatballs.platform.models import OrganizationProvisioning, ProvisioningStatus +from chatballs.platform.testing import create_platform_operator + + +@override_settings(ROOT_URLCONF="chatballs_backend.urls_platform") +class PlatformRoleProvisioningTests(TransactionTestCase): + """Провижининг под реальной runtime-ролью platform (tenancy/0031). + + Остальные тесты ходят в базу владельцем кластера и не заметили бы + отсутствующий GRANT: в деплое backend-platform работает ролью + chatballs_platform, и без прав на платформенные таблицы уже проверка + токена падала с «permission denied». Здесь весь HTTP-запрос выполняется + под этой ролью — от чтения токена до записи приглашения владельца. + """ + + def setUp(self) -> None: + self.operator, self.token = create_platform_operator() + + def _post(self, token: str, *, slug: str, owner_email: str, key: str): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Token {token}") + with connection.cursor() as cursor: + cursor.execute("SET ROLE chatballs_runtime_platform") + try: + return client.post( + "/api/v1/organizations", + data={ + "name": "Role Co", + "slug": slug, + "owner_email": owner_email, + "timezone": "Europe/Moscow", + "currency": "RUB", + }, + format="json", + HTTP_IDEMPOTENCY_KEY=key, + ) + finally: + with connection.cursor() as cursor: + cursor.execute("RESET ROLE") + + def test_unknown_token_is_rejected_not_crashed(self) -> None: + response = self._post( + "ctp_not_a_real_token", slug="role-co", owner_email="x@example.test", key="k0" + ) + self.assertEqual(response.status_code, 401, response.content) + + def test_active_owner_is_provisioned_under_platform_role(self) -> None: + HumanUser.objects.create_user(email="role-owner@example.test") + response = self._post( + self.token, slug="role-co", owner_email="role-owner@example.test", key="k1" + ) + self.assertEqual(response.status_code, 201, response.content) + payload = response.json() + self.assertEqual(payload["owner"]["state"], "active") + self.assertEqual(payload["provisioning"]["status"], ProvisioningStatus.COMPLETED) + self.assertTrue( + OrganizationMembership.objects.filter( + organization__slug="role-co", role=EmployeeRole.OWNER + ).exists() + ) + record = OrganizationProvisioning.objects.get(idempotency_key="k1") + self.assertEqual(record.status, ProvisioningStatus.COMPLETED) + + def test_pending_owner_gets_invitation_under_platform_role(self) -> None: + response = self._post( + self.token, slug="role-co", owner_email="new-owner@example.test", key="k2" + ) + self.assertEqual(response.status_code, 201, response.content) + self.assertEqual(response.json()["owner"]["state"], "pending_invitation") + self.assertTrue( + OrganizationInvitation.objects.filter( + organization__slug="role-co", + email="new-owner@example.test", + role=EmployeeRole.OWNER, + ).exists() + ) + + def test_replay_is_idempotent_under_platform_role(self) -> None: + HumanUser.objects.create_user(email="role-owner@example.test") + first = self._post( + self.token, slug="role-co", owner_email="role-owner@example.test", key="k3" + ) + second = self._post( + self.token, slug="role-co", owner_email="role-owner@example.test", key="k3" + ) + self.assertEqual(first.status_code, 201, first.content) + self.assertEqual(second.status_code, 200, second.content) + self.assertEqual(OrganizationProvisioning.objects.filter(idempotency_key="k3").count(), 1) diff --git a/apps/backend/chatballs/platform/views.py b/apps/backend/chatballs/platform/views.py index 0bd5adf..cefb6a2 100644 --- a/apps/backend/chatballs/platform/views.py +++ b/apps/backend/chatballs/platform/views.py @@ -13,6 +13,7 @@ from chatballs.platform.payloads import owner_state_for, provisioning_result_pay from chatballs.platform.permissions import HasPlatformCapability from chatballs.platform.provisioning_service import provision_organization from chatballs.platform.validation import parse_provisioning_body +from chatballs.tenancy.database import tenant_atomic _IDEMPOTENCY_HEADER = "Idempotency-Key" @@ -44,9 +45,12 @@ class OrganizationProvisionView(APIView): result = provision_organization(command=command, operator=request.user) except ProvisioningError as error: return Response({"detail": str(error)}, status=error.status_code) - owner_membership = OrganizationMembership.objects.filter( - organization=result.organization, role=EmployeeRole.OWNER - ).first() + # Членство — тенантная строка: под ролью platform без tenant-контекста + # RLS её не покажет, и ответ назвал бы активного владельца ожидающим. + with tenant_atomic(result.organization.pk): + owner_membership = OrganizationMembership.objects.filter( + organization=result.organization, role=EmployeeRole.OWNER + ).first() payload = provisioning_result_payload( provisioning=result.provisioning, organization=result.organization, diff --git a/apps/backend/chatballs/support_portals/file_views.py b/apps/backend/chatballs/support_portals/file_views.py index 8be4f31..b495ed0 100644 --- a/apps/backend/chatballs/support_portals/file_views.py +++ b/apps/backend/chatballs/support_portals/file_views.py @@ -3,12 +3,12 @@ from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.views import APIView -from chatballs.identity.models import Organization from chatballs.support_portals.content_services import INLINE_CONTENT_TYPES from chatballs.support_portals.models import PortalArticleFile from chatballs.tenancy.context import TenantContext from chatballs.tenancy.database import tenant_atomic from chatballs.tenancy.ingress import portal_article_file_route +from chatballs.tenancy.lookup import load_organization class PortalArticleFileView(APIView): @@ -26,10 +26,9 @@ class PortalArticleFileView(APIView): route = portal_article_file_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 + organization = load_organization(route.organization_id) + if organization is None: + raise Http404 context = TenantContext.for_resource(organization) with tenant_atomic(context): article_file = PortalArticleFile.objects.filter( diff --git a/apps/backend/chatballs/support_portals/public_views.py b/apps/backend/chatballs/support_portals/public_views.py index d1c740c..59354a5 100644 --- a/apps/backend/chatballs/support_portals/public_views.py +++ b/apps/backend/chatballs/support_portals/public_views.py @@ -5,7 +5,6 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from chatballs.i18n import t -from chatballs.identity.models import Organization from chatballs.support_portals.content_services import record_feedback from chatballs.support_portals.models import SupportPortal from chatballs.support_portals.selectors import category_article_counts, public_articles @@ -17,6 +16,7 @@ from chatballs.support_portals.serializers import ( from chatballs.tenancy.context import TenantContext from chatballs.tenancy.database import tenant_atomic from chatballs.tenancy.ingress import support_portal_route +from chatballs.tenancy.lookup import load_organization class PublicPortalView(APIView): @@ -28,9 +28,8 @@ class PublicPortalView(APIView): route = support_portal_route(hostname) if route is None: return None - try: - organization = Organization.objects.get(id=route.organization_id) - except Organization.DoesNotExist: + organization = load_organization(route.organization_id) + if organization is None: return None context = TenantContext.for_resource(organization) with tenant_atomic(context): diff --git a/apps/backend/chatballs/tenancy/ingress.py b/apps/backend/chatballs/tenancy/ingress.py index 88aa52c..88eb24e 100644 --- a/apps/backend/chatballs/tenancy/ingress.py +++ b/apps/backend/chatballs/tenancy/ingress.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from django.conf import settings from django.db import connections @@ -14,8 +13,9 @@ class IngressRoute: def _rows(query: str, parameters: list[Any]) -> list[tuple]: - alias = "default" if settings.TESTING else "platform" - with connections[alias].cursor() as cursor: + # Каталоги — security-barrier вьюхи, на них есть SELECT у роли app + # (tenancy/0032): чтение идёт по основному соединению процесса. + with connections["default"].cursor() as cursor: cursor.execute(query, parameters) return list(cursor.fetchall()) @@ -84,3 +84,45 @@ def support_portal_route(hostname: str) -> IngressRoute | None: return _unique_route("support_portal_directory", hostname.strip().lower().rstrip(".")) +# Каталог организаций: id по публичному id или слагу, публичный id по id и +# список всех id. Роль app видит строку организации только в её контексте +# (tenancy/0033), а сюда приходят до того, как контекст открыт. +def organization_route_by_public_id(public_id: str) -> IngressRoute | None: + rows = _rows( + "SELECT organization_id, public_id FROM chatballs.organization_directory " + "WHERE public_id = %s::uuid", + [public_id], + ) + if len(rows) != 1: + return None + return IngressRoute(organization_id=int(rows[0][0]), resource_id=str(rows[0][1])) + + +def organization_route_by_slug(slug: str) -> IngressRoute | None: + rows = _rows( + "SELECT organization_id, slug FROM chatballs.organization_directory WHERE slug = %s", + [slug], + ) + if len(rows) != 1: + return None + return IngressRoute(organization_id=int(rows[0][0]), resource_id=str(rows[0][1])) + + +def organization_public_id_of(organization_id: int) -> str | None: + rows = _rows( + "SELECT public_id FROM chatballs.organization_directory WHERE organization_id = %s", + [int(organization_id)], + ) + return str(rows[0][0]) if rows else None + + +def organization_ids() -> list[int]: + return [ + int(row[0]) + for row in _rows( + "SELECT organization_id FROM chatballs.organization_directory ORDER BY organization_id", + [], + ) + ] + + diff --git a/apps/backend/chatballs/tenancy/lookup.py b/apps/backend/chatballs/tenancy/lookup.py new file mode 100644 index 0000000..22be852 --- /dev/null +++ b/apps/backend/chatballs/tenancy/lookup.py @@ -0,0 +1,74 @@ +"""Поиск организации там, где tenant-контекста ещё нет. + +Роль app видит строку организации только в контексте этой организации +(tenancy/0033). Входы, которые начинаются с внешнего ключа — адрес, публичный +id, слаг, id из outbox-события, — сначала находят id через security-barrier +каталог ``chatballs.organization_directory`` и лишь затем открывают контекст и +читают строку. Так процесс приложения не перечисляет чужие организации. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +from django.db import DEFAULT_DB_ALIAS, connections + +from chatballs.identity.models import Organization +from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.ingress import ( + organization_ids, + organization_route_by_public_id, + organization_route_by_slug, +) + + +def load_organization(organization_id: int) -> Organization | None: + """Строка организации по id: читается в её собственном контексте.""" + + with tenant_atomic(int(organization_id)): + return Organization.objects.filter(pk=organization_id).first() + + +def organization_by_public_id(public_id: object) -> Organization | None: + route = organization_route_by_public_id(str(public_id)) + return load_organization(route.organization_id) if route is not None else None + + +def organization_by_slug(slug: str) -> Organization | None: + route = organization_route_by_slug(slug) + return load_organization(route.organization_id) if route is not None else None + + +def iter_organizations() -> Iterator[Organization]: + """Все организации установки по одной, каждая в своём контексте (воркер).""" + + for organization_id in organization_ids(): + organization = load_organization(organization_id) + if organization is not None: + yield organization + + +def reserve_organization_id(*, using: str = DEFAULT_DB_ALIAS) -> int: + """Выделить id для будущей организации до INSERT. + + Роль app видит строку организации только в её контексте, а INSERT с + RETURNING обязан вернуть видимую строку. Поэтому мастер первого запуска + берёт id из последовательности заранее, открывает контекст этого id и уже + в нём вставляет строку. + """ + + with connections[using].cursor() as cursor: + cursor.execute("SELECT nextval('identity_organization_id_seq')") + return int(cursor.fetchone()[0]) + + +def instance_has_organizations(*, using: str = DEFAULT_DB_ALIAS) -> bool: + """Есть ли в установке хоть одна организация — без tenant-контекста. + + Проверку делает SECURITY DEFINER-функция (tenancy/0032): та же, что держит + политику «первая организация» мастера первого запуска. + """ + + with connections[using].cursor() as cursor: + cursor.execute("SELECT chatballs.instance_has_organizations()") + return bool(cursor.fetchone()[0]) diff --git a/apps/backend/chatballs/tenancy/management/commands/migrate_tenant_media.py b/apps/backend/chatballs/tenancy/management/commands/migrate_tenant_media.py index f1f8aa4..b93ae7c 100644 --- a/apps/backend/chatballs/tenancy/management/commands/migrate_tenant_media.py +++ b/apps/backend/chatballs/tenancy/management/commands/migrate_tenant_media.py @@ -1,15 +1,16 @@ from __future__ import annotations import json +import uuid from datetime import UTC, datetime from pathlib import Path from django.core.management.base import BaseCommand, CommandError from chatballs.ai.models import KnowledgeAttachment -from chatballs.identity.models import Organization from chatballs.tenancy.context import TenantContext from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.lookup import organization_by_public_id from chatballs.tenancy.media_migration import ( copy_attachment, reconcile_attachment_storage_usage, @@ -34,11 +35,11 @@ class Command(BaseCommand): def handle(self, *args, **options) -> None: try: - organization = Organization.objects.get( - public_id=options["organization_public_id"] - ) - except (Organization.DoesNotExist, ValueError) as error: + organization = organization_by_public_id(uuid.UUID(str(options["organization_public_id"]))) + except ValueError as error: raise CommandError("Organization not found") from error + if organization is None: + raise CommandError("Organization not found") source_root = Path(options["source_root"]) manifest_path = Path(options["manifest"]) diff --git a/apps/backend/chatballs/tenancy/middleware.py b/apps/backend/chatballs/tenancy/middleware.py index 535714d..0c94f9d 100644 --- a/apps/backend/chatballs/tenancy/middleware.py +++ b/apps/backend/chatballs/tenancy/middleware.py @@ -8,9 +8,10 @@ from django.http import Http404, HttpRequest, HttpResponse from django.urls import Resolver404, resolve from chatballs.events.context import get_correlation_id -from chatballs.identity.models import Organization, OrganizationMembership +from chatballs.identity.models import OrganizationMembership from chatballs.tenancy.context import TenantContext from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.lookup import organization_by_public_id class TenantContextMiddleware: @@ -47,9 +48,13 @@ class TenantContextMiddleware: 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: + except ValueError as error: raise Http404 from error + # Строка организации видна роли app только в её контексте: id берётся + # из каталога, а сама строка читается уже внутри tenant_atomic. + organization = organization_by_public_id(public_id) + if organization is None: + raise Http404 with tenant_atomic(organization.pk): try: diff --git a/apps/backend/chatballs/tenancy/migrations/0003_rls_policies.py b/apps/backend/chatballs/tenancy/migrations/0003_rls_policies.py index 8facdc4..6ec5dda 100644 --- a/apps/backend/chatballs/tenancy/migrations/0003_rls_policies.py +++ b/apps/backend/chatballs/tenancy/migrations/0003_rls_policies.py @@ -1,6 +1,9 @@ from django.db import migrations +# Исторический список на момент миграции: identity_department и три таблицы +# access-профилей позже удалены (identity/0020), ai_knowledgedepartment — в +# ai/0014. Актуальное покрытие RLS — сумма всех миграций tenancy, не эта константа. TENANT_TABLES = ( "identity_department", "identity_employeeprofile", diff --git a/apps/backend/chatballs/tenancy/migrations/0031_platform_provisioning_table_grants.py b/apps/backend/chatballs/tenancy/migrations/0031_platform_provisioning_table_grants.py new file mode 100644 index 0000000..a28c697 --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0031_platform_provisioning_table_grants.py @@ -0,0 +1,53 @@ +# Таблицы платформенного приложения (operator, token, provisioning) появились +# после tenancy/0003, и ни одна миграция не выдала на них прав runtime-роли +# platform. В деплое backend-platform работает ролью chatballs_platform, поэтому +# уже проверка токена падала «permission denied for table platform_platformtoken», +# а вместе с ней — весь POST /api/v1/organizations (SPEC-HUB-0021 §12). Тесты +# этого не видели: они ходят в базу владельцем кластера. +# +# Здесь роль platform получает DML на платформенные таблицы (без DELETE: токены +# отзываются, записи провижининга хранятся) и на приглашения — их выписывает тот +# же провижининг для будущего владельца внутри set_local_tenant(new_org_id), под +# той же tenant-политикой, что в 0005 для членств. +from django.db import migrations + +PLATFORM_POLICY = "chatballs_platform_tenant_provisioning" + +GRANTS = f""" +GRANT SELECT, INSERT, UPDATE ON platform_platformoperator, platform_platformtoken, + platform_organizationprovisioning TO chatballs_runtime_platform; +GRANT USAGE, SELECT ON SEQUENCE platform_platformoperator_id_seq, + platform_platformtoken_id_seq, platform_organizationprovisioning_id_seq + TO chatballs_runtime_platform; + +GRANT SELECT, INSERT, UPDATE ON identity_organizationinvitation + TO chatballs_runtime_platform; +GRANT USAGE, SELECT ON SEQUENCE identity_organizationinvitation_id_seq + TO chatballs_runtime_platform; +DROP POLICY IF EXISTS {PLATFORM_POLICY} ON identity_organizationinvitation; +CREATE POLICY {PLATFORM_POLICY} ON identity_organizationinvitation + FOR ALL TO chatballs_runtime_platform + USING (organization_id = chatballs.current_organization_id()) + WITH CHECK (organization_id = chatballs.current_organization_id()); +""" + +REVOKE = f""" +DROP POLICY IF EXISTS {PLATFORM_POLICY} ON identity_organizationinvitation; +REVOKE ALL ON identity_organizationinvitation FROM chatballs_runtime_platform; +REVOKE ALL ON SEQUENCE identity_organizationinvitation_id_seq + FROM chatballs_runtime_platform; +REVOKE ALL ON platform_platformoperator, platform_platformtoken, + platform_organizationprovisioning FROM chatballs_runtime_platform; +REVOKE ALL ON SEQUENCE platform_platformoperator_id_seq, + platform_platformtoken_id_seq, platform_organizationprovisioning_id_seq + FROM chatballs_runtime_platform; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0030_encrypted_column_widths"), + ("platform", "0001_initial"), + ] + + operations = [migrations.RunSQL(GRANTS, REVOKE)] diff --git a/apps/backend/chatballs/tenancy/migrations/0032_app_role_ingress_and_bootstrap.py b/apps/backend/chatballs/tenancy/migrations/0032_app_role_ingress_and_bootstrap.py new file mode 100644 index 0000000..7c5522f --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0032_app_role_ingress_and_bootstrap.py @@ -0,0 +1,72 @@ +# Процесс backend-app держал platform-соединение ради двух вещей: чтения +# каталогов входа (виджет, портал, приглашение в звонок) и мастера первого +# запуска. Из-за этого пароль роли platform лежал в каждом процессе стека. +# +# Каталоги — security-barrier вьюхи над тенантными таблицами: они и задуманы как +# безопасный вход без tenant-контекста, роль app получает на них SELECT. +# +# Мастер первого запуска создаёт первую организацию. Право INSERT на +# identity_organization у роли app появляется под политикой «пока организаций +# нет»: проверку делает SECURITY DEFINER-функция от chatballs_schema (иначе +# политика ссылалась бы на свою же таблицу и Postgres отказал бы за рекурсию). +# После первой организации INSERT для app закрыт навсегда — создавать следующие +# по-прежнему может только роль platform (SPEC-HUB-0021 §10). +from django.db import migrations + +INGRESS_VIEWS = ( + "attachment_directory", + "call_invite_directory", + "call_session_directory", + "membership_directory", + "organization_directory", + "portal_article_file_directory", + "support_portal_directory", + "web_channel_directory", + "web_session_directory", + "web_widget_directory", +) + +BOOTSTRAP_POLICY = "chatballs_organization_app_bootstrap" + +BOOTSTRAP_SQL = f""" +CREATE OR REPLACE FUNCTION chatballs.instance_has_organizations() RETURNS boolean +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ + SELECT EXISTS (SELECT 1 FROM identity_organization) +$$; +ALTER FUNCTION chatballs.instance_has_organizations() OWNER TO chatballs_schema; +REVOKE ALL ON FUNCTION chatballs.instance_has_organizations() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION chatballs.instance_has_organizations() + TO chatballs_runtime_app, chatballs_schema; + +GRANT INSERT ON identity_organization TO chatballs_runtime_app; +DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization; +CREATE POLICY {BOOTSTRAP_POLICY} ON identity_organization + FOR INSERT TO chatballs_runtime_app + WITH CHECK (NOT chatballs.instance_has_organizations()); +""" + +BOOTSTRAP_REVERSE_SQL = f""" +DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization; +REVOKE INSERT ON identity_organization FROM chatballs_runtime_app; +DROP FUNCTION IF EXISTS chatballs.instance_has_organizations(); +""" + + +def grant_ingress(apps, schema_editor): + for view in INGRESS_VIEWS: + schema_editor.execute(f"GRANT SELECT ON chatballs.{view} TO chatballs_runtime_app") + schema_editor.execute(BOOTSTRAP_SQL) + + +def revoke_ingress(apps, schema_editor): + schema_editor.execute(BOOTSTRAP_REVERSE_SQL) + for view in INGRESS_VIEWS: + schema_editor.execute(f"REVOKE SELECT ON chatballs.{view} FROM chatballs_runtime_app") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0031_platform_provisioning_table_grants"), + ] + + operations = [migrations.RunPython(grant_ingress, revoke_ingress)] diff --git a/apps/backend/chatballs/tenancy/migrations/0033_organization_select_scope.py b/apps/backend/chatballs/tenancy/migrations/0033_organization_select_scope.py new file mode 100644 index 0000000..bc63a04 --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0033_organization_select_scope.py @@ -0,0 +1,30 @@ +# Роль app читала таблицу организаций целиком (0012: SELECT USING (true)) — +# любой процесс приложения без tenant-контекста мог перечислить чужие +# организации с именами, слагами и часовыми поясами. Теперь строка видна +# только в контексте своей организации. Входы без контекста (адрес, публичный +# id, слаг, id из outbox) идут через security-barrier каталог +# chatballs.organization_directory (tenancy/lookup), а мастер первого запуска +# спрашивает о наличии организаций SECURITY DEFINER-функцию (0032). +from django.db import migrations + +SCOPED = """ +DROP POLICY IF EXISTS chatballs_organization_app_select ON identity_organization; +CREATE POLICY chatballs_organization_app_select ON identity_organization + FOR SELECT TO chatballs_runtime_app + USING (id = chatballs.current_organization_id()); +""" + +UNSCOPED = """ +DROP POLICY IF EXISTS chatballs_organization_app_select ON identity_organization; +CREATE POLICY chatballs_organization_app_select ON identity_organization + FOR SELECT TO chatballs_runtime_app + USING (true); +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0032_app_role_ingress_and_bootstrap"), + ] + + operations = [migrations.RunSQL(SCOPED, UNSCOPED)] diff --git a/apps/backend/chatballs/tenancy/routing.py b/apps/backend/chatballs/tenancy/routing.py index 024d723..be23d42 100644 --- a/apps/backend/chatballs/tenancy/routing.py +++ b/apps/backend/chatballs/tenancy/routing.py @@ -1,10 +1,12 @@ """Принудительный выбор соединения БД для блока кода. -Runtime backend-app держит два соединения: ``default`` (роль app) и -``platform`` (роль platform, права на создание организаций — SPEC-HUB-0021). -Обычные запросы идут в ``default``; операции уровня инстанса (мастер первого -запуска) выполняются целиком на ``platform`` через ``use_database("platform")``, -чтобы транзакция и RLS-контекст жили на одном соединении. +Алиас ``platform`` (роль platform, права на создание организаций — +SPEC-HUB-0021) есть только у процессов, которым он нужен: у платформенной +поверхности и у воркера, захватывающего outbox всех организаций +(см. ``settings_database.build_databases``). У backend-app его нет: каталоги +входа и мастер первого запуска работают ролью app (tenancy/0032). +``use_database`` переводит блок кода целиком на выбранный алиас, чтобы +транзакция и RLS-контекст жили на одном соединении. """ from __future__ import annotations diff --git a/apps/backend/chatballs/tenancy/test_bootstrap_policy.py b/apps/backend/chatballs/tenancy/test_bootstrap_policy.py new file mode 100644 index 0000000..0cfffe6 --- /dev/null +++ b/apps/backend/chatballs/tenancy/test_bootstrap_policy.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from django.db import DatabaseError, connection, transaction +from django.test import TransactionTestCase + +from chatballs.identity.models import EmployeeRole, Organization, OrganizationMembership +from chatballs.identity.setup import SetupInput, complete_setup +from chatballs.tenancy.ingress import organization_ids +from chatballs.tenancy.lookup import instance_has_organizations + + +class AppRoleBootstrapPolicyTests(TransactionTestCase): + """Мастер первого запуска работает ролью app (tenancy/0032). + + Пока организаций нет, роль app вправе создать первую; сразу после этого + INSERT для неё закрыт, и следующие организации создаёт только роль + platform. Проверяется под реальной runtime-ролью, а не владельцем кластера. + """ + + def _set_role(self, role: str) -> None: + with connection.cursor() as cursor: + cursor.execute(f"SET ROLE {role}") + + def _reset_role(self) -> None: + with connection.cursor() as cursor: + cursor.execute("RESET ROLE") + + def test_setup_creates_first_organization_under_app_role_only_once(self) -> None: + self._set_role("chatballs_runtime_app") + try: + result = complete_setup( + SetupInput( + organization_name="Bootstrap Co", + full_name="First Owner", + email="first-owner@example.test", + password="Long-and-strong-passphrase-42", + ) + ) + # Без контекста строки организаций роли app не видны (tenancy/0033): + # факт создания проверяется через каталог. + self.assertTrue(instance_has_organizations()) + self.assertEqual(len(organization_ids()), 1) + with transaction.atomic(), self.assertRaises(DatabaseError): + Organization.objects.create(name="Second", slug="second-co") + finally: + self._reset_role() + self.assertTrue( + OrganizationMembership.objects.filter( + organization=result.organization, + user=result.owner, + role=EmployeeRole.OWNER, + ).exists() + ) diff --git a/apps/backend/chatballs/tenancy/test_rls.py b/apps/backend/chatballs/tenancy/test_rls.py index bd74134..e561131 100644 --- a/apps/backend/chatballs/tenancy/test_rls.py +++ b/apps/backend/chatballs/tenancy/test_rls.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import DatabaseError, connection, transaction from django.test import TransactionTestCase @@ -162,7 +163,9 @@ class RowLevelSecurityTests(TransactionTestCase): - with self.assertRaises(DatabaseError), transaction.atomic(): + # Чужая организация в контексте первой не видна вовсе (tenancy/0033): + # проверка внешнего ключа в full_clean отказывает ещё до INSERT. + with self.assertRaises((DatabaseError, ValidationError)), transaction.atomic(): self._set_role("chatballs_runtime_app") @@ -426,6 +429,23 @@ class RowLevelSecurityTests(TransactionTestCase): + def test_app_role_reads_ingress_directory_and_cannot_add_organizations(self) -> None: + # Каталоги входа доступны роли app (tenancy/0032): backend-app + # обходится без platform-соединения. + with transaction.atomic(): + self._set_role("chatballs_runtime_app") + with connection.cursor() as cursor: + cursor.execute( + "SELECT organization_id FROM chatballs.membership_directory " + "WHERE user_id = %s", + [self.user.id], + ) + self.assertEqual(cursor.fetchone()[0], self.first.id) + # Организации уже есть — INSERT для app закрыт политикой bootstrap. + with self.assertRaises(DatabaseError), transaction.atomic(): + self._set_role("chatballs_runtime_app") + Organization.objects.create(name="Third", slug="rls-third") + def test_platform_role_can_only_use_ingress_directory(self) -> None: with self.assertRaises(DatabaseError), transaction.atomic(): @@ -464,6 +484,47 @@ class RowLevelSecurityTests(TransactionTestCase): + def test_app_role_sees_organizations_only_in_their_context(self) -> None: + # tenancy/0033: без контекста строк организаций нет, в контексте — своя. + with transaction.atomic(): + self._set_role("chatballs_runtime_app") + self.assertEqual(Organization.objects.count(), 0) + set_local_tenant(self.first.id) + self.assertEqual( + list(Organization.objects.values_list("id", flat=True)), [self.first.id] + ) + + def test_app_role_finds_organizations_through_the_directory(self) -> None: + from chatballs.tenancy.lookup import ( + instance_has_organizations, + iter_organizations, + organization_by_public_id, + ) + + with connection.cursor() as cursor: + cursor.execute("SET ROLE chatballs_runtime_app") + try: + self.assertTrue(instance_has_organizations()) + self.assertEqual( + [organization.id for organization in iter_organizations()], + [self.first.id, self.second.id], + ) + found = organization_by_public_id(self.second.public_id) + self.assertIsNotNone(found) + self.assertEqual(found.id, self.second.id) + # Сессия собирает членства по каталогу и читает каждую организацию + # в её контексте. + client = TenantAPIClient() + client.force_authenticate(self.user) + session = client.get("/api/v1/auth/session/").json() + self.assertEqual( + [item["organizationPublicId"] for item in session["user"]["memberships"]], + [str(self.first.public_id)], + ) + finally: + with connection.cursor() as cursor: + cursor.execute("RESET ROLE") + def test_transaction_local_context_clears_after_commit_and_rollback(self) -> None: with transaction.atomic(): diff --git a/apps/backend/chatballs/webchat/views.py b/apps/backend/chatballs/webchat/views.py index 8299dff..ee619b0 100644 --- a/apps/backend/chatballs/webchat/views.py +++ b/apps/backend/chatballs/webchat/views.py @@ -15,7 +15,6 @@ from chatballs.conversations.attachment_views import ( from chatballs.conversations.models import Message, MessageKind from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES from chatballs.i18n import t -from chatballs.identity.models import Organization from chatballs.integrations.features import voice_messages_allowed from chatballs.integrations.models import IntegrationStatus from chatballs.tenancy.context import TenantContext @@ -25,6 +24,7 @@ from chatballs.tenancy.ingress import ( web_session_route, web_widget_route, ) +from chatballs.tenancy.lookup import load_organization from chatballs.webchat import services from chatballs.webchat.api_inputs import host_origin, session_token from chatballs.webchat.loader import LOADER_JS @@ -58,9 +58,8 @@ def _resolved_web_widget(widget_key: str, channel_code: str = ""): if route is None: yield None, None return - try: - organization = Organization.objects.get(pk=route.organization_id) - except Organization.DoesNotExist: + organization = load_organization(route.organization_id) + if organization is None: yield None, None return context = TenantContext.for_resource(organization) @@ -96,9 +95,8 @@ def _resolved_web_session(request: Request): if route is None: yield None, None return - try: - organization = Organization.objects.get(pk=route.organization_id) - except Organization.DoesNotExist: + organization = load_organization(route.organization_id) + if organization is None: yield None, None return context = TenantContext.for_resource(organization) diff --git a/apps/backend/chatballs_backend/settings_database.py b/apps/backend/chatballs_backend/settings_database.py index 1e251a9..051a847 100644 --- a/apps/backend/chatballs_backend/settings_database.py +++ b/apps/backend/chatballs_backend/settings_database.py @@ -2,7 +2,7 @@ import os from django.core.exceptions import ImproperlyConfigured -from chatballs_backend.settings_env import env_secret +from chatballs_backend.settings_env import env_bool, env_secret def _credentials() -> tuple[dict[str, str], dict[str, str]]: @@ -16,16 +16,19 @@ def _credentials() -> tuple[dict[str, str], dict[str, str]]: "POSTGRES_MIGRATION_USER", "chatballs_migration" ), } - # Пароли ролей генерирует первый старт стека в том с секретами; человек их - # не вводит и не хранит. Переменные окружения остаются переопределением. - fallback = env_secret("POSTGRES_PASSWORD", "postgres_password", "chatballs") + # Пароли ролей генерирует первый старт стека в томах с секретами; человек их + # не вводит и не хранит. Пароли platform и migration лежат в своих томах + # (подкаталоги platform/ и schema/), которые монтируются только процессам с + # этими ролями; у остальных файла нет и остаётся default. Переменные + # окружения остаются переопределением. + fallback = env_secret("POSTGRES_PASSWORD", "schema/postgres_password", "chatballs") passwords = { "app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback), "platform": env_secret( - "POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback + "POSTGRES_PLATFORM_PASSWORD", "platform/postgres_platform_password", fallback ), "migration": env_secret( - "POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback + "POSTGRES_MIGRATION_PASSWORD", "schema/postgres_migration_password", fallback ), } return users, passwords @@ -75,10 +78,13 @@ def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]: "OPTIONS": {"pool": dict(pool)} if pool else {}, } - databases = { - "default": config("migration" if testing else role), - "platform": config("platform"), - } + databases = {"default": config("migration" if testing else role)} + # Алиас platform поднимается только там, где он нужен: в платформенной + # поверхности и в воркере (CHATBALLS_DB_PLATFORM_ALIAS=1 — он захватывает + # outbox всех организаций). Остальные процессы пароль этой роли не читают; + # обращение к алиасу там упадёт сразу, а не откроет обход изоляции. + if testing or role == "platform" or env_bool("CHATBALLS_DB_PLATFORM_ALIAS"): + databases["platform"] = config("platform") if testing: # Тесты создают свою БД и подключаются владельцем кластера. Его пароль # приходит оттуда же, откуда у остальных ролей: файл секрета инстанса, @@ -87,7 +93,7 @@ def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]: "POSTGRES_USER", "chatballs_bootstrap" ) databases["default"]["PASSWORD"] = env_secret( - "POSTGRES_PASSWORD", "postgres_password", "chatballs" + "POSTGRES_PASSWORD", "schema/postgres_password", "chatballs" ) databases["platform"]["TEST"] = {"MIRROR": "default"} return databases