Files
core-api/src/domains/tenants/controller.py
T
jpmschweitzerandClaude Sonnet 5 80034bbacf feat(tenants): add a default address on each consumer grant (T-11)
claude.ai's connector keeps no MCP session between tool calls, so the
Office cannot remember which address a conversation chose. Each consumer
grant now carries a fallback: the Office uses it when a call names none
and has no session binding (tatlock-office T-5, in a parallel round,
reads it).

Migration 008 (down_revision="007") adds
tenant_consumers.default_address — nullable varchar(63) — and a
composite foreign key (tenant_id, default_address) ->
scribe_addresses(owner_tenant, name), so a default always names a real
address in the same tenant. scribe_addresses already carries both a
primary key and a named unique constraint on (owner_tenant, name)
(migration 006), so the composite FK has something to bind to; Postgres's
default MATCH SIMPLE means a NULL default_address never triggers the
check, which is the state every grant but one is left in. The data step
sets jpmschweitzer/office-claude -> claude-ai only if that address exists
at upgrade time (true in production, false in core_api_test, where no
scribe_addresses are seeded) — an UPDATE ... WHERE EXISTS, not a bare
UPDATE, so it is a no-op rather than a failure when absent.
llm_tester/office-claude is untouched and stays null. Downgrade drops the
FK then the column.

TenantConsumer (models.py) gains the column and a matching
ForeignKeyConstraint. TenantsService.get_me now returns a third element,
the caller's own consumer's default_address (looked up only when
caller.consumer is not None — a user-kind caller carries no consumer, so
there is no grant row to hold a default for it) or None. GET /tenants/me
answers it; GET /tenants and its TenantSummary schema are unchanged.

Tests: TestMigration008RoundTrip/ForeignKey/DataStep added to
tests/test_tenants_migration.py (round-trip 007<->008, the FK existing
and refusing an unknown address while allowing null, the data step's
no-op and positive cases). tests/conftest.py gains a
default_address_grant fixture (registers a throwaway address, points a
grant at it, reverses both on teardown). tests/test_tenants.py gains
TestMeDefaultAddress (an office caller with/without a default, a user
caller always null).

Friction found and fixed in the same commit: the new composite FK made
Postgres refuse tests/test_scribe.py's existing clean_scribe_tables
TRUNCATE ("cannot truncate a table referenced in a foreign key
constraint") — the restriction is on the constraint's existence, not on
whether any row's default_address is actually non-null. tenant_consumers
joins that TRUNCATE; CASCADE was rejected because it would silently drop
every grant rather than only addresses, so the four grants
(jpmschweitzer/llm_tester x office-claude/office-gemini) are reinserted
in the same fixture, right after.

Mutation-checked: forcing default_address to null in the controller
fails exactly test_office_caller_with_a_default_sees_it, leaving the
without-a-default and user-caller tests green. Disabling the FK's
creation (and, symmetrically, its drop in downgrade — to keep the
round-trip self-consistent against the live core_api_test schema) fails
test_fk_exists and test_fk_refuses_a_default_naming_an_address_that_does_not_exist,
leaving test_fk_allows_a_null_default_regardless_of_tenant green. Both
mutations reverted; the full suite re-verified green (543 + 11 = 554
passed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 22:05:46 +02:00

94 lines
3.3 KiB
Python

"""
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, default_address = 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,
default_address=default_address,
)
# =====================================================================
# 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()