feat(tenants): add the tenant registry, the one enforcement point Book and Scribe now share (T-8)
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>
This commit is contained in:
@@ -579,3 +579,42 @@ class TestTenantIsolation:
|
||||
).json()
|
||||
assert found["total"] == 1
|
||||
assert found["entries"][0]["message"]["body"] == "shared-search-term in TENANT"
|
||||
|
||||
|
||||
class TestTenantRegistryEnforcement:
|
||||
"""core-api T-8 / workspace D-73: Scribe's own `_require_tenant` now
|
||||
delegates to the tenant registry's one enforcement point, so a Scribe
|
||||
route gains three refusal paths it did not have before, alongside the
|
||||
control that a properly granted, active tenant still succeeds."""
|
||||
|
||||
def test_unknown_tenant_is_403(self, client):
|
||||
response = client.get(
|
||||
"/scribe/addresses",
|
||||
headers=office_headers("jeroen", "office-claude", 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(
|
||||
"/scribe/addresses",
|
||||
headers=office_headers("jeroen", "office-claude", 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(
|
||||
"/scribe/addresses",
|
||||
headers=office_headers("jeroen", "office-ungranted", tenant=TENANT),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "consumer_not_granted"
|
||||
|
||||
def test_granted_consumer_in_an_active_tenant_succeeds(self, client):
|
||||
"""The control: office-claude, granted in jpmschweitzer (active),
|
||||
still reaches the route."""
|
||||
response = client.get(
|
||||
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant=TENANT)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user