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

@@ -0,0 +1,193 @@
"""`/v1/domains` over HTTP, against real Postgres (ADR-0008, ADR-0009).
The property worth testing at this layer is the scope boundary: an upload key
must not be able to create domains, or the allowlist stops preventing anything.
"""
from collections.abc import AsyncIterator
import pytest
import pytest_asyncio
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.bootstrap.dependencies import get_sessionmaker
from src.config import Settings
from src.main import create_app
from tests.support.factories import create_api_key, create_tenant, create_tenant_domain
pytestmark = [
pytest.mark.integration,
pytest.mark.postgres,
pytest.mark.asyncio(loop_scope="session"),
]
@pytest_asyncio.fixture(loop_scope="session")
async def api_client(
settings: Settings, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> AsyncIterator[AsyncClient]:
"""The real app, with only the database swapped for the test's session
factory -- so routing, auth, scopes, and the error envelope are exercised.
"""
app = create_app(settings)
app.dependency_overrides[get_sessionmaker] = lambda: db_sessionmaker
async with (
LifespanManager(app) as manager,
AsyncClient(transport=ASGITransport(app=manager.app), base_url="http://test") as client,
):
yield client
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
async def test_create_domain_returns_201_and_lists_it(
api_client: AsyncClient, db_session: AsyncSession
) -> None:
tenant = await create_tenant(db_session)
_, token = await create_api_key(
db_session, tenant=tenant, scopes=["domains:read", "domains:write"]
)
await db_session.commit()
created = await api_client.post(
"/v1/domains",
json={"domain": "fire", "display_name": "Fire insurance"},
headers=_auth(token),
)
listed = await api_client.get("/v1/domains", headers=_auth(token))
assert created.status_code == 201
assert created.json()["domain"] == "fire"
assert [item["domain"] for item in listed.json()["domains"]] == ["fire"]
async def test_create_domain_requires_the_domains_write_scope(
api_client: AsyncClient, db_session: AsyncSession
) -> None:
"""An upload key creating domains would defeat the allowlist entirely."""
tenant = await create_tenant(db_session)
_, token = await create_api_key(db_session, tenant=tenant, scopes=["files:write"])
await db_session.commit()
response = await api_client.post(
"/v1/domains",
json={"domain": "fire", "display_name": "Fire"},
headers=_auth(token),
)
assert response.status_code == 403
assert response.json()["error"]["code"] == "missing_scope"
async def test_list_domains_never_shows_another_tenants_domains(
api_client: AsyncClient, db_session: AsyncSession
) -> None:
owner = await create_tenant(db_session)
other = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=owner, domain="fire")
_, other_token = await create_api_key(db_session, tenant=other, scopes=["domains:read"])
await db_session.commit()
response = await api_client.get("/v1/domains", headers=_auth(other_token))
assert response.status_code == 200
assert response.json()["domains"] == []
async def test_create_domain_rejects_a_duplicate_with_409(
api_client: AsyncClient, db_session: AsyncSession
) -> None:
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
_, token = await create_api_key(db_session, tenant=tenant, scopes=["domains:write"])
await db_session.commit()
response = await api_client.post(
"/v1/domains",
json={"domain": "fire", "display_name": "Fire"},
headers=_auth(token),
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "conflict"
async def test_create_domain_rejects_a_malformed_key(
api_client: AsyncClient, db_session: AsyncSession
) -> None:
tenant = await create_tenant(db_session)
_, token = await create_api_key(db_session, tenant=tenant, scopes=["domains:write"])
await db_session.commit()
response = await api_client.post(
"/v1/domains",
json={"domain": "Fire Insurance!", "display_name": "Fire"},
headers=_auth(token),
)
assert response.status_code == 422
async def test_patch_domain_cannot_rename_the_key(
api_client: AsyncClient, db_session: AsyncSession
) -> None:
"""`domain` is not part of the update schema -- it is denormalized into
every point payload, so renaming it is a migration, not an edit.
"""
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
_, token = await create_api_key(
db_session, tenant=tenant, scopes=["domains:read", "domains:write"]
)
await db_session.commit()
response = await api_client.patch(
"/v1/domains/fire",
json={"display_name": "Fire & perils", "domain": "renamed"},
headers=_auth(token),
)
assert response.status_code == 200
assert response.json()["domain"] == "fire"
assert response.json()["display_name"] == "Fire & perils"
async def test_delete_domain_disables_it_without_removing_it(
api_client: AsyncClient, db_session: AsyncSession
) -> None:
tenant = await create_tenant(db_session)
await create_tenant_domain(db_session, tenant=tenant, domain="fire")
_, token = await create_api_key(
db_session, tenant=tenant, scopes=["domains:read", "domains:write"]
)
await db_session.commit()
deleted = await api_client.delete("/v1/domains/fire", headers=_auth(token))
default_list = await api_client.get("/v1/domains", headers=_auth(token))
full_list = await api_client.get(
"/v1/domains", params={"include_disabled": True}, headers=_auth(token)
)
assert deleted.status_code == 200
assert deleted.json()["status"] == "disabled"
assert default_list.json()["domains"] == []
assert [item["domain"] for item in full_list.json()["domains"]] == ["fire"]
async def test_patch_unknown_domain_returns_400(
api_client: AsyncClient, db_session: AsyncSession
) -> None:
tenant = await create_tenant(db_session)
_, token = await create_api_key(db_session, tenant=tenant, scopes=["domains:write"])
await db_session.commit()
response = await api_client.patch(
"/v1/domains/absent", json={"display_name": "x"}, headers=_auth(token)
)
assert response.status_code == 400
assert response.json()["error"]["code"] == "unknown_domain"