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:
2026-10-19 17:30:55 +02:00
co-authored by Claude Sonnet 5
parent e02173aefb
commit 99ee994023
20 changed files with 1105 additions and 37 deletions
@@ -0,0 +1,97 @@
"""Create the tenant registry (core-api T-8, workspace D-73)
Revision ID: 007
Revises: 006
Create Date: 2026-10-19
`tenants` and `tenant_consumers` — the registry every Book and Scribe
`owner_tenant` column now conforms to via a foreign key (`ON DELETE
RESTRICT`: a tenant with any Book or Scribe row can never be dropped out
from under it). 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
(dossier core-api T-8). No write routes ship this round; provisioning a
third tenant is later (workspace T-195) and happens by hand until then.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "007"
down_revision: Union[str, None] = "006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Every existing owner_tenant column (core-api T-8's dossier: "five
# owner_tenant columns exist ... find them by grep rather than trusting
# this count" — grepped and confirmed at five, here).
OWNER_TENANT_FKS = (
("book_records", "fk_book_records_owner_tenant"),
("book_tenant_counters", "fk_book_tenant_counters_owner_tenant"),
("scribe_addresses", "fk_scribe_addresses_owner_tenant"),
("scribe_messages", "fk_scribe_messages_owner_tenant"),
("scribe_mailbox_entries", "fk_scribe_mailbox_entries_owner_tenant"),
)
def upgrade() -> None:
"""Create the tenant registry, seed it, then bind every existing
owner_tenant column to it."""
op.create_table(
"tenants",
sa.Column("id", sa.String(length=63), nullable=False),
sa.Column("display_name", sa.Text(), nullable=False),
sa.Column("kind", sa.String(length=20), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.CheckConstraint("kind IN ('household', 'person', 'test')", name="ck_tenants_kind"),
sa.CheckConstraint("status IN ('active', 'suspended')", name="ck_tenants_status"),
)
op.create_table(
"tenant_consumers",
sa.Column("tenant_id", sa.String(length=63), nullable=False),
sa.Column("consumer", sa.String(length=63), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("tenant_id", "consumer"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_tenant_consumers_tenant_id"),
)
# Seed: the two tenants in use today, both granted to office-claude —
# the only Office consumer configured in Authentik (dossier T-8).
op.execute(
"""
INSERT INTO tenants (id, display_name, kind, status) VALUES
('jpmschweitzer', 'Jeroen', 'household', 'active'),
('llm_tester', 'Playground', 'test', 'active')
"""
)
op.execute(
"""
INSERT INTO tenant_consumers (tenant_id, consumer) VALUES
('jpmschweitzer', 'office-claude'),
('llm_tester', 'office-claude')
"""
)
# Every existing owner_tenant column now conforms to the registry.
for table, fk_name in OWNER_TENANT_FKS:
op.create_foreign_key(
fk_name, table, "tenants", ["owner_tenant"], ["id"], ondelete="RESTRICT"
)
def downgrade() -> None:
"""Unbind every owner_tenant column, then drop the registry."""
for table, fk_name in OWNER_TENANT_FKS:
op.drop_constraint(fk_name, table, type_="foreignkey")
op.drop_table("tenant_consumers")
op.drop_table("tenants")