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

@@ -16,6 +16,7 @@ from src.application.auth.errors import (
MissingScopeError,
TenantInactiveError,
)
from src.application.domains.errors import DomainAlreadyExistsError, UnknownDomainError
from src.application.files.errors import FileTooLargeError, InvalidUploadError
from src.application.ingestion.errors import (
ChunkLimitExceededError,
@@ -40,6 +41,8 @@ _MAPPING: tuple[tuple[type[Exception], int, str], ...] = (
(TenantInactiveError, status.HTTP_401_UNAUTHORIZED, "tenant_not_found"),
(MissingScopeError, status.HTTP_403_FORBIDDEN, "missing_scope"),
(InvalidUploadError, status.HTTP_400_BAD_REQUEST, "validation_error"),
(UnknownDomainError, status.HTTP_400_BAD_REQUEST, "unknown_domain"),
(DomainAlreadyExistsError, status.HTTP_409_CONFLICT, "conflict"),
(DocumentParseError, status.HTTP_400_BAD_REQUEST, "validation_error"),
(UnsupportedSourceTypeError, status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "unsupported_media_type"),
(FileTooLargeError, status.HTTP_413_CONTENT_TOO_LARGE, "payload_too_large"),

View File

@@ -1,6 +1,8 @@
from fastapi import APIRouter
from src.api.routers.domains import router as domains_router
from src.api.routers.files import router as files_router
router = APIRouter()
router.include_router(domains_router)
router.include_router(files_router)

110
src/api/routers/domains.py Normal file
View File

@@ -0,0 +1,110 @@
"""`/v1/domains` (ADR-0008, ADR-0009).
The management surface for a tenant's domain allowlist, used by the calling
backend rather than by an operator with a psql prompt.
Gated on `domains:read`/`domains:write`, deliberately **not** on `files:write`:
if an upload key could create domains, the allowlist would no longer prevent a
typo'd `domain` from creating a Qdrant partition, which is its only purpose.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.api.dependencies.auth import require_scope
from src.api.schemas.domains import (
CreateDomainRequest,
DomainListResponse,
DomainResponse,
UpdateDomainRequest,
)
from src.application.auth.context import AuthContext
from src.application.domains import (
create_domain,
list_domains,
set_domain_status,
update_domain,
)
from src.bootstrap.dependencies import get_sessionmaker
router = APIRouter(prefix="/domains", tags=["domains"])
_RequireDomainsRead = Annotated[AuthContext, Depends(require_scope("domains:read"))]
_RequireDomainsWrite = Annotated[AuthContext, Depends(require_scope("domains:write"))]
_SessionmakerDep = Annotated[async_sessionmaker[AsyncSession], Depends(get_sessionmaker)]
@router.get("")
async def list_tenant_domains(
auth: _RequireDomainsRead,
sessionmaker: _SessionmakerDep,
include_disabled: bool = False,
) -> DomainListResponse:
results = await list_domains(
sessionmaker, tenant_id=auth.tenant_id, include_disabled=include_disabled
)
return DomainListResponse(domains=[DomainResponse.from_result(item) for item in results])
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_tenant_domain(
request: CreateDomainRequest,
auth: _RequireDomainsWrite,
sessionmaker: _SessionmakerDep,
) -> DomainResponse:
result = await create_domain(
sessionmaker,
tenant_id=auth.tenant_id,
domain=request.domain,
display_name=request.display_name,
metadata=request.metadata,
)
return DomainResponse.from_result(result)
@router.patch("/{domain}")
async def update_tenant_domain(
domain: str,
request: UpdateDomainRequest,
auth: _RequireDomainsWrite,
sessionmaker: _SessionmakerDep,
) -> DomainResponse:
result = await update_domain(
sessionmaker,
tenant_id=auth.tenant_id,
domain=domain,
display_name=request.display_name,
)
return DomainResponse.from_result(result)
@router.delete("/{domain}")
async def disable_tenant_domain(
domain: str,
auth: _RequireDomainsWrite,
sessionmaker: _SessionmakerDep,
) -> DomainResponse:
"""Disable, not delete.
Blocks new uploads and drops the domain from pickers while leaving the
points already indexed under it intact and retrievable. Actually removing
them needs the tenant-erasure workflow plan 001 defers.
"""
result = await set_domain_status(
sessionmaker, tenant_id=auth.tenant_id, domain=domain, status="disabled"
)
return DomainResponse.from_result(result)
@router.post("/{domain}/enable")
async def enable_tenant_domain(
domain: str,
auth: _RequireDomainsWrite,
sessionmaker: _SessionmakerDep,
) -> DomainResponse:
result = await set_domain_status(
sessionmaker, tenant_id=auth.tenant_id, domain=domain, status="active"
)
return DomainResponse.from_result(result)

View File

@@ -0,0 +1,63 @@
"""Public request/response models for `/v1/domains` (ADR-0008, ADR-0009).
`tenant_id` appears in none of these: it comes from the authenticated key, and
accepting it from a body would break the isolation boundary (ADR-0002).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from src.application.domains.models import DomainResult
# Lowercase alphanumerics plus - and _; the key is embedded in every Qdrant
# payload and filtered on as a keyword, so it stays boring on purpose.
_DOMAIN_PATTERN = r"^[a-z0-9][a-z0-9_-]*$"
class DomainResponse(BaseModel):
id: uuid.UUID
domain: str
display_name: str
status: str
metadata: dict[str, object]
created_at: datetime
updated_at: datetime
@classmethod
def from_result(cls, result: DomainResult) -> "DomainResponse":
return cls(
id=result.id,
domain=result.domain,
display_name=result.display_name,
status=result.status,
metadata=result.metadata,
created_at=result.created_at,
updated_at=result.updated_at,
)
class DomainListResponse(BaseModel):
domains: list[DomainResponse]
class CreateDomainRequest(BaseModel):
domain: str = Field(min_length=1, max_length=80, pattern=_DOMAIN_PATTERN)
display_name: str = Field(min_length=1, max_length=200)
metadata: dict[str, object] = Field(default_factory=dict)
@field_validator("domain")
@classmethod
def _normalize(cls, value: str) -> str:
return value.strip()
class UpdateDomainRequest(BaseModel):
"""`domain` is absent by design — the key is immutable.
It is denormalized into every point payload and into `source_files`, so
renaming it is a migration rather than an edit (ADR-0009).
"""
display_name: str = Field(min_length=1, max_length=200)

View File

@@ -0,0 +1,27 @@
"""Tenant-domain management and the upload-time allowlist check (ADR-0009)."""
from src.application.domains.errors import (
DomainAlreadyExistsError,
DomainsError,
UnknownDomainError,
)
from src.application.domains.models import DomainResult
from src.application.domains.service import (
create_domain,
ensure_domain_allowed,
list_domains,
set_domain_status,
update_domain,
)
__all__ = [
"DomainAlreadyExistsError",
"DomainResult",
"DomainsError",
"UnknownDomainError",
"create_domain",
"ensure_domain_allowed",
"list_domains",
"set_domain_status",
"update_domain",
]

View File

@@ -0,0 +1,21 @@
"""Domain-management failures (ADR-0009). No HTTP knowledge here —
`src/api/errors.py` maps these to status codes.
"""
class DomainsError(Exception):
"""Base class for tenant-domain failures."""
class UnknownDomainError(DomainsError):
"""The upload named a domain the tenant has not registered, or one that is
disabled. Maps to `400`.
Rejecting is the whole point: an unrecognized `domain` would otherwise
create a new Qdrant partition silently, and a file in a partition nothing
queries is invisible rather than failed (ADR-0009).
"""
class DomainAlreadyExistsError(DomainsError):
"""The tenant already has a domain with this key. Maps to `409`."""

View File

@@ -0,0 +1,16 @@
"""Transport-agnostic results for the domain-management service."""
import uuid
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class DomainResult:
id: uuid.UUID
domain: str
display_name: str
status: str
metadata: dict[str, object]
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,142 @@
"""Tenant-domain management (ADR-0009).
A tenant's domain set is per-tenant and varies in size — one may run 14
insurance lines, another 6 — so it is data, not an enum.
`ensure_domain_allowed` is the reason this package exists: it is the strict
allowlist check the upload path runs before anything is written. Everything
else here is the management surface the calling backend uses to populate that
allowlist, under its own `domains:write` scope so an upload key cannot create
partitions.
`tenant_id` is always a required parameter taken from `AuthContext`, never from
a request body (ADR-0002).
"""
import uuid
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.domains.errors import DomainAlreadyExistsError, UnknownDomainError
from src.application.domains.models import DomainResult
from src.infrastructure.postgres.models.tenant_domain import TenantDomain
from src.infrastructure.postgres.repositories import tenant_domains as repo
async def _flush_and_refresh(session: AsyncSession, tenant_domain: TenantDomain) -> None:
"""Materialize server-generated columns before the row leaves the session.
`updated_at` is `onupdate=func.now()`, so after an UPDATE its value lives in
the database, not in the instance. Reading it later would trigger a lazy
load outside any greenlet context (`MissingGreenlet`), so it is fetched here
while the session is still open.
"""
await session.flush()
await session.refresh(tenant_domain)
def _to_result(tenant_domain: TenantDomain) -> DomainResult:
return DomainResult(
id=tenant_domain.id,
domain=tenant_domain.domain,
display_name=tenant_domain.display_name,
status=tenant_domain.status,
metadata=tenant_domain.metadata_,
created_at=tenant_domain.created_at,
updated_at=tenant_domain.updated_at,
)
async def ensure_domain_allowed(
session: AsyncSession, *, tenant_id: uuid.UUID, domain: str
) -> None:
"""Raise `UnknownDomainError` unless the tenant has this domain active.
Takes a session rather than a sessionmaker: the upload path calls this
inside its existing txn A, so the check costs no extra connection and
cannot pass and then go stale before the row is written.
"""
tenant_domain = await repo.get(session, tenant_id=tenant_id, domain=domain)
if tenant_domain is None:
raise UnknownDomainError(
f"domain '{domain}' is not registered for this tenant; "
f"create it via POST /v1/domains before uploading to it"
)
if tenant_domain.status != "active":
raise UnknownDomainError(f"domain '{domain}' is disabled for this tenant")
async def list_domains(
sessionmaker: async_sessionmaker[AsyncSession],
*,
tenant_id: uuid.UUID,
include_disabled: bool = False,
) -> list[DomainResult]:
async with sessionmaker() as session:
found = await repo.list_for_tenant(
session, tenant_id=tenant_id, include_disabled=include_disabled
)
return [_to_result(item) for item in found]
async def create_domain(
sessionmaker: async_sessionmaker[AsyncSession],
*,
tenant_id: uuid.UUID,
domain: str,
display_name: str,
metadata: dict[str, object] | None = None,
) -> DomainResult:
async with sessionmaker() as session:
if await repo.get(session, tenant_id=tenant_id, domain=domain) is not None:
raise DomainAlreadyExistsError(f"domain '{domain}' already exists for this tenant")
created = repo.create(
session,
tenant_id=tenant_id,
domain=domain,
display_name=display_name,
metadata=metadata,
)
await session.commit()
return _to_result(created)
async def update_domain(
sessionmaker: async_sessionmaker[AsyncSession],
*,
tenant_id: uuid.UUID,
domain: str,
display_name: str,
) -> DomainResult:
"""Only the label is mutable — see `repo.update_display_name`."""
async with sessionmaker() as session:
found = await repo.get(session, tenant_id=tenant_id, domain=domain)
if found is None:
raise UnknownDomainError(f"domain '{domain}' is not registered for this tenant")
repo.update_display_name(found, display_name=display_name)
await _flush_and_refresh(session, found)
await session.commit()
return _to_result(found)
async def set_domain_status(
sessionmaker: async_sessionmaker[AsyncSession],
*,
tenant_id: uuid.UUID,
domain: str,
status: str,
) -> DomainResult:
"""Disable or re-enable a domain.
Disabling blocks new uploads and hides the domain from pickers. It does not
touch the points already indexed under it — removing those needs the
tenant-erasure workflow plan 001 defers.
"""
async with sessionmaker() as session:
found = await repo.get(session, tenant_id=tenant_id, domain=domain)
if found is None:
raise UnknownDomainError(f"domain '{domain}' is not registered for this tenant")
repo.set_status(found, status=status)
await _flush_and_refresh(session, found)
await session.commit()
return _to_result(found)

View File

@@ -30,6 +30,7 @@ from anyio import CapacityLimiter, Semaphore, fail_after, to_thread
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.auth.context import AuthContext
from src.application.domains import ensure_domain_allowed
from src.application.files.errors import InvalidUploadError
from src.application.files.models import UploadResult
from src.application.files.storage_keys import source_file_object_key
@@ -118,6 +119,12 @@ async def upload_source_file(
async with acquire_ingestion_slot(concurrency_limiter):
async with sessionmaker() as session:
# Strict allowlist, checked inside txn A before anything is written
# (ADR-0009). An unregistered domain would otherwise create a new
# Qdrant partition silently, leaving the file invisible to
# retrieval rather than failing.
await ensure_domain_allowed(session, tenant_id=auth.tenant_id, domain=domain)
existing = await source_files_repo.find_active_by_content_hash(
session,
tenant_id=auth.tenant_id,

View File

@@ -4,6 +4,7 @@ from src.infrastructure.postgres.models.ingestion_job import IngestionJob
from src.infrastructure.postgres.models.ingestion_job_event import IngestionJobEvent
from src.infrastructure.postgres.models.source_file import SourceFile
from src.infrastructure.postgres.models.tenant import Tenant
from src.infrastructure.postgres.models.tenant_domain import TenantDomain
__all__ = [
"ApiKey",
@@ -12,4 +13,5 @@ __all__ = [
"IngestionJobEvent",
"SourceFile",
"Tenant",
"TenantDomain",
]

View File

@@ -0,0 +1,52 @@
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.postgres.models.base import Base
TENANT_DOMAIN_STATUSES = ("active", "disabled")
class TenantDomain(Base):
"""A domain a tenant is allowed to ingest into (ADR-0009).
Tenants do not share a domain list — one may run 14 insurance lines and
another 6 — so this is a per-tenant table rather than an enum or a global
lookup.
Its purpose is to stop an arbitrary caller-supplied `domain` from silently
creating a new Qdrant partition. `domain` is denormalized into every point's
payload and into `source_files`, and a typo like `fier` for `fire` produces
no error anywhere: the file indexes into a partition retrieval never queries,
so it is invisible rather than failed.
`domain` is the immutable key. Renaming it would mean rewriting every point
payload that carries it, which is a migration, not an edit — `display_name`
is the mutable human-facing label instead.
"""
__tablename__ = "tenant_domains"
__table_args__ = (
UniqueConstraint("tenant_id", "domain", name="uq_tenant_domains_tenant_id_domain"),
CheckConstraint(f"status IN {TENANT_DOMAIN_STATUSES}", name="ck_tenant_domains_status"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
tenant_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), index=True
)
domain: Mapped[str] = mapped_column(String(80))
display_name: Mapped[str] = mapped_column(String(200))
status: Mapped[str] = mapped_column(String(20), default="active", server_default="active")
metadata_: Mapped[dict[str, object]] = mapped_column(
"metadata", JSONB, default=dict, server_default="{}"
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)

View File

@@ -0,0 +1,73 @@
"""`tenant_domains` persistence (ADR-0009).
Plain functions over an `AsyncSession` the caller owns. No function here
commits, rolls back, or closes the session (ADR-0012). Every read and write is
tenant-scoped by a required `tenant_id` argument, so a missing filter is a
signature error rather than a cross-tenant leak.
"""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.infrastructure.postgres.models.tenant_domain import TenantDomain
async def get(session: AsyncSession, *, tenant_id: uuid.UUID, domain: str) -> TenantDomain | None:
result = await session.execute(
select(TenantDomain).where(
TenantDomain.tenant_id == tenant_id, TenantDomain.domain == domain
)
)
return result.scalar_one_or_none()
async def list_for_tenant(
session: AsyncSession, *, tenant_id: uuid.UUID, include_disabled: bool = False
) -> list[TenantDomain]:
statement = select(TenantDomain).where(TenantDomain.tenant_id == tenant_id)
if not include_disabled:
statement = statement.where(TenantDomain.status == "active")
result = await session.execute(statement.order_by(TenantDomain.domain))
return list(result.scalars().all())
def create(
session: AsyncSession,
*,
tenant_id: uuid.UUID,
domain: str,
display_name: str,
metadata: dict[str, object] | None = None,
) -> TenantDomain:
tenant_domain = TenantDomain(
id=uuid.uuid4(),
tenant_id=tenant_id,
domain=domain,
display_name=display_name,
metadata_=metadata or {},
)
session.add(tenant_domain)
return tenant_domain
def update_display_name(tenant_domain: TenantDomain, *, display_name: str) -> TenantDomain:
"""`domain` itself is deliberately not updatable.
It is denormalized into every Qdrant point payload and into `source_files`,
so changing the key would mean rewriting all of them — a migration, not an
edit. The label is what callers actually want to change.
"""
tenant_domain.display_name = display_name
return tenant_domain
def set_status(tenant_domain: TenantDomain, *, status: str) -> TenantDomain:
"""Disable/re-enable a domain. Existing points are untouched either way —
disabling blocks new uploads, it is not a delete (ADR-0002).
"""
tenant_domain.status = status
tenant_domain.disabled_at = datetime.now(UTC) if status == "disabled" else None
return tenant_domain