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>
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""
|
|
Book Domain Models
|
|
|
|
The Book holds Notes, Tasks, Decisions, Questions and Rejected options for
|
|
the Office (workspace D-68, D-69, D-70). SQLAlchemy 2.0 Mapped/mapped_column
|
|
style, timestamptz, following src/domains/auth/models.py.
|
|
|
|
Table prefix is `book_` (D-68). `book_events` is append-only — migration 005
|
|
adds a trigger that raises on UPDATE and DELETE, enforced at the database,
|
|
not here.
|
|
"""
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Computed,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from src.shared.database import Base
|
|
|
|
# Kinds and their valid statuses. Enforced in the service layer (per kind),
|
|
# not as a database CHECK — the valid status set depends on the kind, which
|
|
# a single-column CHECK cannot express without a trigger.
|
|
RECORD_KINDS = ("note", "task", "decision", "question", "rejected")
|
|
|
|
STATUSES_BY_KIND = {
|
|
"note": {"active"},
|
|
"task": {"inbox", "open", "waiting", "someday", "done", "dropped"},
|
|
"decision": {"proposed", "accepted", "superseded"},
|
|
"question": {"open", "settled", "dropped"},
|
|
"rejected": {"rejected"},
|
|
}
|
|
|
|
DEFAULT_STATUS_BY_KIND = {
|
|
"note": "active",
|
|
"task": "inbox",
|
|
"decision": "proposed",
|
|
"question": "open",
|
|
"rejected": "rejected",
|
|
}
|
|
|
|
SCOPES = ("work", "personal")
|
|
DEFAULT_SCOPE = "personal"
|
|
|
|
LINK_RELATIONS = ("parent", "blocks", "resolves", "supersedes", "related")
|
|
|
|
|
|
class BookRecord(Base):
|
|
"""A Note, Task, Decision, Question or Rejected option."""
|
|
|
|
__tablename__ = "book_records"
|
|
__table_args__ = (
|
|
UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
|
UniqueConstraint("owner_tenant", "number", name="uq_book_records_tenant_number"),
|
|
Index("ix_book_records_tenant_kind_status", "owner_tenant", "kind", "status"),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
)
|
|
# 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. 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
|
|
# FOR UPDATE in the same transaction as this row's insert.
|
|
number: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
|
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
checklist: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
|
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
|
scope: Mapped[str] = mapped_column(String(10), nullable=False, default=DEFAULT_SCOPE)
|
|
priority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
energy: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
|
estimate_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
next_step: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
start_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
|
all_day: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
remind_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
recurrence: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
recurrence_mode: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
|
remind_to: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
|
last_touched_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
trashed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
paused_statuses: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
|
source: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
|
external_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
colour: Mapped[Optional[str]] = mapped_column(String(20), 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,
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
nullable=False,
|
|
)
|
|
# Generated by migration 005 (`to_tsvector('simple', ...)`, GIN-indexed).
|
|
# 'simple' because the household writes mixed Dutch and English — a
|
|
# language-specific config would stem one of the two badly. `Computed`
|
|
# mirrors the migration's SQL so SQLAlchemy knows this column is
|
|
# database-generated and excludes it from INSERT/UPDATE — without it,
|
|
# the ORM tries to write NULL into a GENERATED ALWAYS column and
|
|
# Postgres rejects the insert outright.
|
|
search: Mapped[Optional[str]] = mapped_column(
|
|
TSVECTOR,
|
|
Computed("to_tsvector('simple', coalesce(title, '') || ' ' || body)", persisted=True),
|
|
nullable=True,
|
|
)
|
|
|
|
# Deliberately no `tags` relationship here. The service always queries
|
|
# book_tags explicitly (`BookService._tags_for`) rather than traversing
|
|
# an ORM relationship — partly because async SQLAlchemy can't lazy-load
|
|
# on an unloaded attribute without a greenlet error, but mainly because
|
|
# a relationship named `tags` collides with the response schema's
|
|
# `tags: List[str]` field: `BookRecordResponse.model_validate(record,
|
|
# from_attributes=True)` would read the relationship (a list of BookTag
|
|
# ORM objects) instead of the list of strings the controller builds
|
|
# separately, and fail validation. Cascade delete is handled by the
|
|
# column's own `ondelete="CASCADE"`, so the relationship bought nothing.
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BookRecord {self.kind} {self.id}>"
|
|
|
|
|
|
class BookTag(Base):
|
|
"""A free tag on a record. `work`/`personal` live on `scope`, not here."""
|
|
|
|
__tablename__ = "book_tags"
|
|
|
|
record_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
tag: Mapped[str] = mapped_column(String(63), primary_key=True)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BookTag {self.tag} on {self.record_id}>"
|
|
|
|
|
|
class BookRecordLink(Base):
|
|
"""A directed relation between two records."""
|
|
|
|
__tablename__ = "book_record_links"
|
|
|
|
from_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
relation: Mapped[str] = mapped_column(String(20), primary_key=True)
|
|
to_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
primary_key=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"<BookRecordLink {self.from_id} {self.relation} {self.to_id}>"
|
|
|
|
|
|
class BookAttachment(Base):
|
|
"""A file attached to a record. Table only — no file handling yet."""
|
|
|
|
__tablename__ = "book_attachments"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
)
|
|
record_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
path: Mapped[str] = mapped_column(String(1000), nullable=False)
|
|
mime: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BookAttachment {self.path} on {self.record_id}>"
|
|
|
|
|
|
class BookEvent(Base):
|
|
"""
|
|
An append-only audit row for every change to a record.
|
|
|
|
Migration 005 adds a trigger that raises on UPDATE and DELETE against
|
|
this table — enforced for every role, the owning application included.
|
|
`redact` (D-70 rule 4) is the one sanctioned exception, and it is a
|
|
later round; nothing here weakens the trigger.
|
|
"""
|
|
|
|
__tablename__ = "book_events"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
)
|
|
record_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
server_default=func.now(),
|
|
nullable=False,
|
|
)
|
|
identity: Mapped[str] = mapped_column(String(63), nullable=False)
|
|
client: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
|
action: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
diff: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BookEvent {self.action} on {self.record_id} by {self.identity}>"
|
|
|
|
|
|
class BookTenantCounter(Base):
|
|
"""
|
|
One row per tenant: the next `number` to allocate on `book_records`.
|
|
|
|
`BookService._allocate_number` upserts this row (`INSERT ... ON
|
|
CONFLICT DO NOTHING`, so a tenant's first `add` never races its own
|
|
counter row into existence) and then locks it `FOR UPDATE` in the same
|
|
transaction as the record insert it numbers — a second concurrent `add`
|
|
in the same tenant blocks on that lock until the first commits, so two
|
|
concurrent creates never read the same `next_number`. A different
|
|
tenant's counter row is untouched, so tenants never contend with each
|
|
other.
|
|
"""
|
|
|
|
__tablename__ = "book_tenant_counters"
|
|
|
|
# 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:
|
|
return f"<BookTenantCounter {self.owner_tenant} next={self.next_number}>"
|