mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🔒 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:
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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
[],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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,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)]
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user