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
+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