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:
@@ -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",
|
||||
]
|
||||
|
||||
52
src/infrastructure/postgres/models/tenant_domain.py
Normal file
52
src/infrastructure/postgres/models/tenant_domain.py
Normal 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)
|
||||
73
src/infrastructure/postgres/repositories/tenant_domains.py
Normal file
73
src/infrastructure/postgres/repositories/tenant_domains.py
Normal 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
|
||||
Reference in New Issue
Block a user