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

@@ -22,10 +22,14 @@ and re-benchmark rather than tune them in place (ADR-0005). Also working: the
`chunks` collection bootstrap (`src/infrastructure/qdrant/collection.py`, run as
a deployment step via `uv run python -m src.cli.qdrant_bootstrap` — never at
startup) and tenant-scoped point upserts (`src/application/points/` behind the
`PointStorage` port), so an upload is searchable by the time `201` returns. Not
built yet: `/v1/points` CRUD and keyword search (plan 002), `tenant_domains`
validation of the `domain` field, and `src/agent/`. That maps to plan 001
Phases 1-5 done.
`PointStorage` port), so an upload is searchable by the time `201` returns.
Also working: `tenant_domains` plus `/v1/domains` (`src/application/domains/`),
a strict per-tenant allowlist — `POST /v1/files` rejects an unregistered or
disabled `domain` with `400` before anything is written, and domain management
sits behind its own `domains:read`/`domains:write` scopes, never `files:write`.
Not built yet: `/v1/points` CRUD and keyword search (plan 002), and
`src/agent/`. That maps to plan 001 Phases 1-5 done, Phase 6 (runbook, e2e
tests, Compose smoke test) not started.
Architecture decisions live in `docs/adr/` (18 ADRs plus the 0000 template;
0001–0004 are `Accepted` — 0004 amended by 0018; 0014 is `Superseded by 0017`;
@@ -229,6 +233,11 @@ content_sha256)` idempotency, no terminal job returning to `running`.
### Postgres conventions (ADR-0009)
`domain` is never free-form: it must match an `active` `tenant_domains` row for
the authenticated tenant (ADR-0009). Domain sets are per-tenant and vary in
size. The key itself is immutable — it is denormalized into every Qdrant point
payload and into `source_files`, so renaming it is a migration, not an edit.
UUID primary keys (app-generated), `timestamptz` for all timestamps,
`Numeric(18, 8)` for money (never floats), `JSONB` for flexible metadata but
typed/indexed columns for query-critical fields, string status columns with