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:
193
tests/integration/postgres/test_domains_api.py
Normal file
193
tests/integration/postgres/test_domains_api.py
Normal 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"
|
||||
Reference in New Issue
Block a user