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