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:
@@ -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")
|
||||
Reference in New Issue
Block a user