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:
27
src/application/domains/__init__.py
Normal file
27
src/application/domains/__init__.py
Normal 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",
|
||||
]
|
||||
21
src/application/domains/errors.py
Normal file
21
src/application/domains/errors.py
Normal 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`."""
|
||||
16
src/application/domains/models.py
Normal file
16
src/application/domains/models.py
Normal 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
|
||||
142
src/application/domains/service.py
Normal file
142
src/application/domains/service.py
Normal 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)
|
||||
Reference in New Issue
Block a user