feat(tenant): add tenant_domains allowlist and /v1/domains management API
Why: - Domain values are denormalized into every Qdrant point payload. Without validation, an unregistered or typo'd domain (e.g. "fier" for "fire") silently creates a new partition that retrieval never queries — the file ends up invisible rather than rejected. Tenants also need independently sized domain sets (one may run 14 insurance lines, another 6), which rules out an enum. Changes: - tenant_domains table (migration 41335d162de8) + repository, unique on (tenant_id, domain). - src/application/domains/: ensure_domain_allowed() is the strict-allowlist check now run inside upload_source_file()'s first transaction, before any MinIO object, job row, or Qdrant point is written. - /v1/domains (list/create/patch/disable/enable) gated on its own domains:read/domains:write scopes, deliberately separate from files:write so an upload key cannot create partitions. domain itself is immutable (denormalized into every point payload); only display_name is editable. Disable blocks new uploads without touching already-indexed points. Impact: - BREAKING: POST /v1/files now rejects any domain without an active tenant_domains row (400, unknown_domain). A domain must be created via POST /v1/domains before the first upload to it.
This commit is contained in:
184
tests/integration/postgres/test_domains_service.py
Normal file
184
tests/integration/postgres/test_domains_service.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Tenant-domain management and the upload-time allowlist (ADR-0009)."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.application.domains import (
|
||||
DomainAlreadyExistsError,
|
||||
UnknownDomainError,
|
||||
create_domain,
|
||||
ensure_domain_allowed,
|
||||
list_domains,
|
||||
set_domain_status,
|
||||
update_domain,
|
||||
)
|
||||
from tests.support.factories import create_tenant, create_tenant_domain
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.postgres,
|
||||
pytest.mark.asyncio(loop_scope="session"),
|
||||
]
|
||||
|
||||
|
||||
async def test_ensure_domain_allowed_passes_for_a_registered_active_domain(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
|
||||
|
||||
await ensure_domain_allowed(db_session, tenant_id=tenant.id, domain="fire")
|
||||
|
||||
|
||||
async def test_ensure_domain_allowed_rejects_an_unregistered_domain(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""The typo case: `fier` must not silently become a new Qdrant partition."""
|
||||
tenant = await create_tenant(db_session)
|
||||
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
|
||||
|
||||
with pytest.raises(UnknownDomainError, match="fier"):
|
||||
await ensure_domain_allowed(db_session, tenant_id=tenant.id, domain="fier")
|
||||
|
||||
|
||||
async def test_ensure_domain_allowed_rejects_a_disabled_domain(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
await create_tenant_domain(db_session, tenant=tenant, domain="fire", status="disabled")
|
||||
|
||||
with pytest.raises(UnknownDomainError, match="disabled"):
|
||||
await ensure_domain_allowed(db_session, tenant_id=tenant.id, domain="fire")
|
||||
|
||||
|
||||
async def test_ensure_domain_allowed_rejects_another_tenants_domain(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Domain lists are per-tenant; one tenant's `fire` is not another's."""
|
||||
owner = await create_tenant(db_session)
|
||||
other = await create_tenant(db_session)
|
||||
await create_tenant_domain(db_session, tenant=owner, domain="fire")
|
||||
|
||||
with pytest.raises(UnknownDomainError):
|
||||
await ensure_domain_allowed(db_session, tenant_id=other.id, domain="fire")
|
||||
|
||||
|
||||
async def test_tenants_hold_independent_domain_sets_of_different_sizes(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
big = await create_tenant(db_session)
|
||||
small = await create_tenant(db_session)
|
||||
for index in range(14):
|
||||
await create_tenant_domain(db_session, tenant=big, domain=f"line-{index:02d}")
|
||||
for index in range(6):
|
||||
await create_tenant_domain(db_session, tenant=small, domain=f"line-{index:02d}")
|
||||
await db_session.commit()
|
||||
|
||||
assert len(await list_domains(db_sessionmaker, tenant_id=big.id)) == 14
|
||||
assert len(await list_domains(db_sessionmaker, tenant_id=small.id)) == 6
|
||||
|
||||
|
||||
async def test_create_domain_then_upload_is_allowed(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
created = await create_domain(
|
||||
db_sessionmaker, tenant_id=tenant.id, domain="car", display_name="Car insurance"
|
||||
)
|
||||
|
||||
assert created.domain == "car"
|
||||
assert created.status == "active"
|
||||
async with db_sessionmaker() as session:
|
||||
await ensure_domain_allowed(session, tenant_id=tenant.id, domain="car")
|
||||
|
||||
|
||||
async def test_create_domain_rejects_a_duplicate_key_for_the_same_tenant(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(DomainAlreadyExistsError):
|
||||
await create_domain(
|
||||
db_sessionmaker, tenant_id=tenant.id, domain="fire", display_name="Fire again"
|
||||
)
|
||||
|
||||
|
||||
async def test_create_domain_allows_the_same_key_for_different_tenants(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
first = await create_tenant(db_session)
|
||||
second = await create_tenant(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
await create_domain(db_sessionmaker, tenant_id=first.id, domain="fire", display_name="Fire")
|
||||
await create_domain(db_sessionmaker, tenant_id=second.id, domain="fire", display_name="Fire")
|
||||
|
||||
assert len(await list_domains(db_sessionmaker, tenant_id=first.id)) == 1
|
||||
assert len(await list_domains(db_sessionmaker, tenant_id=second.id)) == 1
|
||||
|
||||
|
||||
async def test_update_domain_changes_only_the_display_name(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
|
||||
await db_session.commit()
|
||||
|
||||
updated = await update_domain(
|
||||
db_sessionmaker, tenant_id=tenant.id, domain="fire", display_name="Fire & perils"
|
||||
)
|
||||
|
||||
assert updated.display_name == "Fire & perils"
|
||||
# The key is immutable: it is denormalized into every point payload.
|
||||
assert updated.domain == "fire"
|
||||
|
||||
|
||||
async def test_disabling_a_domain_blocks_new_uploads_without_deleting_it(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
|
||||
await db_session.commit()
|
||||
|
||||
disabled = await set_domain_status(
|
||||
db_sessionmaker, tenant_id=tenant.id, domain="fire", status="disabled"
|
||||
)
|
||||
|
||||
assert disabled.status == "disabled"
|
||||
async with db_sessionmaker() as session:
|
||||
with pytest.raises(UnknownDomainError):
|
||||
await ensure_domain_allowed(session, tenant_id=tenant.id, domain="fire")
|
||||
# Still there, just hidden from the default listing.
|
||||
assert await list_domains(db_sessionmaker, tenant_id=tenant.id) == []
|
||||
assert len(await list_domains(db_sessionmaker, tenant_id=tenant.id, include_disabled=True)) == 1
|
||||
|
||||
|
||||
async def test_re_enabling_a_domain_restores_uploads(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
await create_tenant_domain(db_session, tenant=tenant, domain="fire", status="disabled")
|
||||
await db_session.commit()
|
||||
|
||||
await set_domain_status(db_sessionmaker, tenant_id=tenant.id, domain="fire", status="active")
|
||||
|
||||
async with db_sessionmaker() as session:
|
||||
await ensure_domain_allowed(session, tenant_id=tenant.id, domain="fire")
|
||||
|
||||
|
||||
async def test_update_domain_rejects_another_tenants_domain(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
owner = await create_tenant(db_session)
|
||||
other = await create_tenant(db_session)
|
||||
await create_tenant_domain(db_session, tenant=owner, domain="fire")
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(UnknownDomainError):
|
||||
await update_domain(
|
||||
db_sessionmaker, tenant_id=other.id, domain="fire", display_name="hijacked"
|
||||
)
|
||||
Reference in New Issue
Block a user