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:
@@ -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"),
|
||||
|
||||
@@ -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
110
src/api/routers/domains.py
Normal 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)
|
||||
63
src/api/schemas/domains.py
Normal file
63
src/api/schemas/domains.py
Normal 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)
|
||||
Reference in New Issue
Block a user