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.
64 lines
1.8 KiB
Python
64 lines
1.8 KiB
Python
"""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)
|