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:
Ali Zarinkolah
2026-08-20 18:20:24 +03:30
parent fa933b08ff
commit e9e83b3a26
21 changed files with 1102 additions and 12 deletions

View File

@@ -10,6 +10,7 @@ pytestmark = [
EXPECTED_TABLES = {
"tenants",
"tenant_domains",
"api_keys",
"source_files",
"ingestion_jobs",
@@ -25,3 +26,17 @@ async def test_migrations_create_schema_from_empty_database(postgres_engine: Asy
)
assert EXPECTED_TABLES.issubset(set(table_names))
async def test_tenant_domains_enforces_one_row_per_tenant_and_key(
postgres_engine: AsyncEngine,
) -> None:
"""The unique constraint is what stops the same domain being registered
twice for a tenant while still letting two tenants share a key.
"""
async with postgres_engine.connect() as connection:
constraints = await connection.run_sync(
lambda sync_conn: inspect(sync_conn).get_unique_constraints("tenant_domains")
)
assert any(constraint["column_names"] == ["tenant_id", "domain"] for constraint in constraints)