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>
146 lines
5.8 KiB
Python
146 lines
5.8 KiB
Python
"""
|
|
Scribe Domain Models
|
|
|
|
The Scribe is the household's message bus (workspace D-66, D-68, D-73): a
|
|
retained pub/sub log where an address is a topic. This round ships the
|
|
primitives migration 006 creates — addresses, messages, mailbox entries —
|
|
for direct messaging only; groups, subscriptions, the notification matrix
|
|
and delivery are later work (workspace T-176).
|
|
|
|
SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
|
src/domains/book/models.py. Every table carries `owner_tenant` and every
|
|
query in the service layer filters on it (D-73's hard tenant boundary) —
|
|
there is no database-level RLS here, so that filter is load-bearing in
|
|
Python, not just in SQL.
|
|
"""
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
|
|
from sqlalchemy import (
|
|
Computed,
|
|
DateTime,
|
|
ForeignKey,
|
|
Identity,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from src.shared.database import Base
|
|
|
|
# Address name shape, identical to src/domains/auth/callers.py's identity
|
|
# rule (an address is named for an identity, D-66) — not imported from there
|
|
# because that module's constants are private to it; kept here so a drift
|
|
# between the two never happens silently, the regex is asserted equal in
|
|
# tests/test_scribe.py instead.
|
|
ADDRESS_NAME_PATTERN = r"^[a-z0-9][a-z0-9-]{0,62}$"
|
|
RESERVED_ADDRESS_NAMES = {"book", "office", "scheduler", "tatlock"}
|
|
|
|
|
|
class ScribeAddress(Base):
|
|
"""
|
|
An address: a topic named for an identity. `register` creates it once
|
|
(D-66) — the pair (owner_tenant, name) is the natural key, so no
|
|
surrogate id: no route ever references an address except by name.
|
|
"""
|
|
|
|
__tablename__ = "scribe_addresses"
|
|
__table_args__ = (
|
|
UniqueConstraint("owner_tenant", "name", name="uq_scribe_addresses_tenant_name"),
|
|
)
|
|
|
|
# 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
|
|
# registers has none either.
|
|
owner_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
|
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<ScribeAddress {self.owner_tenant}/{self.name}>"
|
|
|
|
|
|
class ScribeMessage(Base):
|
|
"""
|
|
A retained message. `topics` is the set of addresses (and, later,
|
|
groups) it was published to — an integer array is denormalized from
|
|
the mailbox entries so `history` can read the retained log directly
|
|
without joining through them (D-66: "topics are retained and
|
|
re-readable"). Nothing deletes a message.
|
|
"""
|
|
|
|
__tablename__ = "scribe_messages"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, Identity(always=False), 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"), 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)
|
|
subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
|
reply_to: Mapped[Optional[int]] = mapped_column(
|
|
Integer, ForeignKey("scribe_messages.id"), nullable=True
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
# Generated by migration 006 (`to_tsvector('simple', ...)`, GIN-indexed),
|
|
# 'simple' for the same reason as book_records.search: mixed Dutch/
|
|
# English content. `Computed` mirrors the migration's SQL.
|
|
search: Mapped[Optional[str]] = mapped_column(
|
|
TSVECTOR,
|
|
Computed("to_tsvector('simple', coalesce(subject, '') || ' ' || body)", persisted=True),
|
|
nullable=True,
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<ScribeMessage {self.id} from={self.from_identity}>"
|
|
|
|
|
|
class ScribeMailboxEntry(Base):
|
|
"""One row per recipient address per message — what lands in a mailbox."""
|
|
|
|
__tablename__ = "scribe_mailbox_entries"
|
|
__table_args__ = (
|
|
UniqueConstraint("address", "message_id", name="uq_scribe_mailbox_entries_address_message"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True)
|
|
address: 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
|
|
)
|
|
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<ScribeMailboxEntry {self.id} address={self.address} message={self.message_id}>"
|