Implement PostgreSQL production store path
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s

Add the PostgreSQL backend, migration and stopped-write transfer tools, lease-aware deployment manifests, tenancy declarations, and shared conformance coverage. Persist grouping mutations in durable stores and separate process liveness from database readiness.
This commit is contained in:
tegwick 2026-08-19 14:42:01 +02:00
parent 2063470ac8
commit 749461b97b
30 changed files with 2364 additions and 71 deletions

30
tests/postgres_backend.py Normal file
View file

@ -0,0 +1,30 @@
from __future__ import annotations
import os
from pathlib import Path
import pytest
from tenant_engine.postgres_store import PostgresTenantStore
def clean_postgres_store(tmp_path: Path) -> PostgresTenantStore:
dsn = os.getenv("TENANT_ENGINE_TEST_DATABASE_URL", "").strip()
if not dsn:
pytest.skip("set TENANT_ENGINE_TEST_DATABASE_URL to exercise PostgreSQL conformance")
try:
import psycopg
except ImportError:
pytest.skip("install tenant-engine[postgres] to exercise PostgreSQL conformance")
migration = Path(__file__).parents[1] / "migrations/postgres/0001_tenant_store.sql"
with psycopg.connect(dsn, autocommit=True) as connection:
connection.execute(migration.read_text(encoding="utf-8"))
connection.execute(
"""TRUNCATE guardrail_changes, guardrail_overrides,
idempotency_receipts, events, plans, grants, tenants
RESTART IDENTITY CASCADE"""
)
url_file = tmp_path / "postgres-url"
url_file.write_text(dsn, encoding="utf-8")
return PostgresTenantStore(str(url_file), min_pool_size=1, max_pool_size=4)

View file

@ -317,7 +317,7 @@ def test_a_cross_tenant_write_changes_nothing():
client = make_client(_TenantScopedAuthorizer("t-own"))
assert put(client).status_code == 403
# the store was never touched: the version is untouched
assert client.get("/tenants/t-1").json()["version"] == 1
assert client.app.state.store.get_tenant("t-1").version == 1
def test_a_store_outage_fails_closed_on_write():
@ -340,9 +340,9 @@ def test_errors_never_reflect_policy_internals():
def test_existing_endpoints_are_unaffected(client):
assert client.get("/tenants/t-1").status_code == 200
assert client.get("/tenants/t-1/roles").status_code == 200
assert client.get("/tenants/t-1/roles/live").status_code == 200
assert client.get("/tenants/t-1", params={"actor": "tenant-engine"}).status_code == 200
assert client.get("/tenants/t-1/roles", params={"actor": "tenant-engine"}).status_code == 200
assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).status_code == 200
assert client.get("/health").status_code == 200

View file

