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>
189 lines
7.8 KiB
Python
189 lines
7.8 KiB
Python
"""
|
|
Tests for the tenant registry migration 007 (core-api T-8, workspace D-73).
|
|
|
|
Runs against `core_api_test` only (see conftest.py — skip when
|
|
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
|
Self-contained: starts and ends at head regardless of what ran before it,
|
|
so test order never matters for the schema the rest of the suite needs.
|
|
"""
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from tests.conftest import run_alembic
|
|
|
|
|
|
async def _table_exists(url: str, table: str) -> bool:
|
|
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
try:
|
|
async with engine.connect() as conn:
|
|
result = await conn.execute(
|
|
text("SELECT to_regclass(:name) IS NOT NULL AS present"),
|
|
{"name": table},
|
|
)
|
|
return bool(result.scalar())
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def _scalar(url: str, query: str, **params) -> object:
|
|
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
try:
|
|
async with engine.connect() as conn:
|
|
result = await conn.execute(text(query), params)
|
|
return result.scalar()
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
class TestMigration007RoundTrip:
|
|
"""Migration 007 must upgrade and downgrade cleanly (dossier verification #2)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upgrade_creates_tenant_tables(self, test_database_url):
|
|
result = run_alembic(test_database_url, "upgrade", "head")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
for table in ("tenants", "tenant_consumers"):
|
|
assert await _table_exists(test_database_url, table), f"{table} missing after upgrade"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
|
|
baseline = run_alembic(test_database_url, "upgrade", "head")
|
|
assert baseline.returncode == 0, baseline.stderr
|
|
|
|
down = run_alembic(test_database_url, "downgrade", "006")
|
|
assert down.returncode == 0, down.stderr
|
|
assert not await _table_exists(test_database_url, "tenants")
|
|
assert not await _table_exists(test_database_url, "tenant_consumers")
|
|
# Book and Scribe tables, created by migrations below this one, must
|
|
# survive a downgrade of 007 — proves 007 only touches its own
|
|
# tables (and the FKs it added onto theirs).
|
|
assert await _table_exists(test_database_url, "book_records")
|
|
assert await _table_exists(test_database_url, "scribe_addresses")
|
|
|
|
# Leave the schema at head for the rest of the suite.
|
|
up = run_alembic(test_database_url, "upgrade", "007")
|
|
assert up.returncode == 0, up.stderr
|
|
assert await _table_exists(test_database_url, "tenants")
|
|
assert await _table_exists(test_database_url, "tenant_consumers")
|
|
|
|
|
|
class TestMigration007Checks:
|
|
"""The CHECK constraints on `tenants.kind` and `tenants.status`."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kind_check_constraint_exists(self, test_database_url):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
found = await _scalar(
|
|
test_database_url,
|
|
"SELECT conname FROM pg_constraint WHERE conname = 'ck_tenants_kind'",
|
|
)
|
|
assert found == "ck_tenants_kind"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_check_constraint_exists(self, test_database_url):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
found = await _scalar(
|
|
test_database_url,
|
|
"SELECT conname FROM pg_constraint WHERE conname = 'ck_tenants_status'",
|
|
)
|
|
assert found == "ck_tenants_status"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kind_check_rejects_an_invalid_kind(self, test_database_url):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
try:
|
|
async with engine.connect() as conn:
|
|
with pytest.raises(IntegrityError):
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO tenants (id, display_name, kind, status) "
|
|
"VALUES ('bad-kind-tenant', 'x', 'nonsense', 'active')"
|
|
)
|
|
)
|
|
await conn.commit()
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
class TestMigration007Seed:
|
|
"""core-api T-8: the registry seeds exactly the tenants/grants the
|
|
dossier specifies — not a third, and not a second grant."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seeds_jpmschweitzer_and_llm_tester(self, test_database_url):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
for tenant_id, display_name, kind in (
|
|
("jpmschweitzer", "Jeroen", "household"),
|
|
("llm_tester", "Playground", "test"),
|
|
):
|
|
row_kind = await _scalar(
|
|
test_database_url,
|
|
"SELECT kind FROM tenants WHERE id = :id AND display_name = :name AND status = 'active'",
|
|
id=tenant_id,
|
|
name=display_name,
|
|
)
|
|
assert row_kind == kind, f"{tenant_id} seed row missing or wrong"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_both_tenants_grant_only_office_claude(self, test_database_url):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
for tenant_id in ("jpmschweitzer", "llm_tester"):
|
|
found = await _scalar(
|
|
test_database_url,
|
|
"SELECT consumer FROM tenant_consumers WHERE tenant_id = :id AND consumer = 'office-claude'",
|
|
id=tenant_id,
|
|
)
|
|
assert found == "office-claude", f"{tenant_id} is missing its office-claude grant"
|
|
|
|
|
|
class TestMigration007ForeignKeys:
|
|
"""Every owner_tenant column (five of them, per the dossier) now carries
|
|
an FK to tenants.id; an unknown owner_tenant is refused at the database,
|
|
not only in the service layer (dossier verification #5)."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"fk_name",
|
|
[
|
|
"fk_book_records_owner_tenant",
|
|
"fk_book_tenant_counters_owner_tenant",
|
|
"fk_scribe_addresses_owner_tenant",
|
|
"fk_scribe_messages_owner_tenant",
|
|
"fk_scribe_mailbox_entries_owner_tenant",
|
|
],
|
|
)
|
|
async def test_owner_tenant_fk_exists(self, test_database_url, fk_name):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
found = await _scalar(
|
|
test_database_url,
|
|
"SELECT conname FROM pg_constraint WHERE conname = :name AND contype = 'f'",
|
|
name=fk_name,
|
|
)
|
|
assert found == fk_name
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fk_refuses_a_scribe_address_with_an_unknown_owner_tenant(self, test_database_url):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
try:
|
|
async with engine.connect() as conn:
|
|
with pytest.raises(IntegrityError):
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO scribe_addresses (owner_tenant, name, created_by) "
|
|
"VALUES ('no-such-tenant', 'fk-probe', 'test-harness')"
|
|
)
|
|
)
|
|
await conn.commit()
|
|
finally:
|
|
await engine.dispose()
|