🔒 fix(tenancy): гранты платформенной роли, backend-app только ролью app, организация видна в своём контексте

Провижининг организаций через платформенный 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 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-12 14:37:10 +03:00
1 parent 2e52bdaea0
commit 1390ccc426
26 files changed
+617 -99

No files matched your search

@@ -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(
@@ -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:
@@ -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 = (
@@ -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
)
+5 -2
View File
@@ -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:
+5 -2
View File
@@ -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:
@@ -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"]:
+27 -21
View File
@@ -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,
@@ -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:
@@ -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)
+7 -3
View File
@@ -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,
@@ -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(
@@ -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):
+45 -3
View File
@@ -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",
[],
)
]
+74
View File
@@ -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])
@@ -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"])
+8 -3
View File
@@ -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:
@@ -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",
@@ -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)]
@@ -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)]
@@ -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)]
+7 -5
View File
@@ -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
@@ -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()
)
+62 -1
View File
@@ -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():
+5 -7
View File
@@ -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)
@@ -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