A tenant has been an unchecked string since T-4/T-7: the Office's X-Office-Tenant header or a user JWT's tatlock_user claim, with Book and Scribe each running their own _require_tenant that checked only presence. A typo'd claim opened a fresh, empty namespace rather than being refused. src/domains/tenants/ (controller/models/schemas/service) adds tenants and tenant_consumers (migration 007, down_revision="006"): tenants.id is the slug a tatlock tenant carries, kind (household|person|test) and status (active|suspended) CHECK-constrained; tenant_consumers is the grant pairing a consumer to a tenant. Migration 007 seeds the two tenants in use today — jpmschweitzer (household, "Jeroen") and llm_tester (test, "Playground") — each granted to office-claude, the only Office consumer configured in Authentik. Every existing owner_tenant column (book_records, book_tenant_counters, scribe_addresses, scribe_messages, scribe_mailbox_entries — five, grepped and confirmed) now carries a foreign key to tenants.id, ON DELETE RESTRICT, at both the migration and the ORM model. TenantsService.require_tenant is the one enforcement point: no tenant is 400 tenant_required (unchanged); an unknown tenant is 403 tenant_unknown; a suspended one is 403 tenant_suspended; an Office caller whose consumer holds no grant in that tenant is 403 consumer_not_granted. One query — a LEFT JOIN against the grant — so presence, status and the grant are all read in a single round trip. Book's and Scribe's own _require_tenant now delegate to it, translating TenantError to their own exception type so every call site and each controller's single except clause keep working unchanged. Read-only this round: GET /tenants/me answers the caller's own tenant and its granted consumers (the cheap "am I allowed here" probe); GET /tenants answers every tenant for a tatlock/scheduler caller, or a list of one for a tenant-bearing caller — another tenant's existence is never revealed. No write routes; provisioning a tenant is workspace T-195. Tests: tests/test_tenants.py and tests/test_tenants_migration.py (new); a TestTenantRegistryEnforcement class added to both tests/test_book.py and tests/test_scribe.py proving all four cases (unknown, suspended, ungranted-consumer, and the control) on a live Book and Scribe route, not only on /tenants itself. tests/conftest.py gains a tenant_registry schema fixture and a suspended_tenant fixture (inserted and deleted per test; no FK references it); book_schema and scribe_schema each independently grant a second, test-environment-only consumer (office-gemini) in both seeded tenants, since test_book.py and test_scribe.py have used it as a stand-in second consumer since before this grant check existed — the migration itself still seeds only office-claude, matching the dossier. Mutation-checked: dropping the grant check fails the three consumer_not_granted tests (200 instead of 403); dropping the status check fails the three tenant_suspended tests; making Book bypass the helper fails its unknown-tenant test (200 instead of 403). All three restored and the full suite re-verified green (508 + 35 = 543 passed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
206 lines
8.3 KiB
Python
206 lines
8.3 KiB
Python
"""
|
|
Tests for the tenant registry's read API (core-api T-8 / workspace D-73)
|
|
against `core_api_test`: `GET /tenants/me` and `GET /tenants`.
|
|
|
|
Follows tests/test_scribe.py's TestClient pattern: `get_async_session` is
|
|
overridden to the test database for every test, building a fresh
|
|
engine/session per call because TestClient drives the ASGI app on a
|
|
different event loop than pytest-asyncio's fixture setup.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from src.domains.auth.callers import _TENANT_RE
|
|
from src.domains.tenants.models import TENANT_ID_PATTERN
|
|
from src.main import app
|
|
from src.shared.config import get_settings
|
|
from src.shared.database import get_async_session
|
|
|
|
settings = get_settings()
|
|
|
|
OFFICE_KEY = "test-office-key"
|
|
TATLOCK_KEY = "test-tatlock-key"
|
|
SCHEDULER_KEY = "test-scheduler-key"
|
|
TENANT = "jpmschweitzer"
|
|
OTHER_TENANT = "llm_tester"
|
|
|
|
|
|
def test_tenant_id_pattern_matches_callers_tenant_regex():
|
|
"""models.py deliberately duplicates callers.py's `_TENANT_RE` shape
|
|
rather than importing its private constant (see models.py's comment) —
|
|
assert the two never drift apart instead."""
|
|
assert TENANT_ID_PATTERN == _TENANT_RE.pattern
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def service_keys():
|
|
"""Every test here authenticates with one of the three static keys
|
|
unless it says otherwise."""
|
|
originals = (
|
|
settings.office_service_key,
|
|
settings.tatlock_service_key,
|
|
settings.scheduler_service_key,
|
|
)
|
|
settings.office_service_key = OFFICE_KEY
|
|
settings.tatlock_service_key = TATLOCK_KEY
|
|
settings.scheduler_service_key = SCHEDULER_KEY
|
|
yield
|
|
(
|
|
settings.office_service_key,
|
|
settings.tatlock_service_key,
|
|
settings.scheduler_service_key,
|
|
) = originals
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tenant_registry, test_database_url):
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
|
|
async def _override():
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
async with session_factory() as session:
|
|
yield session
|
|
await engine.dispose()
|
|
|
|
app.dependency_overrides[get_async_session] = _override
|
|
yield TestClient(app)
|
|
app.dependency_overrides.pop(get_async_session, None)
|
|
|
|
|
|
def office_headers(
|
|
identity: str = "jeroen", consumer: str = "office-claude", tenant: str = TENANT
|
|
) -> dict:
|
|
return {
|
|
"Authorization": f"Bearer {OFFICE_KEY}",
|
|
"X-Office-Identity": identity,
|
|
"X-Office-Consumer": consumer,
|
|
"X-Office-Tenant": tenant,
|
|
}
|
|
|
|
|
|
def tatlock_headers() -> dict:
|
|
return {"Authorization": f"Bearer {TATLOCK_KEY}"}
|
|
|
|
|
|
def scheduler_headers() -> dict:
|
|
return {"Authorization": f"Bearer {SCHEDULER_KEY}"}
|
|
|
|
|
|
class TestMe:
|
|
"""`GET /tenants/me` — the cheap "am I allowed here" probe (D-73)."""
|
|
|
|
def test_office_caller_sees_its_own_tenant_and_its_grants(self, client):
|
|
response = client.get("/tenants/me", headers=office_headers())
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["id"] == TENANT
|
|
assert body["display_name"] == "Jeroen"
|
|
assert body["kind"] == "household"
|
|
assert body["status"] == "active"
|
|
# office-claude is the grant the dossier seeds; office-gemini is a
|
|
# test-environment-only addition (tests/conftest.py's
|
|
# tenant_registry fixture) — assert membership, not the exact list,
|
|
# so this test is not coupled to that test-only extra.
|
|
assert "office-claude" in body["consumers"]
|
|
|
|
def test_other_seeded_tenant_answers_its_own_identity(self, client):
|
|
response = client.get("/tenants/me", headers=office_headers(tenant=OTHER_TENANT))
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["id"] == OTHER_TENANT
|
|
assert body["kind"] == "test"
|
|
|
|
def test_unknown_tenant_is_403(self, client):
|
|
response = client.get(
|
|
"/tenants/me", headers=office_headers(tenant="no-such-tenant")
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"]["code"] == "tenant_unknown"
|
|
|
|
def test_suspended_tenant_is_403(self, client, suspended_tenant):
|
|
response = client.get(
|
|
"/tenants/me", headers=office_headers(tenant=suspended_tenant)
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"]["code"] == "tenant_suspended"
|
|
|
|
def test_consumer_without_a_grant_is_403(self, client):
|
|
response = client.get(
|
|
"/tenants/me", headers=office_headers(consumer="office-ungranted")
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"]["code"] == "consumer_not_granted"
|
|
|
|
def test_tatlock_caller_is_403_forbidden(self, client):
|
|
"""`/me` is Office/user-scoped (`require_tenants_reader`), the same
|
|
reader class as Book and Scribe — tatlock has no "own tenant" to
|
|
answer with, so it is refused at the caller-kind check, before the
|
|
service ever runs, rather than reaching a tenant_required 400."""
|
|
response = client.get("/tenants/me", headers=tatlock_headers())
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"]["code"] == "forbidden"
|
|
|
|
def test_user_without_tenant_claim_is_400(self, client):
|
|
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
|
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
|
|
response = client.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"})
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "tenant_required"
|
|
|
|
def test_user_with_tenant_claim_succeeds(self, client):
|
|
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
|
mock_get_current_user.return_value = {
|
|
"preferred_username": "jeroen",
|
|
"tatlock_user": TENANT,
|
|
}
|
|
response = client.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"})
|
|
assert response.status_code == 200
|
|
assert response.json()["id"] == TENANT
|
|
|
|
|
|
class TestList:
|
|
"""`GET /tenants` — the hard boundary: another tenant's existence is
|
|
never revealed to a tenant-bearing caller."""
|
|
|
|
def test_tatlock_sees_every_tenant(self, client):
|
|
response = client.get("/tenants", headers=tatlock_headers())
|
|
assert response.status_code == 200
|
|
ids = {t["id"] for t in response.json()["tenants"]}
|
|
assert {TENANT, OTHER_TENANT}.issubset(ids)
|
|
|
|
def test_scheduler_sees_every_tenant(self, client):
|
|
response = client.get("/tenants", headers=scheduler_headers())
|
|
assert response.status_code == 200
|
|
ids = {t["id"] for t in response.json()["tenants"]}
|
|
assert {TENANT, OTHER_TENANT}.issubset(ids)
|
|
|
|
def test_office_caller_sees_only_its_own_tenant_as_a_list_of_one(self, client):
|
|
response = client.get("/tenants", headers=office_headers(tenant=TENANT))
|
|
assert response.status_code == 200
|
|
tenants = response.json()["tenants"]
|
|
assert len(tenants) == 1
|
|
assert tenants[0]["id"] == TENANT
|
|
# The hard boundary (D-73): the other seeded tenant's existence is
|
|
# not revealed, not even as an id in a list it is excluded from.
|
|
assert all(t["id"] != OTHER_TENANT for t in tenants)
|
|
|
|
def test_other_tenant_caller_sees_only_its_own_as_a_list_of_one(self, client):
|
|
response = client.get("/tenants", headers=office_headers(tenant=OTHER_TENANT))
|
|
assert response.status_code == 200
|
|
tenants = response.json()["tenants"]
|
|
assert len(tenants) == 1
|
|
assert tenants[0]["id"] == OTHER_TENANT
|
|
|
|
def test_user_without_tenant_claim_is_400(self, client):
|
|
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
|
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
|
|
response = client.get("/tenants", headers={"Authorization": "Bearer some-jwt"})
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"]["code"] == "tenant_required"
|