@ -68,7 +68,7 @@ def _patch(client, *, headers=None, metadata=None, **overrides):
def test_get_tenant_returns_record_and_etag(client) -> None:
response = client.get("/tenants/t-1")
response = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
assert response.status_code == 200
assert response.headers["ETag"] == '"1"'
@ -79,13 +79,15 @@ def test_get_tenant_returns_record_and_etag(client) -> None:
def test_get_tenant_resolves_by_identifier(client) -> None:
response = client.get("/tenants/tenant:friendly:binky")
response = client.get(
"/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"}
)
assert response.status_code == 200
assert response.json()["tenant_id"] == "t-1"
def test_get_unknown_tenant_is_404(client) -> None:
assert client.get("/tenants/nope").status_code == 404
assert client.get("/tenants/nope", params={"actor": "tenant-engine"}).status_code == 404
# -- update -------------------------------------------------------------
@ -98,7 +100,8 @@ def test_update_succeeds_and_advances_the_etag(client) -> None:
assert response.headers["ETag"] == '"2"'
assert response.headers["Idempotent-Replay"] == "false"
assert response.json()["display_name"] == "Binky Ltd"
assert client.get("/tenants/t-1").json()["display_name"] == "Binky Ltd"
persisted = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
assert persisted.json()["display_name"] == "Binky Ltd"
def test_update_rejects_unknown_field(client) -> None:
@ -110,7 +113,8 @@ def test_update_rejects_identifier_mutation(client) -> None:
response = _patch(client, metadata={"identifier": "tenant:large:other"})
assert response.status_code == 422
assert client.get("/tenants/t-1").json()["identifier"] == "tenant:friendly:binky"
persisted = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
assert persisted.json()["identifier"] == "tenant:friendly:binky"
def test_update_rejects_empty_metadata(client) -> None:
@ -138,7 +142,7 @@ def test_duplicate_idempotency_key_replays(client) -> None:
assert replay.status_code == 200
assert replay.headers["Idempotent-Replay"] == "true"
assert replay.json() == first.json()
assert client.get("/tenants/t-1").json()["version"] == 2
assert client.get("/tenants/t-1", params={"actor": "tenant-engine"}).json()["version"] == 2
def test_idempotency_key_reused_for_a_different_request_is_409(client) -> None:
@ -290,7 +294,7 @@ def test_update_permission_does_not_imply_retire_permission() -> None:
def test_store_outage_is_a_redacted_503() -> None:
client = TestClient(create_app(store=_BrokenStore(), authorizer=_AllowAllAuthorizer()))
read = client.get("/tenants/t-1")
read = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
write = client.patch(
"/tenants/t-1", headers=HEADERS, json={**BODY, "metadata": {"display_name": "X"}}
)

View file

