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
@@ -90,3 +90,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.', NULL, '2026-10-19 14:06:27', '2026-10-19 14:06:27.325', '2026-10-19 14:06:27.325', NULL, '0e233f93ee1e85162d05109749d33032', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'status', 'backlog', 'done', NULL, '2026-10-19 14:51:47', '2026-10-19 14:51:47.496', '2026-10-19 14:51:47.496', NULL, '625ca10cfc9bcaf576a2fd52a3fe2792', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -5,3 +5,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'T-5', '2026-10-19 12:57:09.632', '2026-10-19 12:57:09.632', NULL, 'a8774787d37deb533e701914864fd560', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8NBWVQ53ZX7WMZNE5GJJP4', 'T-6', '2026-10-19 13:24:11.102', '2026-10-19 13:24:11.102', NULL, 'f6351f2d6190ae5fbd9e55e6eadc04e5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'T-7', '2026-10-19 14:06:27.193', '2026-10-19 14:06:27.193', NULL, '09267e8234ec21c6d75767d7cd21bb51', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'T-8', '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, 'd8c7f6122652cb1c3e448f1adedd54ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
+15
View File
@@ -79,3 +79,18 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 14:06:27.324', NULL, '58063dda134a948a461cd97325286e48', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'story', NULL, 'The tenant registry: tenants, consumer grants, and one enforcement point', 'The tenant registry: one table that says which tenants exist, which consumers may act in each, and whether a tenant is active — the anchor every service''s tenancy conforms to (workspace D-73).
Today a tenant is an unchecked string: the Office header or a JWT''s `tatlock_user` claim is trusted as-is, and the Book and the Scribe file rows under whatever value arrives. A typo''d claim opens a fresh, empty namespace instead of being refused.
**Tables (migration 007):**
- `tenants` — `id` (the slug, the value of `tatlock_user`; same pattern `_require_office_tenant` accepts), `display_name`, `kind` (`household` | `person` | `test`), `status` (`active` | `suspended`), timestamps. Seeded: `jpmschweitzer` (household), `llm_tester` (test).
- `tenant_consumers` — a grant: consumer X may act in tenant Y. PK `(tenant_id, consumer)`. Seeded: `office-claude` in both seeded tenants.
- Foreign keys from every `owner_tenant` column in the Book and the Scribe to `tenants.id`.
**Enforcement:** every tenant-scoped operation goes through one helper. No tenant → 400 `tenant_required`. Tenant unknown → 403 `tenant_unknown`. Tenant suspended → 403 `tenant_suspended`. Office caller whose consumer holds no grant in that tenant → 403 `consumer_not_granted`.
**Read API, for other services to conform to:** `GET /tenants/me` (the caller''s own tenant and its consumer''s grant); `GET /tenants` (service callers without a tenant see all; a tenant-bearing caller sees only its own — the hard boundary).
**Not in v1:** write routes for provisioning (T-195 on the workspace board adds them, or an operator verb); per-consumer verb classes (workspace T-184 — a column on `tenant_consumers` when it lands).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, '756a467f5dc4a84c7ee9080ef21ca5fb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'task', NULL, 'The Book''s final schema and tenant boundary, before 005''s first release', 'The Book''s final schema and its tenant boundary, before core-api''s first release applies migration 005 to production. Split from T-2 (whose remaining verbs stay there). Amend 005 — never run in production — rather than add a migration: per-tenant short outward numbers ("Task 12", workspace D-67), an owner tenant on every record (D-73), and every Book route filtered by it, so the routes are safe to expose the moment they deploy.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 14:06:27.192', '2026-10-19 14:51:47.496', NULL, '8797eb16319aa33c8317b0a1f0defc3d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
+1
View File
@@ -25,6 +25,7 @@ from src.domains.book.models import ( # noqa: F401
from src.domains.scribe.models import ( # noqa: F401
ScribeAddress, ScribeMessage, ScribeMailboxEntry,
)
from src.domains.tenants.models import Tenant, TenantConsumer # noqa: F401
# Alembic Config object
config = context.config
@@ -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")
+9 -3
View File
@@ -75,8 +75,11 @@ class BookRecord(Base):
# The tatlock tenant the record belongs to (Caller.tenant, T-4/D-73).
# Every query in the service layer filters on it — there is no
# database-level RLS here, so that filter is load-bearing in Python,
# following src/domains/scribe/models.py's same note.
owner_tenant: Mapped[str] = mapped_column(String(63), nullable=False)
# following src/domains/scribe/models.py's same note. FK to
# tenants.id added by migration 007 (core-api T-8).
owner_tenant: Mapped[str] = mapped_column(
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False
)
# A short outward id, numbered per tenant (never globally) so a tenant
# can never infer another's activity from its numbers. Allocated by
# BookService._allocate_number, which locks a per-tenant counter row
@@ -277,7 +280,10 @@ class BookTenantCounter(Base):
__tablename__ = "book_tenant_counters"
owner_tenant: Mapped[str] = mapped_column(String(63), primary_key=True)
# FK to tenants.id added by migration 007 (core-api T-8).
owner_tenant: Mapped[str] = mapped_column(
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), primary_key=True
)
next_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
def __repr__(self) -> str:
+25 -16
View File
@@ -38,6 +38,7 @@ from src.domains.book.models import (
BookTenantCounter,
)
from src.domains.book.schemas import BookCloseRequest, BookRecordCreate, BookRecordUpdate
from src.domains.tenants.service import TenantError, get_tenants_service
from src.shared.config import get_settings
from src.shared.logging import get_logger
@@ -60,14 +61,22 @@ class BookService:
# Internal helpers
# =========================================================================
def _require_tenant(self, caller: Caller) -> str:
async def _require_tenant(self, session: AsyncSession, caller: Caller) -> str:
"""Every Book operation needs a tenant; a caller without one gets a
clear error rather than a query that silently matches nothing. An
Office caller always has one (callers.py enforces it on every call);
a user-kind caller without a `tatlock_user` claim does not."""
if not caller.tenant:
raise BookError("tenant_required", 400)
return caller.tenant
a user-kind caller without a `tatlock_user` claim does not.
Delegates to the tenant registry's one enforcement point (core-api
T-8 / workspace D-73), which additionally refuses an unknown
tenant, a suspended one, or an Office consumer with no grant in it.
`TenantError` is translated to `BookError` so every call site and
the controller's single `except BookError` keep working unchanged.
"""
try:
return await get_tenants_service().require_tenant(session, caller)
except TenantError as error:
raise BookError(error.code, error.status_code) from error
async def _get_or_404(self, session: AsyncSession, record_id, tenant: str) -> BookRecord:
"""A record outside `tenant` answers exactly like one that does not
@@ -160,7 +169,7 @@ class BookService:
) -> Tuple[BookRecord, List[str]]:
"""`add` — text-only input lands an unfiled Note (D-72). Sets
`owner_tenant` from the caller and allocates `number` (core-api T-7)."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
status = self._validate_status(data.kind, data.status)
number = await self._allocate_number(session, tenant)
@@ -224,7 +233,7 @@ class BookService:
caller: Caller,
) -> Tuple[BookRecord, List[str], List[BookRecordLink], List[BookRecordLink]]:
"""`show` — the record, its tags, outgoing links and backlinks."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
record = await self._get_or_404(session, record_id, tenant)
tags = await self._tags_for(session, record_id)
@@ -257,7 +266,7 @@ class BookService:
next round. `number=` finds the one record with that outward id in
the caller's tenant (core-api T-7).
"""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
conditions = [BookRecord.owner_tenant == tenant]
if view in ("inbox", "waiting", "someday", "done"):
@@ -331,7 +340,7 @@ class BookService:
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
"""`find` — full-text search over title and body, scoped to the
caller's tenant."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
tsquery = func.websearch_to_tsquery("simple", q)
base_query = select(BookRecord).where(
BookRecord.owner_tenant == tenant, BookRecord.search.op("@@")(tsquery)
@@ -362,7 +371,7 @@ class BookService:
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
"""`change` — a partial update; only fields set on the request move."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
record = await self._get_or_404(session, record_id, tenant)
update_data = data.model_dump(exclude_unset=True)
@@ -412,7 +421,7 @@ class BookService:
in the caller's tenant the same as `link` — a target in another
tenant answers 404 not_found.
"""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
record = await self._get_or_404(session, record_id, tenant)
diff: dict = {}
@@ -479,7 +488,7 @@ class BookService:
) -> Tuple[BookRecord, List[str]]:
"""`link` refuses a target in another tenant the same way a missing
one is refused — both are 404 not_found (core-api T-7)."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
record = await self._get_or_404(session, record_id, tenant)
await self._get_or_404(session, to_id, tenant) # the target must exist in the same tenant
@@ -506,7 +515,7 @@ class BookService:
relation: str,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
record = await self._get_or_404(session, record_id, tenant)
stmt = delete(BookRecordLink).where(
@@ -538,7 +547,7 @@ class BookService:
tag: str,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
record = await self._get_or_404(session, record_id, tenant)
existing = await session.get(BookTag, (record_id, tag))
@@ -557,7 +566,7 @@ class BookService:
tag: str,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
record = await self._get_or_404(session, record_id, tenant)
stmt = delete(BookTag).where(BookTag.record_id == record_id, BookTag.tag == tag)
@@ -576,7 +585,7 @@ class BookService:
async def get_history(
self, session: AsyncSession, record_id, caller: Caller, limit: int = 100
) -> List[BookEvent]:
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
await self._get_or_404(session, record_id, tenant)
stmt = (
+12 -3
View File
@@ -53,7 +53,10 @@ class ScribeAddress(Base):
UniqueConstraint("owner_tenant", "name", name="uq_scribe_addresses_tenant_name"),
)
owner_tenant: Mapped[str] = mapped_column(String(63), primary_key=True)
# FK to tenants.id added by migration 007 (core-api T-8).
owner_tenant: Mapped[str] = mapped_column(
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), primary_key=True
)
name: Mapped[str] = mapped_column(String(63), primary_key=True)
# Nullable: a "user"-kind caller (direct OIDC, not through the Office)
# carries no consumer (src/domains/auth/callers.py), so an address it
@@ -82,7 +85,10 @@ class ScribeMessage(Base):
__tablename__ = "scribe_messages"
id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True)
owner_tenant: Mapped[str] = mapped_column(String(63), nullable=False, index=True)
# FK to tenants.id added by migration 007 (core-api T-8).
owner_tenant: Mapped[str] = mapped_column(
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False, index=True
)
topics: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
from_identity: Mapped[str] = mapped_column(String(63), nullable=False)
from_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
@@ -120,7 +126,10 @@ class ScribeMailboxEntry(Base):
id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True)
address: Mapped[str] = mapped_column(String(63), nullable=False)
owner_tenant: Mapped[str] = mapped_column(String(63), nullable=False)
# FK to tenants.id added by migration 007 (core-api T-8).
owner_tenant: Mapped[str] = mapped_column(
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False
)
message_id: Mapped[int] = mapped_column(
Integer, ForeignKey("scribe_messages.id"), nullable=False
)
+21 -12
View File
@@ -24,6 +24,7 @@ from src.domains.scribe.models import (
ScribeMessage,
)
from src.domains.scribe.schemas import MailboxPatchRequest, MessageSendRequest
from src.domains.tenants.service import TenantError, get_tenants_service
from src.shared.logging import get_logger
logger = get_logger(__name__)
@@ -46,12 +47,20 @@ class ScribeService:
# Internal helpers
# =========================================================================
def _require_tenant(self, caller: Caller) -> str:
async def _require_tenant(self, session: AsyncSession, caller: Caller) -> str:
"""Every Scribe operation needs a tenant; a caller without one gets
a clear error rather than a query that silently matches nothing."""
if not caller.tenant:
raise ScribeError("tenant_required", 400)
return caller.tenant
a clear error rather than a query that silently matches nothing.
Delegates to the tenant registry's one enforcement point (core-api
T-8 / workspace D-73), which additionally refuses an unknown
tenant, a suspended one, or an Office consumer with no grant in it.
`TenantError` is translated to `ScribeError` so every call site and
the controller's single `except ScribeError` keep working
unchanged."""
try:
return await get_tenants_service().require_tenant(session, caller)
except TenantError as error:
raise ScribeError(error.code, error.status_code) from error
def _validate_address_name(self, name: str) -> None:
"""Beyond the schema's regex (shape only): not `group-*`, not reserved.
@@ -90,7 +99,7 @@ class ScribeService:
) -> Tuple[ScribeAddress, bool]:
"""`register` — idempotent for the same consumer; 409 address_taken
for another consumer in the tenant (D-66)."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
self._validate_address_name(name)
existing = await self._get_address(session, tenant, name)
@@ -116,7 +125,7 @@ class ScribeService:
self, session: AsyncSession, caller: Caller
) -> List[Tuple[ScribeAddress, bool]]:
"""`list` — the caller's tenant; mark which the caller's consumer owns."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
stmt = (
select(ScribeAddress)
@@ -134,7 +143,7 @@ class ScribeService:
self, session: AsyncSession, data: MessageSendRequest, caller: Caller
) -> ScribeMessage:
"""`send` — `from` is the caller's own identity, never the body."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
from_address = await self._get_address(session, tenant, caller.identity)
if from_address is None or from_address.owner_consumer != caller.consumer:
@@ -196,7 +205,7 @@ class ScribeService:
before_id: Optional[int] = None,
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
"""`mailbox` — the caller's identity's entries, unread first."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
await self._require_owned_identity(session, caller, tenant)
conditions = [
@@ -228,7 +237,7 @@ class ScribeService:
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
"""`find` — full text over messages in the caller identity's mailbox."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
await self._require_owned_identity(session, caller, tenant)
tsquery = func.websearch_to_tsquery("simple", q)
@@ -265,7 +274,7 @@ class ScribeService:
"""`history` — the retained messages sent to that address, oldest
first. Reading never consumes or marks: this reads scribe_messages
directly, never scribe_mailbox_entries."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
address = await self._get_address(session, tenant, name)
if address is None:
@@ -298,7 +307,7 @@ class ScribeService:
an identity the caller is not reading as, answers 404 not_found —
indistinguishable from a nonexistent entry, so an id never confirms
someone else's mail exists."""
tenant = self._require_tenant(caller)
tenant = await self._require_tenant(session, caller)
entry = await session.get(ScribeMailboxEntry, entry_id)
if entry is None or entry.owner_tenant != tenant or entry.address != caller.identity:
+10
View File
@@ -0,0 +1,10 @@
"""
Tenants Domain
The tenant registry (core-api T-8 / workspace D-73): tenants, consumer
grants, and the one enforcement point every tenant-scoped domain's own
`_require_tenant` calls through to (Book, Scribe). Served under /tenants/*.
"""
from src.domains.tenants.controller import tenants_controller
__all__ = ["tenants_controller"]
+92
View File
@@ -0,0 +1,92 @@
"""
Tenants Controller
The tenant registry's read API (core-api T-8 / workspace D-73): `GET
/tenants/me` and `GET /tenants`. No write routes this round — provisioning
is later (workspace T-195).
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.domains.auth.callers import Caller, require_caller
from src.domains.tenants.schemas import TenantListResponse, TenantMeResponse, TenantSummary
from src.domains.tenants.service import TenantError, get_tenants_service
from src.shared.base import BaseController
from src.shared.database import get_async_session
from src.shared.logging import get_logger
logger = get_logger(__name__)
require_tenants_reader = require_caller("office", "user")
require_tenants_lister = require_caller("office", "user", "tatlock", "scheduler")
def _summary(tenant) -> TenantSummary:
return TenantSummary(
id=tenant.id, display_name=tenant.display_name, kind=tenant.kind, status=tenant.status
)
class TenantsController(BaseController):
"""Controller for the tenant registry's read API."""
def __init__(self):
super().__init__(prefix="/tenants", tags=["Tenants"])
def create_router(self) -> APIRouter:
router = APIRouter(prefix=self.prefix, tags=self.tags)
service = get_tenants_service()
# =====================================================================
# me
# =====================================================================
@router.get(
"/me",
response_model=TenantMeResponse,
summary="The caller's own tenant and the consumers granted in it",
)
async def get_me(
caller: Caller = Depends(require_tenants_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
tenant, consumers = await service.get_me(session, caller)
except TenantError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code}
) from error
return TenantMeResponse(
id=tenant.id,
display_name=tenant.display_name,
kind=tenant.kind,
status=tenant.status,
consumers=consumers,
)
# =====================================================================
# list
# =====================================================================
@router.get(
"",
response_model=TenantListResponse,
summary="Every tenant (tatlock/scheduler) or the caller's own, as a list of one",
)
async def list_tenants(
caller: Caller = Depends(require_tenants_lister),
session: AsyncSession = Depends(get_async_session),
):
try:
tenants = await service.list_tenants(session, caller)
except TenantError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code}
) from error
return TenantListResponse(tenants=[_summary(tenant) for tenant in tenants])
return router
# Create controller instance
tenants_controller = TenantsController()
+71
View File
@@ -0,0 +1,71 @@
"""
Tenants Domain Models
The tenant registry (core-api T-8 / workspace D-73): every Book and Scribe
`owner_tenant` column carries a foreign key to `tenants.id` (migration 007),
and `TenantsService.require_tenant` is the one place a caller's tenant is
checked known, active, and — for an Office caller — granted to the calling
consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
src/domains/book/models.py.
"""
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from src.shared.database import Base
TENANT_KINDS = ("household", "person", "test")
TENANT_STATUSES = ("active", "suspended")
# The slug shape a tenant id carries, identical to
# src/domains/auth/callers.py's `_TENANT_RE` (tatlock's own tenant ids carry
# underscores, e.g. `llm_tester`) — not imported from there because that
# module's constants are private to it, following
# src/domains/scribe/models.py's same note about ADDRESS_NAME_PATTERN.
# Asserted equal in tests/test_tenants.py.
TENANT_ID_PATTERN = r"^[a-z0-9_][a-z0-9_.-]{0,62}$"
class Tenant(Base):
"""A tenant: tatlock's own per-household/person/test namespace."""
__tablename__ = "tenants"
__table_args__ = (
CheckConstraint("kind IN ('household', 'person', 'test')", name="ck_tenants_kind"),
CheckConstraint("status IN ('active', 'suspended')", name="ck_tenants_status"),
)
id: Mapped[str] = mapped_column(String(63), primary_key=True)
display_name: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(String(20), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str:
return f"<Tenant {self.id} kind={self.kind} status={self.status}>"
class TenantConsumer(Base):
"""A grant: consumer `consumer` may act in tenant `tenant_id`."""
__tablename__ = "tenant_consumers"
tenant_id: Mapped[str] = mapped_column(
String(63), ForeignKey("tenants.id"), primary_key=True
)
consumer: Mapped[str] = mapped_column(String(63), primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str:
return f"<TenantConsumer {self.tenant_id}/{self.consumer}>"
+30
View File
@@ -0,0 +1,30 @@
"""
Tenants Domain Schemas
Pydantic request/response payloads for the tenant registry (core-api T-8 /
workspace D-73). No write routes this round — provisioning is later
(workspace T-195).
"""
from typing import List
from src.shared.base import BaseSchema
class TenantSummary(BaseSchema):
"""The shape `GET /tenants` answers with, per tenant."""
id: str
display_name: str
kind: str
status: str
class TenantMeResponse(TenantSummary):
"""`GET /tenants/me` — the caller's own tenant plus the consumers
granted in it, the cheap "am I allowed here" probe (D-73)."""
consumers: List[str]
class TenantListResponse(BaseSchema):
tenants: List[TenantSummary]
+126
View File
@@ -0,0 +1,126 @@
"""
Tenants Domain Service
The tenant registry (core-api T-8 / workspace D-73): `require_tenant` is
the one enforcement point Book's and Scribe's own `_require_tenant` now
delegate to, rather than each checking only presence (core-api T-1/T-5's
original shape, which let a typo'd claim open a fresh, empty namespace
instead of being refused).
It takes the session, so it is one query: a LEFT JOIN against the grant
table, so presence, status and (for an Office caller) the grant are all
read in a single round trip. When `caller.consumer` is `None` (tatlock,
scheduler, or a user-kind caller — core-api T-6 carries no consumer for
those), the join condition `TenantConsumer.consumer == None` compiles to
`IS NULL`, which never matches a grant row (consumer is NOT NULL there),
so the grant side of the row is always empty for them — correct, since the
grant check below is skipped outright when `caller.consumer` is `None`.
"""
from typing import List, Optional, Tuple
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domains.auth.callers import Caller
from src.domains.tenants.models import Tenant, TenantConsumer
from src.shared.logging import get_logger
logger = get_logger(__name__)
class TenantError(Exception):
"""Raised for a tenant-registry failure; the controller maps it to a response."""
def __init__(self, code: str, status_code: int = 400):
self.code = code
self.status_code = status_code
super().__init__(code)
class TenantsService:
"""The tenant registry's one enforcement point, and its read API."""
async def require_tenant(self, session: AsyncSession, caller: Caller) -> str:
"""
The one enforcement point (core-api T-8 / workspace D-73):
- no tenant on the caller -> 400 `tenant_required` (unchanged)
- tenant not in the table -> 403 `tenant_unknown`
- tenant `suspended` -> 403 `tenant_suspended`
- an Office caller (carries a consumer) whose consumer holds no
grant in that tenant -> 403 `consumer_not_granted`
- a user-kind caller carries no consumer (core-api T-6, not this
round): tenant checks only.
Returns the tenant id on success.
"""
if not caller.tenant:
raise TenantError("tenant_required", 400)
tenant = caller.tenant
stmt = (
select(Tenant.status, TenantConsumer.consumer)
.outerjoin(
TenantConsumer,
and_(
TenantConsumer.tenant_id == Tenant.id,
TenantConsumer.consumer == caller.consumer,
),
)
.where(Tenant.id == tenant)
)
row = (await session.execute(stmt)).first()
if row is None:
raise TenantError("tenant_unknown", 403)
status, granted_consumer = row
if status == "suspended":
raise TenantError("tenant_suspended", 403)
if caller.consumer is not None and granted_consumer is None:
raise TenantError("consumer_not_granted", 403)
return tenant
async def get_me(self, session: AsyncSession, caller: Caller) -> Tuple[Tenant, List[str]]:
"""`GET /tenants/me` — through the same helper, so "am I allowed
here" shares its answer with every other tenant-scoped call."""
tenant_id = await self.require_tenant(session, caller)
tenant = await session.get(Tenant, tenant_id)
consumers_stmt = (
select(TenantConsumer.consumer)
.where(TenantConsumer.tenant_id == tenant_id)
.order_by(TenantConsumer.consumer)
)
consumers = list((await session.execute(consumers_stmt)).scalars().all())
return tenant, consumers
async def list_tenants(self, session: AsyncSession, caller: Caller) -> List[Tenant]:
"""
`GET /tenants` — `tatlock` and `scheduler` callers (no tenant) see
every tenant; a tenant-bearing caller sees only its own, as a list
of one (the hard boundary: another tenant's existence is never
revealed, so this still runs through `require_tenant`); a user
caller with no tenant is 400 `tenant_required`.
"""
if caller.tenant is None:
if caller.kind in ("tatlock", "scheduler"):
stmt = select(Tenant).order_by(Tenant.id)
return list((await session.execute(stmt)).scalars().all())
raise TenantError("tenant_required", 400)
tenant_id = await self.require_tenant(session, caller)
tenant = await session.get(Tenant, tenant_id)
return [tenant]
# Singleton instance
_tenants_service: Optional[TenantsService] = None
def get_tenants_service() -> TenantsService:
"""Get singleton Tenants service instance"""
global _tenants_service
if _tenants_service is None:
_tenants_service = TenantsService()
return _tenants_service
+2
View File
@@ -21,6 +21,7 @@ from src.domains.static import static_controller
from src.domains.dashboard import dashboard_controller
from src.domains.book import book_controller
from src.domains.scribe import scribe_controller
from src.domains.tenants import tenants_controller
# Initialize settings
settings = get_settings()
@@ -110,6 +111,7 @@ app.include_router(static_controller.router) # /static/*
app.include_router(dashboard_controller.router) # /dashboard/*
app.include_router(book_controller.router) # /book/*
app.include_router(scribe_controller.router) # /scribe/*
app.include_router(tenants_controller.router) # /tenants/*
# Global exception handler
+126 -3
View File
@@ -18,6 +18,7 @@ environment and a clean cache; overriding `POSTGRES_HOST`/`USER`/
`PASSWORD`/`DATABASE` in that child's environment, derived from
`TEST_DATABASE_URL`, is what actually redirects the migration.
"""
import asyncio
import os
import subprocess
from pathlib import Path
@@ -76,12 +77,80 @@ def test_database_url() -> str:
return url
def _grant_test_office_gemini(url: str) -> None:
"""
Grant a second, test-environment-only consumer, `office-gemini`, in
both seeded tenants: test_book.py and test_scribe.py have used
`office-gemini` as a stand-in second Office consumer since before
core-api T-8's grant check existed, to prove behaviour that depends on
two *distinct* consumers (address ownership across consumers,
`book_events.client` recording the actual caller) rather than on any
one consumer being granted. Migration 007 itself seeds only
`office-claude` — the only consumer actually configured in Authentik
today (T-8's dossier) — so this grant is inserted directly here,
test-only, rather than through the migration; production's seed stays
exactly what the dossier specifies. A consumer this never grants
(e.g. `office-ungranted` in a test) is what proves the
`consumer_not_granted` refusal — this must never widen to cover one of
those.
A plain function, not a fixture: called from each of `book_schema`,
`scribe_schema` and `tenant_registry` right after their own `run_alembic
upgrade head`, so the grant is restored whenever any one of them is the
first to re-establish the schema in a session — the same
self-healing-on-first-use property those fixtures already rely on to
survive a migration test downgrading the schema mid-suite. A session-
scoped fixture shared between them would cache the *string* across that
downgrade while the real schema moved on underneath it — which is what
a first version of this did, and `tests/test_scribe.py` failing with
"relation tenants does not exist" is what caught it.
"""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
async def _grant() -> None:
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO tenant_consumers (tenant_id, consumer) VALUES "
"('jpmschweitzer', 'office-gemini'), ('llm_tester', 'office-gemini') "
"ON CONFLICT DO NOTHING"
)
)
finally:
await engine.dispose()
asyncio.run(_grant())
@pytest.fixture(scope="session")
def book_schema(test_database_url: str) -> str:
"""Ensure migration 005 (and everything before it) is applied to core_api_test."""
def tenant_registry(test_database_url: str) -> str:
"""Ensure migration 007 (and everything before it) is applied to
core_api_test, with its seed tenants/grants plus the test-only
office-gemini grant (see `_grant_test_office_gemini`). Used directly by
tests/test_tenants.py; book_schema and scribe_schema do the same thing
independently rather than depending on this, so each keeps its own
self-healing-on-first-use property (see that function's docstring)."""
result = run_alembic(test_database_url, "upgrade", "head")
if result.returncode != 0:
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
_grant_test_office_gemini(test_database_url)
return test_database_url
@pytest.fixture(scope="session")
def book_schema(test_database_url: str) -> str:
"""Ensure migration 005 (and everything before it, now including the
tenant registry) is applied to core_api_test."""
result = run_alembic(test_database_url, "upgrade", "head")
if result.returncode != 0:
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
_grant_test_office_gemini(test_database_url)
return test_database_url
@@ -125,10 +194,12 @@ async def clean_book_tables(book_session):
@pytest.fixture(scope="session")
def scribe_schema(test_database_url: str) -> str:
"""Ensure migration 006 (and everything before it) is applied to core_api_test."""
"""Ensure migration 006 (and everything before it, now including the
tenant registry) is applied to core_api_test."""
result = run_alembic(test_database_url, "upgrade", "head")
if result.returncode != 0:
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
_grant_test_office_gemini(test_database_url)
return test_database_url
@@ -164,3 +235,55 @@ async def clean_scribe_tables(scribe_session):
)
await scribe_session.commit()
yield scribe_session
@pytest.fixture
async def suspended_tenant(test_database_url: str):
"""
A third tenant, inserted with `status='suspended'`, for proving the
`tenant_suspended` refusal (core-api T-8) without touching either
seeded tenant that the rest of the suite depends on being active.
Nothing else ever references this id, so inserting and deleting it
per test is safe — no FK points at it.
Depends on `test_database_url` and runs its own `alembic upgrade head`
rather than on `tenant_registry`/`book_schema`/`scribe_schema`: this
fixture is reused from test_book.py, test_scribe.py and test_tenants.py,
so whichever of those files runs first would permanently cache
*any* session-scoped schema fixture it depended on — and a migration
test downgrading the schema afterwards (test_book_migration.py,
test_scribe_migration.py) would leave that cache stale for the other
two files. Self-contained and function-scoped avoids it outright, at
the cost of a redundant `alembic upgrade head` call (cheap relative to
the many such calls the migration test files already make).
"""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
result = run_alembic(test_database_url, "upgrade", "head")
if result.returncode != 0:
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
tenant_id = "suspended-tenant"
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
async with session_factory() as session:
await session.execute(
text(
"INSERT INTO tenants (id, display_name, kind, status) "
"VALUES (:id, 'Suspended Test Tenant', 'test', 'suspended') "
"ON CONFLICT (id) DO UPDATE SET status = 'suspended'"
),
{"id": tenant_id},
)
await session.commit()
yield tenant_id
async with session_factory() as session:
await session.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id})
await session.commit()
await engine.dispose()
+34
View File
@@ -721,3 +721,37 @@ class TestUserCallerTenantRequired:
assert response.status_code == 201
assert response.json()["created_by"] == "jeroen"
assert response.json()["number"] == 1
class TestTenantRegistryEnforcement:
"""core-api T-8 / workspace D-73: Book's own `_require_tenant` now
delegates to the tenant registry's one enforcement point, so a Book
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(
"/book/records", 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(
"/book/records", 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(
"/book/records", headers=office_headers(consumer="office-ungranted")
)
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("/book/records", headers=office_headers())
assert response.status_code == 200
+39
View File
@@ -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
+205
View File
@@ -0,0 +1,205 @@
"""
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"
+188
View File
@@ -0,0 +1,188 @@
"""
Tests for the tenant registry migration 007 (core-api T-8, workspace D-73).
Runs against `core_api_test` only (see conftest.py — skip when
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
Self-contained: starts and ends at head regardless of what ran before it,
so test order never matters for the schema the rest of the suite needs.
"""
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from tests.conftest import run_alembic
async def _table_exists(url: str, table: str) -> bool:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT to_regclass(:name) IS NOT NULL AS present"),
{"name": table},
)
return bool(result.scalar())
finally:
await engine.dispose()
async def _scalar(url: str, query: str, **params) -> object:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
result = await conn.execute(text(query), params)
return result.scalar()
finally:
await engine.dispose()
class TestMigration007RoundTrip:
"""Migration 007 must upgrade and downgrade cleanly (dossier verification #2)."""
@pytest.mark.asyncio
async def test_upgrade_creates_tenant_tables(self, test_database_url):
result = run_alembic(test_database_url, "upgrade", "head")
assert result.returncode == 0, result.stderr
for table in ("tenants", "tenant_consumers"):
assert await _table_exists(test_database_url, table), f"{table} missing after upgrade"
@pytest.mark.asyncio
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
baseline = run_alembic(test_database_url, "upgrade", "head")
assert baseline.returncode == 0, baseline.stderr
down = run_alembic(test_database_url, "downgrade", "006")
assert down.returncode == 0, down.stderr
assert not await _table_exists(test_database_url, "tenants")
assert not await _table_exists(test_database_url, "tenant_consumers")
# Book and Scribe tables, created by migrations below this one, must
# survive a downgrade of 007 — proves 007 only touches its own
# tables (and the FKs it added onto theirs).
assert await _table_exists(test_database_url, "book_records")
assert await _table_exists(test_database_url, "scribe_addresses")
# Leave the schema at head for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "007")
assert up.returncode == 0, up.stderr
assert await _table_exists(test_database_url, "tenants")
assert await _table_exists(test_database_url, "tenant_consumers")
class TestMigration007Checks:
"""The CHECK constraints on `tenants.kind` and `tenants.status`."""
@pytest.mark.asyncio
async def test_kind_check_constraint_exists(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
found = await _scalar(
test_database_url,
"SELECT conname FROM pg_constraint WHERE conname = 'ck_tenants_kind'",
)
assert found == "ck_tenants_kind"
@pytest.mark.asyncio
async def test_status_check_constraint_exists(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
found = await _scalar(
test_database_url,
"SELECT conname FROM pg_constraint WHERE conname = 'ck_tenants_status'",
)
assert found == "ck_tenants_status"
@pytest.mark.asyncio
async def test_kind_check_rejects_an_invalid_kind(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
with pytest.raises(IntegrityError):
await conn.execute(
text(
"INSERT INTO tenants (id, display_name, kind, status) "
"VALUES ('bad-kind-tenant', 'x', 'nonsense', 'active')"
)
)
await conn.commit()
finally:
await engine.dispose()
class TestMigration007Seed:
"""core-api T-8: the registry seeds exactly the tenants/grants the
dossier specifies — not a third, and not a second grant."""
@pytest.mark.asyncio
async def test_seeds_jpmschweitzer_and_llm_tester(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
for tenant_id, display_name, kind in (
("jpmschweitzer", "Jeroen", "household"),
("llm_tester", "Playground", "test"),
):
row_kind = await _scalar(
test_database_url,
"SELECT kind FROM tenants WHERE id = :id AND display_name = :name AND status = 'active'",
id=tenant_id,
name=display_name,
)
assert row_kind == kind, f"{tenant_id} seed row missing or wrong"
@pytest.mark.asyncio
async def test_both_tenants_grant_only_office_claude(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
for tenant_id in ("jpmschweitzer", "llm_tester"):
found = await _scalar(
test_database_url,
"SELECT consumer FROM tenant_consumers WHERE tenant_id = :id AND consumer = 'office-claude'",
id=tenant_id,
)
assert found == "office-claude", f"{tenant_id} is missing its office-claude grant"
class TestMigration007ForeignKeys:
"""Every owner_tenant column (five of them, per the dossier) now carries
an FK to tenants.id; an unknown owner_tenant is refused at the database,
not only in the service layer (dossier verification #5)."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"fk_name",
[
"fk_book_records_owner_tenant",
"fk_book_tenant_counters_owner_tenant",
"fk_scribe_addresses_owner_tenant",
"fk_scribe_messages_owner_tenant",
"fk_scribe_mailbox_entries_owner_tenant",
],
)
async def test_owner_tenant_fk_exists(self, test_database_url, fk_name):
run_alembic(test_database_url, "upgrade", "head")
found = await _scalar(
test_database_url,
"SELECT conname FROM pg_constraint WHERE conname = :name AND contype = 'f'",
name=fk_name,
)
assert found == fk_name
@pytest.mark.asyncio
async def test_fk_refuses_a_scribe_address_with_an_unknown_owner_tenant(self, test_database_url):
run_alembic(test_database_url, "upgrade", "head")
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
with pytest.raises(IntegrityError):
await conn.execute(
text(
"INSERT INTO scribe_addresses (owner_tenant, name, created_by) "
"VALUES ('no-such-tenant', 'fk-probe', 'test-harness')"
)
)
await conn.commit()
finally:
await engine.dispose()