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:
@@ -11,6 +11,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.application.auth.context import AuthContext
|
||||
from src.application.domains import UnknownDomainError
|
||||
from src.application.files.models import UploadResult
|
||||
from src.application.files.upload import upload_source_file
|
||||
from src.application.ingestion.errors import (
|
||||
@@ -28,7 +29,7 @@ from tests.fakes import (
|
||||
FakePointStorage,
|
||||
FakeSparseEmbedder,
|
||||
)
|
||||
from tests.support.factories import create_api_key, create_tenant
|
||||
from tests.support.factories import create_api_key, create_tenant, create_tenant_domain
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
@@ -70,9 +71,12 @@ async def _upload(
|
||||
)
|
||||
|
||||
|
||||
async def _auth_for(db_session: AsyncSession) -> AuthContext:
|
||||
async def _auth_for(db_session: AsyncSession, *, domain: str = "general") -> AuthContext:
|
||||
tenant = await create_tenant(db_session)
|
||||
api_key, _ = await create_api_key(db_session, tenant=tenant)
|
||||
# Uploads reject an unregistered domain (ADR-0009), so register the one the
|
||||
# helper below uploads to.
|
||||
await create_tenant_domain(db_session, tenant=tenant, domain=domain)
|
||||
await db_session.commit()
|
||||
return AuthContext(
|
||||
tenant_id=tenant.id,
|
||||
@@ -289,7 +293,7 @@ async def test_upload_source_file_indexes_points_and_records_real_counters(
|
||||
async def test_upload_source_file_indexes_points_under_the_authenticated_tenant(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
auth = await _auth_for(db_session)
|
||||
auth = await _auth_for(db_session, domain="fire")
|
||||
point_storage = FakePointStorage()
|
||||
|
||||
result = await _upload(
|
||||
@@ -395,3 +399,53 @@ async def test_upload_source_file_retry_after_index_failure_produces_no_duplicat
|
||||
assert len(tenant_jobs) == 2
|
||||
assert {job.status for job in tenant_jobs} == {"failed", "succeeded"}
|
||||
|
||||
|
||||
async def test_upload_source_file_rejects_an_unregistered_domain_before_any_write(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
"""A typo'd domain must fail loudly, not create a new Qdrant partition
|
||||
whose contents retrieval never queries (ADR-0009).
|
||||
"""
|
||||
auth = await _auth_for(db_session, domain="fire")
|
||||
storage = FakeObjectStorage()
|
||||
point_storage = FakePointStorage()
|
||||
|
||||
with pytest.raises(UnknownDomainError, match="fier"):
|
||||
await _upload(
|
||||
sessionmaker=db_sessionmaker,
|
||||
storage=storage,
|
||||
auth=auth,
|
||||
point_storage=point_storage,
|
||||
domain="fier",
|
||||
)
|
||||
|
||||
# Nothing was written anywhere: no object, no points, and no job row.
|
||||
assert storage.objects == {}
|
||||
assert point_storage.points == {}
|
||||
async with db_sessionmaker() as verify_session:
|
||||
jobs = (await verify_session.execute(select(IngestionJob))).scalars().all()
|
||||
assert [job for job in jobs if job.tenant_id == auth.tenant_id] == []
|
||||
|
||||
|
||||
async def test_upload_source_file_rejects_a_disabled_domain(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
api_key, _ = await create_api_key(db_session, tenant=tenant)
|
||||
await create_tenant_domain(db_session, tenant=tenant, domain="fire", status="disabled")
|
||||
await db_session.commit()
|
||||
auth = AuthContext(
|
||||
tenant_id=tenant.id,
|
||||
tenant_slug=tenant.slug,
|
||||
api_key_id=api_key.id,
|
||||
scopes=frozenset({"files:write"}),
|
||||
actor_type="backend",
|
||||
)
|
||||
|
||||
with pytest.raises(UnknownDomainError, match="disabled"):
|
||||
await _upload(
|
||||
sessionmaker=db_sessionmaker,
|
||||
storage=FakeObjectStorage(),
|
||||
auth=auth,
|
||||
domain="fire",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user