@ -3,10 +3,20 @@ from datetime import UTC, datetime
from fastapi.testclient import TestClient
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizer
from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant
from tenant_engine.store import InMemoryTenantStore, TenantStore
class _AllowAll(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
def _client(store: TenantStore) -> TestClient:
return TestClient(create_app(store=store, authorizer=_AllowAll()))
class _BrokenStore:
"""Test double: every active_roles() call raises, simulating an outage."""
@ -57,8 +67,8 @@ def _seeded_store() -> InMemoryTenantStore:
def test_cache_read_roles_returns_active_roles() -> None:
client = TestClient(create_app(store=_seeded_store()))
response = client.get("/tenants/t-binky/roles")
client = _client(_seeded_store())
response = client.get("/tenants/t-binky/roles", params={"actor": "key-cape"})
assert response.status_code == 200
assert response.json() == {"tenant_id": "t-binky", "roles": ["CUS"]}
@ -70,23 +80,25 @@ def test_cache_read_roles_resolves_by_identifier_not_only_internal_id() -> None:
tenant's profile identifier (the IAM Profile `tenant` claim value), via
a URL path segment containing colons -- never the internal tenant_id.
"""
client = TestClient(create_app(store=_seeded_store()))
response = client.get("/tenants/tenant:friendly:binky/roles")
client = _client(_seeded_store())
response = client.get(
"/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"}
)
assert response.status_code == 200
assert response.json() == {"tenant_id": "tenant:friendly:binky", "roles": ["CUS"]}
def test_cache_read_roles_unknown_tenant_is_404() -> None:
client = TestClient(create_app(store=_seeded_store()))
response = client.get("/tenants/does-not-exist/roles")
client = _client(_seeded_store())
response = client.get("/tenants/does-not-exist/roles", params={"actor": "key-cape"})
assert response.status_code == 404
def test_live_lookup_roles_returns_active_roles() -> None:
client = TestClient(create_app(store=_seeded_store()))
response = client.get("/tenants/t-binky/roles/live")
client = _client(_seeded_store())
response = client.get("/tenants/t-binky/roles/live", params={"actor": "flex-auth"})
assert response.status_code == 200
assert response.json()["roles"] == ["CUS"]
@ -94,9 +106,9 @@ def test_live_lookup_roles_returns_active_roles() -> None:
def test_live_lookup_fails_closed_on_store_outage() -> None:
broken = _BrokenStore(_seeded_store())
client = TestClient(create_app(store=broken))
client = _client(broken)
response = client.get("/tenants/t-binky/roles/live")
response = client.get("/tenants/t-binky/roles/live", params={"actor": "flex-auth"})
assert response.status_code == 503
assert response.json() != {"tenant_id": "t-binky", "roles": []}, (
@ -106,8 +118,35 @@ def test_live_lookup_fails_closed_on_store_outage() -> None:
def test_cache_read_also_fails_closed_on_store_outage() -> None:
broken = _BrokenStore(_seeded_store())
client = TestClient(create_app(store=broken))
client = _client(broken)
response = client.get("/tenants/t-binky/roles")
response = client.get("/tenants/t-binky/roles", params={"actor": "key-cape"})
assert response.status_code == 503
def test_unauthorized_reads_are_denied_before_tenant_existence_is_observed() -> None:
class _MustNotRead:
def get_tenant(self, tenant_id):
raise AssertionError(f"store read leaked for {tenant_id}")
def active_roles(self, tenant_id):
raise AssertionError(f"role read leaked for {tenant_id}")
client = TestClient(create_app(store=_MustNotRead()))
for path in (
"/tenants/known?actor=unbound",
"/tenants/unknown?actor=unbound",
"/tenants/known/roles?actor=unbound",
"/tenants/unknown/roles/live?actor=unbound",
):
response = client.get(path)
assert response.status_code == 403
assert response.json()["error_code"] == "write_denied"
def test_read_requires_an_explicit_actor() -> None:
client = _client(_seeded_store())
assert client.get("/tenants/t-binky").status_code == 422
assert client.get("/tenants/t-binky/roles").status_code == 422
assert client.get("/tenants/t-binky/roles/live").status_code == 422

View file

@ -75,13 +75,13 @@ def test_full_write_lifecycle_succeeds_when_authorizer_allows() -> None:
)
assert granted.status_code == 201
roles = client.get("/tenants/t-1/roles")
roles = client.get("/tenants/t-1/roles", params={"actor": "tenant-engine"})
assert roles.json()["roles"] == ["CUS"]
revoked = client.post("/tenants/t-1/roles/revoke", json={"grant_id": "g-1", "actor": "ops"})
assert revoked.status_code == 200
roles_after = client.get("/tenants/t-1/roles")
roles_after = client.get("/tenants/t-1/roles", params={"actor": "tenant-engine"})
assert roles_after.json()["roles"] == []
plan = client.post("/tenants/t-1/plan", json={"plan_id": "plan-x", "actor": "ops"})

View file

@ -11,3 +11,16 @@ def test_health_endpoint() -> None:
body = response.json()
assert body["status"] == "ok"
assert body["service"] == "tenant-engine"
assert body["store_backend"] == "memory"
def test_liveness_endpoint_does_not_depend_on_the_store() -> None:
class _UnavailableStore:
def ping(self) -> None:
raise AssertionError("liveness must not touch the store")
client = TestClient(create_app(store=_UnavailableStore()))
response = client.get("/live")
assert response.status_code == 200
assert response.json()["status"] == "ok"

View file

@ -0,0 +1,33 @@
from dataclasses import replace
import pytest
from tenant_engine.config import Settings
from tenant_engine.main import _build_store
from tenant_engine.sqlite_store import SQLiteTenantStore
def _settings() -> Settings:
return Settings(
flex_auth_base_url=None,
flex_auth_timeout_seconds=1,
host="127.0.0.1",
port=8090,
)
def test_store_selection_defaults_to_memory_and_selects_sqlite(tmp_path) -> None:
assert _build_store(_settings()) is None
store = _build_store(replace(_settings(), database_path=str(tmp_path / "tenant.db")))
assert isinstance(store, SQLiteTenantStore)
def test_store_selection_refuses_ambiguous_configuration(tmp_path) -> None:
with pytest.raises(RuntimeError, match="ambiguous store configuration"):
_build_store(
replace(
_settings(),
database_path=str(tmp_path / "tenant.db"),
database_url_file=str(tmp_path / "database-url"),
)
)

View file

@ -9,6 +9,7 @@ kind a single-backend suite would miss.
from datetime import UTC, datetime
import pytest
from postgres_backend import clean_postgres_store
from tenant_engine.domain import Tenant, TenantRetiredError
from tenant_engine.guardrail import (
@ -30,11 +31,13 @@ NOW = datetime(2026, 8, 16, 12, 0, tzinfo=UTC)
KEY = "spend.monthly"
@pytest.fixture(params=["memory", "sqlite"])
@pytest.fixture(params=["memory", "sqlite", "postgres"])
def store(request, tmp_path):
if request.param == "memory":
return InMemoryTenantStore()
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
if request.param == "sqlite":
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
return clean_postgres_store(tmp_path)
@pytest.fixture

View file

@ -1,6 +1,6 @@
"""TEN-WP-0005-T02/T04: one lifecycle contract, both store backends.
"""TEN-WP-0005-T02/T04: one lifecycle contract, every store backend.
Every test here is parametrised over the in-memory and SQLite stores so the
Every test here is parametrised over the in-memory, SQLite, and PostgreSQL stores so the
durable backend cannot silently diverge from the reference semantics -- the
divergence that matters (CAS, idempotency, retirement guards) is exactly the
kind a single-backend suite would miss.
@ -9,6 +9,7 @@ kind a single-backend suite would miss.
from datetime import UTC, datetime
import pytest
from postgres_backend import clean_postgres_store
from tenant_engine.domain import (
CapabilityRole,
@ -30,11 +31,13 @@ from tenant_engine.store import (
NOW = datetime(2026, 8, 10, 12, 0, tzinfo=UTC)
@pytest.fixture(params=["memory", "sqlite"])
@pytest.fixture(params=["memory", "sqlite", "postgres"])
def store(request, tmp_path):
if request.param == "memory":
return InMemoryTenantStore()
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
if request.param == "sqlite":
return SQLiteTenantStore(str(tmp_path / "tenant.db"))
return clean_postgres_store(tmp_path)
@pytest.fixture
@ -78,6 +81,22 @@ def test_update_persists_and_bumps_version(store, tenant) -> None:
assert store.get_tenant("t-1").display_name == "Binky Ltd"
def test_grouping_mutation_persists(store, tenant) -> None:
updated, replayed = store.mutate_tenant(
tenant_id="t-1",
expected_version=1,
mutate=lambda current: current.with_grouping("large", at=NOW),
event_type="tenant_grouping_changed",
evidence={"actor": "ops", "reason": "growth", "correlation_id": "corr-grouping"},
idempotency_key="grouping-large",
request_fingerprint="fp-grouping-large",
)
assert replayed is False
assert updated.grouping == "large"
assert store.get_tenant("t-1").grouping == "large"
def test_stale_version_is_rejected(store, tenant) -> None:
_rename(store, key="k1", version=1)

161
tests/test_transfer.py Normal file
View file

@ -0,0 +1,161 @@
import json
import os
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
import pytest
from tenant_engine.sqlite_store import SQLiteTenantStore
from tenant_engine.transfer import _digest, _normalise, _parse_expectation, _transfer, _verify
def test_parse_expected_tenant_with_colon_identifier() -> None:
assert _parse_expectation("tenant:trial:portalcheck=active:1") == (
"tenant:trial:portalcheck",
"active",
1,
)
with pytest.raises(ValueError, match="LIFECYCLE"):
_parse_expectation("tenant:trial:portalcheck")
def test_normalised_evidence_matches_sqlite_and_postgres_shapes() -> None:
sqlite_rows = [
{
"seq": 1,
"event_type": "tenant_created",
"tenant_id": "t-1",
"at": "2026-08-18T10:00:00+00:00",
"payload": '{"grouping":"trial"}',
}
]
postgres_rows = [
{
"seq": 1,
"event_type": "tenant_created",
"tenant_id": "t-1",
"at": datetime(2026, 8, 18, 10, tzinfo=UTC),
"payload": {"grouping": "trial"},
}
]
expected = _normalise("events", sqlite_rows)
assert expected == _normalise("events", postgres_rows)
assert _digest(expected) == _digest(_normalise("events", postgres_rows))
def test_sqlite_transfer_preserves_every_table_and_legacy_null_timestamps(tmp_path) -> None:
dsn = os.getenv("TENANT_ENGINE_TEST_DATABASE_URL", "").strip()
if not dsn:
pytest.skip("set TENANT_ENGINE_TEST_DATABASE_URL for transfer integration")
import psycopg
from psycopg.rows import dict_row
sqlite_path = tmp_path / "tenant.db"
sqlite_store = SQLiteTenantStore(str(sqlite_path))
with sqlite_store._db: # noqa: SLF001 - migration fixture uses the real schema
sqlite_store._db.execute( # noqa: SLF001
"""INSERT INTO tenants
(tenant_id, identifier, grouping_name, lifecycle, version)
VALUES (?, ?, ?, ?, ?)""",
("t-legacy", "tenant:trial:legacy", "trial", "active", 1),
)
sqlite_store._db.execute( # noqa: SLF001
"INSERT INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"grant-1",
"t-legacy",
"CUS",
"manual_grant",
None,
"operator",
"2026-08-18T10:00:00+00:00",
"corr-1",
None,
),
)
sqlite_store._db.execute( # noqa: SLF001
"INSERT INTO plans VALUES (?, ?, ?)",
("t-legacy", "silent-tier", "2026-08-18T10:01:00+00:00"),
)
sqlite_store._db.execute( # noqa: SLF001
"INSERT INTO events (seq, event_type, tenant_id, at, payload) VALUES (?, ?, ?, ?, ?)",
(
7,
"tenant_created",
"t-legacy",
"2026-08-18T10:00:00+00:00",
json.dumps({"grouping": "trial"}),
),
)
sqlite_store._db.execute( # noqa: SLF001
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
(
"t-legacy",
"idem-1",
"sha256:request",
json.dumps(
{
"tenant_id": "t-legacy",
"identifier": "tenant:trial:legacy",
"grouping_name": "trial",
"display_name": None,
"contact_email": None,
"lifecycle": "active",
"version": 1,
"created_at": None,
"updated_at": None,
"retired_at": None,
"reactivated_at": None,
}
),
"2026-08-18T10:02:00+00:00",
),
)
sqlite_store._db.execute( # noqa: SLF001
"INSERT INTO guardrail_overrides VALUES (?, ?, ?, ?, ?, ?)",
("t-legacy", "monthly_spend", "money", "250", "EUR", "month"),
)
sqlite_store._db.execute( # noqa: SLF001
"INSERT INTO guardrail_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"change-1",
"t-legacy",
"monthly_spend",
None,
json.dumps(
{"kind": "money", "amount": "250", "currency": "EUR", "period": "month"}
),
"operator",
"initial ceiling",
"corr-2",
"2026-08-18T10:03:00+00:00",
),
)
sqlite_store._db.close() # noqa: SLF001
migration = Path(__file__).parents[1] / "migrations/postgres/0001_tenant_store.sql"
source = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
source.row_factory = sqlite3.Row
try:
with psycopg.connect(dsn, row_factory=dict_row, autocommit=True) as target:
target.execute(migration.read_text(encoding="utf-8"))
target.execute(
"""TRUNCATE guardrail_changes, guardrail_overrides, idempotency_receipts,
events, plans, grants, tenants RESTART IDENTITY CASCADE"""
)
with target.transaction():
_transfer(source, target)
evidence = _verify(source, target, [("t-legacy", "active", 1)])
next_seq = target.execute(
"INSERT INTO events (event_type, tenant_id, at, payload) "
"VALUES ('after_transfer', 't-legacy', now(), '{}'::jsonb) RETURNING seq"
).fetchone()["seq"]
finally:
source.close()
assert evidence["tables"]["events"]["rows"] == 1
assert evidence["tables"]["guardrail_changes"]["rows"] == 1
assert next_seq == 8