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

View file

@ -4,7 +4,7 @@ import hashlib
import json
from datetime import UTC, datetime
from fastapi import FastAPI, Header, HTTPException, Request, Response
from fastapi import FastAPI, Header, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
@ -204,16 +204,36 @@ def create_app(
},
)
@app.get("/live")
async def live() -> dict[str, str]:
return {"status": "ok", "service": "tenant-engine", "version": __version__}
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "service": "tenant-engine", "version": __version__}
ping = getattr(store, "ping", None)
try:
if ping is not None:
ping()
except StoreUnavailableError as exc:
raise HTTPException(status_code=503, detail="tenant_store_unavailable") from exc
return {
"status": "ok",
"service": "tenant-engine",
"version": __version__,
"store_backend": str(getattr(store, "backend_name", "custom")),
}
# -- Authoritative tenant record read (TEN-WP-0005) -------------------
# user-engine's platform operator UI reads this before offering an edit,
# and echoes the returned ETag back as If-Match on the mutation.
@app.get("/tenants/{tenant_id}")
async def get_tenant(tenant_id: str, response: Response) -> dict:
async def get_tenant(
tenant_id: str,
response: Response,
actor: str = Query(min_length=1),
) -> dict:
authorizer.authorize(action="tenant.read", tenant_id=tenant_id, actor=actor)
try:
tenant = store.get_tenant(tenant_id)
except TenantNotFoundError as exc:
@ -228,7 +248,8 @@ def create_app(
# -- Cache-read API (key-cape, at token issuance) --------------------
@app.get("/tenants/{tenant_id}/roles")
async def cache_read_roles(tenant_id: str) -> dict:
async def cache_read_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
authorizer.authorize(action="tenant.role.read", tenant_id=tenant_id, actor=actor)
return _read_roles(store, tenant_id)
# -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
@ -239,7 +260,8 @@ def create_app(
# authorizing a privileged action -- not payload shape or error handling.
@app.get("/tenants/{tenant_id}/roles/live")
async def live_lookup_roles(tenant_id: str) -> dict:
async def live_lookup_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
authorizer.authorize(action="tenant.role.read.live", tenant_id=tenant_id, actor=actor)
return _read_roles(store, tenant_id)
# -- Write API (grant/revoke/plan mutation) ---------------------------

View file

@ -8,9 +8,12 @@ from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_reque
# resource/action vocabulary exactly -- the two repos coordinate on these
# strings, neither invents its own.
_RESOURCE_TYPES: dict[str, str] = {
"tenant.read": "tenant",
"tenant.create": "tenant",
"tenant.role.grant": "role-grant",
"tenant.role.revoke": "role-grant",
"tenant.role.read": "role-grant",
"tenant.role.read.live": "role-grant",
"tenant.plan.assign": "plan-assignment",
# TEN-WP-0005: lifecycle actions are distinct so policy can separate a
# metadata edit from a retirement.

View file

@ -11,6 +11,7 @@ class Settings:
host: str
port: int
database_path: str | None = None
database_url_file: str | None = None
flex_auth_token_file: str | None = None
@classmethod
@ -21,5 +22,6 @@ class Settings:
host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"),
port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")),
database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None,
database_url_file=os.getenv("TENANT_ENGINE_DATABASE_URL_FILE") or None,
flex_auth_token_file=os.getenv("TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE") or None,
)

View file

@ -4,14 +4,29 @@ import uvicorn
from tenant_engine.app import create_app
from tenant_engine.config import Settings
from tenant_engine.postgres_store import PostgresTenantStore
from tenant_engine.sqlite_store import SQLiteTenantStore
from tenant_engine.store import TenantStore
def main() -> None:
settings = Settings.from_env()
store = SQLiteTenantStore(settings.database_path) if settings.database_path else None
store = _build_store(settings)
uvicorn.run(create_app(settings=settings, store=store), host=settings.host, port=settings.port)
def _build_store(settings: Settings) -> TenantStore | None:
if settings.database_path and settings.database_url_file:
raise RuntimeError(
"refusing ambiguous store configuration: set only one of "
"TENANT_ENGINE_DATABASE_PATH or TENANT_ENGINE_DATABASE_URL_FILE"
)
if settings.database_url_file:
return PostgresTenantStore(settings.database_url_file)
if settings.database_path:
return SQLiteTenantStore(settings.database_path)
return None
if __name__ == "__main__":
main()

View file

@ -0,0 +1,38 @@
from __future__ import annotations
import argparse
import os
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Run an out-of-band tenant-engine migration")
parser.add_argument(
"--url-file",
default=os.getenv("TENANT_ENGINE_MIGRATION_DATABASE_URL_FILE", ""),
help="file containing the leased migration database URL",
)
parser.add_argument(
"--migration",
default="/app/migrations/postgres/0001_tenant_store.sql",
help="SQL migration file",
)
args = parser.parse_args()
if not args.url_file:
parser.error("--url-file or TENANT_ENGINE_MIGRATION_DATABASE_URL_FILE is required")
try:
import psycopg
except ImportError as exc: # pragma: no cover - deployment guard
raise RuntimeError("install tenant-engine[postgres] to run migrations") from exc
dsn = Path(args.url_file).read_text(encoding="utf-8").strip()
if not dsn:
raise RuntimeError("migration database URL file is empty")
sql = Path(args.migration).read_text(encoding="utf-8")
with psycopg.connect(dsn, autocommit=True) as connection:
connection.execute(sql)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,541 @@
from __future__ import annotations
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import replace
from datetime import UTC, datetime
from pathlib import Path
from threading import RLock
from typing import Any
try: # Optional runtime dependency; SQLite/development installs stay small.
import psycopg
from psycopg.errors import UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from psycopg_pool import ConnectionPool, PoolTimeout
except ImportError: # pragma: no cover - exercised by the deployment guard
psycopg = None
ConnectionPool = None
PoolTimeout = None
from tenant_engine.domain import (
CapabilityRole,
PlanAssignment,
RoleGrant,
Tenant,
TenantLifecycle,
TenantRetiredError,
)
from tenant_engine.guardrail import (
DEFAULT_REGISTRY,
GuardrailChange,
LimitValue,
dump_limit,
load_limit,
)
from tenant_engine.store import (
DomainEvent,
GrantNotFoundError,
IdempotencyConflictError,
StoreUnavailableError,
TenantAlreadyExistsError,
TenantNotFoundError,
VersionConflictError,
guard_guardrail_write,
)
class RotatingConnectionPool:
"""A bounded pool whose DSN is read from a projected file per checkout.
OpenBao can replace the file atomically when a database lease rotates.
The next checkout swaps pools, while max_lifetime also bounds how long a
still-valid connection can retain an old credential.
"""
def __init__(self, dsn_file: str, *, min_size: int = 1, max_size: int = 4) -> None:
if ConnectionPool is None:
raise RuntimeError("install tenant-engine[postgres] for PostgreSQL support")
self._dsn_file = Path(dsn_file)
self._min_size = min_size
self._max_size = max_size
self._lock = RLock()
self._dsn = ""
self._pool = None
@contextmanager
def connection(self) -> Iterator[Any]:
try:
dsn = self._dsn_file.read_text(encoding="utf-8").strip()
if not dsn:
raise OSError("database URL file is empty")
old_pool = None
with self._lock:
if self._pool is None or dsn != self._dsn:
old_pool = self._pool
self._pool = ConnectionPool(
conninfo=dsn,
min_size=self._min_size,
max_size=self._max_size,
timeout=5,
max_lifetime=300,
kwargs={"row_factory": dict_row},
open=True,
)
self._dsn = dsn
pool = self._pool
if old_pool is not None:
old_pool.close()
with pool.connection() as connection:
yield connection
except OSError as exc:
raise StoreUnavailableError("database credential unavailable") from exc
except (psycopg.Error, PoolTimeout) as exc:
raise StoreUnavailableError("PostgreSQL unavailable") from exc
def close(self) -> None:
with self._lock:
if self._pool is not None:
self._pool.close()
self._pool = None
class PostgresTenantStore:
"""PostgreSQL implementation of the complete TenantStore protocol."""
backend_name = "postgresql"
def __init__(self, dsn_file: str, *, min_pool_size: int = 1, max_pool_size: int = 4) -> None:
self._pool = RotatingConnectionPool(
dsn_file, min_size=min_pool_size, max_size=max_pool_size
)
def close(self) -> None:
self._pool.close()
def ping(self) -> None:
with self._pool.connection() as conn:
conn.execute("SELECT 1").fetchone()
def create_tenant(self, tenant: Tenant) -> None:
try:
with self._pool.connection() as conn, conn.transaction():
conn.execute(
"""INSERT INTO tenants
(tenant_id, identifier, grouping_name, display_name, contact_email,
lifecycle, version, created_at, updated_at, retired_at, reactivated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(
tenant.tenant_id,
tenant.identifier,
tenant.grouping,
tenant.display_name,
tenant.contact_email,
tenant.lifecycle.value,
tenant.version,
tenant.created_at,
tenant.updated_at,
tenant.retired_at,
tenant.reactivated_at,
),
)
self._emit(
conn,
"tenant_created",
tenant.tenant_id,
{"identifier": tenant.identifier, "grouping": tenant.grouping},
)
except StoreUnavailableError as exc:
if isinstance(exc.__cause__, UniqueViolation):
raise TenantAlreadyExistsError(tenant.identifier) from exc
raise
def get_tenant(self, tenant_id: str) -> Tenant:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM tenants WHERE tenant_id = %s OR identifier = %s",
(tenant_id, tenant_id),
).fetchone()
if row is None:
raise TenantNotFoundError(tenant_id)
return _tenant(row)
def mutate_tenant(
self,
*,
tenant_id: str,
expected_version: int,
mutate: Callable[[Tenant], Tenant],
event_type: str,
evidence: dict[str, Any],
idempotency_key: str,
request_fingerprint: str,
) -> tuple[Tenant, bool]:
with self._pool.connection() as conn, conn.transaction():
row = self._locked_tenant(conn, tenant_id)
receipt = conn.execute(
"""SELECT request_fingerprint, result FROM idempotency_receipts
WHERE tenant_id = %s AND idempotency_key = %s""",
(row["tenant_id"], idempotency_key),
).fetchone()
if receipt is not None:
if receipt["request_fingerprint"] != request_fingerprint:
raise IdempotencyConflictError(idempotency_key)
return _tenant(receipt["result"]), True
current = _tenant(row)
if current.version != expected_version:
raise VersionConflictError(expected=expected_version, actual=current.version)
updated = mutate(current)
conn.execute(
"""UPDATE tenants SET grouping_name = %s, display_name = %s,
contact_email = %s, lifecycle = %s, version = %s, updated_at = %s,
retired_at = %s, reactivated_at = %s WHERE tenant_id = %s""",
(
updated.grouping,
updated.display_name,
updated.contact_email,
updated.lifecycle.value,
updated.version,
updated.updated_at,
updated.retired_at,
updated.reactivated_at,
updated.tenant_id,
),
)
self._record_receipt(
conn, updated, idempotency_key, request_fingerprint
)
self._emit(
conn, event_type, updated.tenant_id, {**evidence, "version": updated.version}
)
return updated, False
def grant_role(self, grant: RoleGrant) -> None:
tenant = self.get_tenant(grant.tenant_id)
self._require_active(tenant, "grant a role")
with self._pool.connection() as conn, conn.transaction():
conn.execute(
"""INSERT INTO grants
(grant_id, tenant_id, role, grant_reason, plan_id, granted_by,
granted_at, correlation_id, revoked_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (grant_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id, role = EXCLUDED.role,
grant_reason = EXCLUDED.grant_reason, plan_id = EXCLUDED.plan_id,
granted_by = EXCLUDED.granted_by, granted_at = EXCLUDED.granted_at,
correlation_id = EXCLUDED.correlation_id,
revoked_at = EXCLUDED.revoked_at""",
(
grant.grant_id,
tenant.tenant_id,
grant.role.value,
grant.grant_reason,
grant.plan_id,
grant.granted_by,
grant.granted_at,
grant.correlation_id,
grant.revoked_at,
),
)
self._emit(
conn,
"role_granted",
tenant.tenant_id,
{
"grant_id": grant.grant_id,
"role": grant.role.value,
"grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id,
},
)
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn, conn.transaction():
row = conn.execute(
"SELECT * FROM grants WHERE tenant_id = %s AND grant_id = %s FOR UPDATE",
(tenant.tenant_id, grant_id),
).fetchone()
if row is None:
raise GrantNotFoundError(grant_id)
grant = _grant(row).revoke(at=at)
conn.execute("UPDATE grants SET revoked_at = %s WHERE grant_id = %s", (at, grant_id))
self._emit(
conn,
"role_revoked",
tenant.tenant_id,
{"grant_id": grant_id, "role": grant.role.value},
)
return grant
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT role FROM grants WHERE tenant_id = %s AND revoked_at IS NULL",
(tenant.tenant_id,),
).fetchall()
return frozenset(CapabilityRole(row["role"]) for row in rows)
def assign_plan(self, assignment: PlanAssignment) -> None:
tenant = self.get_tenant(assignment.tenant_id)
self._require_active(tenant, "assign a plan")
with self._pool.connection() as conn, conn.transaction():
conn.execute(
"""INSERT INTO plans (tenant_id, plan_id, assigned_at) VALUES (%s, %s, %s)
ON CONFLICT (tenant_id) DO UPDATE SET
plan_id = EXCLUDED.plan_id, assigned_at = EXCLUDED.assigned_at""",
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at),
)
self._emit(
conn, "plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id}
)
def events(self) -> list[DomainEvent]:
with self._pool.connection() as conn:
rows = conn.execute("SELECT * FROM events ORDER BY seq").fetchall()
return [
DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"])
for row in rows
]
def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM guardrail_overrides WHERE tenant_id = %s", (tenant.tenant_id,)
).fetchall()
return {row["limit_key"]: load_limit(row) for row in rows}
def guardrail_changes(self, tenant_id: str) -> list[GuardrailChange]:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn:
rows = conn.execute(
"""SELECT * FROM guardrail_changes WHERE tenant_id = %s
ORDER BY changed_at, change_id""",
(tenant.tenant_id,),
).fetchall()
return [_change(row) for row in rows]
def set_guardrail_override(
self,
*,
tenant_id: str,
expected_version: int,
limit_key: str,
value: LimitValue | None,
change_id: str,
changed_by: str,
reason: str,
correlation_id: str,
idempotency_key: str,
request_fingerprint: str,
at: datetime,
) -> tuple[Tenant, GuardrailChange | None, bool]:
DEFAULT_REGISTRY.get(limit_key)
with self._pool.connection() as conn, conn.transaction():
row = self._locked_tenant(conn, tenant_id)
receipt = conn.execute(
"""SELECT request_fingerprint, result FROM idempotency_receipts
WHERE tenant_id = %s AND idempotency_key = %s""",
(row["tenant_id"], idempotency_key),
).fetchone()
if receipt is not None:
if receipt["request_fingerprint"] != request_fingerprint:
raise IdempotencyConflictError(idempotency_key)
prior = conn.execute(
"SELECT * FROM guardrail_changes WHERE change_id = %s", (change_id,)
).fetchone()
return _tenant(receipt["result"]), _change(prior) if prior else None, True
current = _tenant(row)
if current.version != expected_version:
raise VersionConflictError(expected=expected_version, actual=current.version)
override_rows = conn.execute(
"SELECT * FROM guardrail_overrides WHERE tenant_id = %s",
(current.tenant_id,),
).fetchall()
overrides = {item["limit_key"]: load_limit(item) for item in override_rows}
guard_guardrail_write(
tenant=current, overrides=overrides, limit_key=limit_key, value=value
)
change = GuardrailChange(
change_id=change_id,
tenant_id=current.tenant_id,
limit_key=limit_key,
previous=overrides.get(limit_key),
current=value,
changed_by=changed_by,
reason=reason,
correlation_id=correlation_id,
changed_at=at,
)
if value is None:
conn.execute(
"DELETE FROM guardrail_overrides WHERE tenant_id = %s AND limit_key = %s",
(current.tenant_id, limit_key),
)
else:
dumped = dump_limit(value)
conn.execute(
"""INSERT INTO guardrail_overrides
(tenant_id, limit_key, kind, amount, currency, period)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (tenant_id, limit_key) DO UPDATE SET
kind = EXCLUDED.kind, amount = EXCLUDED.amount,
currency = EXCLUDED.currency, period = EXCLUDED.period""",
(
current.tenant_id,
limit_key,
dumped["kind"],
dumped["amount"],
dumped["currency"],
dumped["period"],
),
)
conn.execute(
"""INSERT INTO guardrail_changes
(change_id, tenant_id, limit_key, previous, current, changed_by,
reason, correlation_id, changed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(
change.change_id,
change.tenant_id,
change.limit_key,
Jsonb(dump_limit(change.previous)) if change.previous else None,
Jsonb(dump_limit(change.current)) if change.current else None,
change.changed_by,
change.reason,
change.correlation_id,
change.changed_at,
),
)
updated = replace(current, version=current.version + 1, updated_at=at)
conn.execute(
"UPDATE tenants SET version = %s, updated_at = %s WHERE tenant_id = %s",
(updated.version, updated.updated_at, updated.tenant_id),
)
self._record_receipt(conn, updated, idempotency_key, request_fingerprint)
self._emit(
conn,
"guardrail_changed",
updated.tenant_id,
{
"limit_key": limit_key,
"change_id": change_id,
"cleared": value is None,
"correlation_id": correlation_id,
"version": updated.version,
},
)
return updated, change, False
@staticmethod
def _require_active(tenant: Tenant, what: str) -> None:
if tenant.lifecycle is not TenantLifecycle.ACTIVE:
raise TenantRetiredError(f"cannot {what} on a retired tenant")
@staticmethod
def _locked_tenant(conn: Any, tenant_id: str) -> Mapping[str, Any]:
row = conn.execute(
"""SELECT * FROM tenants WHERE tenant_id = %s OR identifier = %s
FOR UPDATE""",
(tenant_id, tenant_id),
).fetchone()
if row is None:
raise TenantNotFoundError(tenant_id)
return row
@staticmethod
def _record_receipt(
conn: Any, tenant: Tenant, idempotency_key: str, request_fingerprint: str
) -> None:
conn.execute(
"""INSERT INTO idempotency_receipts
(tenant_id, idempotency_key, request_fingerprint, result, recorded_at)
VALUES (%s, %s, %s, %s, %s)""",
(
tenant.tenant_id,
idempotency_key,
request_fingerprint,
Jsonb(_row(tenant)),
datetime.now(UTC),
),
)
@staticmethod
def _emit(conn: Any, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
conn.execute(
"INSERT INTO events (event_type, tenant_id, at, payload) VALUES (%s, %s, %s, %s)",
(event_type, tenant_id, datetime.now(UTC), Jsonb(payload)),
)
def _tenant(row: Mapping[str, Any]) -> Tenant:
return Tenant(
tenant_id=row["tenant_id"],
identifier=row["identifier"],
grouping=row["grouping_name"],
display_name=row["display_name"],
contact_email=row["contact_email"],
lifecycle=TenantLifecycle(row["lifecycle"]),
version=int(row["version"]),
created_at=_dt(row["created_at"]),
updated_at=_dt(row["updated_at"]),
retired_at=_dt(row["retired_at"]),
reactivated_at=_dt(row["reactivated_at"]),
)
def _row(tenant: Tenant) -> dict[str, Any]:
return {
"tenant_id": tenant.tenant_id,
"identifier": tenant.identifier,
"grouping_name": tenant.grouping,
"display_name": tenant.display_name,
"contact_email": tenant.contact_email,
"lifecycle": tenant.lifecycle.value,
"version": tenant.version,
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
"retired_at": tenant.retired_at.isoformat() if tenant.retired_at else None,
"reactivated_at": (
tenant.reactivated_at.isoformat() if tenant.reactivated_at else None
),
}
def _dt(value: datetime | str | None) -> datetime | None:
if isinstance(value, datetime) or value is None:
return value
return datetime.fromisoformat(value)
def _grant(row: Mapping[str, Any]) -> RoleGrant:
return RoleGrant(
row["grant_id"],
row["tenant_id"],
CapabilityRole(row["role"]),
row["grant_reason"],
row["plan_id"],
row["granted_by"],
_dt(row["granted_at"]),
row["correlation_id"],
_dt(row["revoked_at"]),
)
def _change(row: Mapping[str, Any]) -> GuardrailChange:
return GuardrailChange(
change_id=row["change_id"],
tenant_id=row["tenant_id"],
limit_key=row["limit_key"],
previous=load_limit(row["previous"]) if row["previous"] else None,
current=load_limit(row["current"]) if row["current"] else None,
changed_by=row["changed_by"],
reason=row["reason"],
correlation_id=row["correlation_id"],
changed_at=_dt(row["changed_at"]),
)

View file

@ -37,6 +37,8 @@ from tenant_engine.store import (
class SQLiteTenantStore:
"""Durable single-node tenant store used by the Kubernetes runtime."""
backend_name = "sqlite"
def __init__(self, path: str) -> None:
self._db = sqlite3.connect(path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
@ -104,6 +106,10 @@ class SQLiteTenantStore:
if column not in existing:
self._db.execute(f"ALTER TABLE tenants ADD COLUMN {column} {spec}")
def ping(self) -> None:
with self._lock:
self._db.execute("SELECT 1").fetchone()
def create_tenant(self, tenant: Tenant) -> None:
with self._lock, self._db:
try:
@ -179,12 +185,14 @@ class SQLiteTenantStore:
updated = mutate(current)
self._db.execute(
"""UPDATE tenants SET display_name = ?, contact_email = ?, lifecycle = ?,
version = ?, updated_at = ?, retired_at = ?, reactivated_at = ?
"""UPDATE tenants SET grouping_name = ?, display_name = ?,
contact_email = ?, lifecycle = ?, version = ?, updated_at = ?,
retired_at = ?, reactivated_at = ?
WHERE tenant_id = ?""",
(updated.display_name, updated.contact_email, updated.lifecycle.value,
updated.version, _iso(updated.updated_at), _iso(updated.retired_at),
_iso(updated.reactivated_at), updated.tenant_id),
(updated.grouping, updated.display_name, updated.contact_email,
updated.lifecycle.value, updated.version, _iso(updated.updated_at),
_iso(updated.retired_at), _iso(updated.reactivated_at),
updated.tenant_id),
)
self._db.execute(
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",

View file

@ -194,6 +194,8 @@ def guard_guardrail_write(
class InMemoryTenantStore:
backend_name = "memory"
def __init__(self) -> None:
self._tenants: dict[str, Tenant] = {}
self._by_identifier: dict[str, str] = {}

View file

@ -0,0 +1,262 @@
from __future__ import annotations
import argparse
import hashlib
import json
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any
TABLES: dict[str, tuple[str, ...]] = {
"tenants": (
"tenant_id",
"identifier",
"grouping_name",
"display_name",
"contact_email",
"lifecycle",
"version",
"created_at",
"updated_at",
"retired_at",
"reactivated_at",
),
"grants": (
"grant_id",
"tenant_id",
"role",
"grant_reason",
"plan_id",
"granted_by",
"granted_at",
"correlation_id",
"revoked_at",
),
"plans": ("tenant_id", "plan_id", "assigned_at"),
"events": ("seq", "event_type", "tenant_id", "at", "payload"),
"idempotency_receipts": (
"tenant_id",
"idempotency_key",
"request_fingerprint",
"result",
"recorded_at",
),
"guardrail_overrides": (
"tenant_id",
"limit_key",
"kind",
"amount",
"currency",
"period",
),
"guardrail_changes": (
"change_id",
"tenant_id",
"limit_key",
"previous",
"current",
"changed_by",
"reason",
"correlation_id",
"changed_at",
),
}
PRIMARY_KEYS = {
"tenants": ("tenant_id",),
"grants": ("grant_id",),
"plans": ("tenant_id",),
"events": ("seq",),
"idempotency_receipts": ("tenant_id", "idempotency_key"),
"guardrail_overrides": ("tenant_id", "limit_key"),
"guardrail_changes": ("change_id",),
}
JSON_COLUMNS = {
"events": {"payload"},
"idempotency_receipts": {"result"},
"guardrail_changes": {"previous", "current"},
}
TIMESTAMP_COLUMNS = {
"tenants": {"created_at", "updated_at", "retired_at", "reactivated_at"},
"grants": {"granted_at", "revoked_at"},
"plans": {"assigned_at"},
"events": {"at"},
"idempotency_receipts": {"recorded_at"},
"guardrail_changes": {"changed_at"},
}
def _source_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]:
columns = TABLES[table]
order = ", ".join(PRIMARY_KEYS[table])
selected = ", ".join(columns)
return [
dict(row) for row in connection.execute(f"SELECT {selected} FROM {table} ORDER BY {order}")
]
def _target_rows(connection: Any, table: str) -> list[dict[str, Any]]:
columns = TABLES[table]
order = ", ".join(PRIMARY_KEYS[table])
selected = ", ".join(columns)
return list(connection.execute(f"SELECT {selected} FROM {table} ORDER BY {order}").fetchall())
def _normalise(table: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
output: list[dict[str, Any]] = []
for row in rows:
item: dict[str, Any] = {}
for column in TABLES[table]:
value = row[column]
if value is not None and column in JSON_COLUMNS.get(table, set()):
value = json.loads(value) if isinstance(value, str) else value
if value is not None and column in TIMESTAMP_COLUMNS.get(table, set()):
parsed = datetime.fromisoformat(value) if isinstance(value, str) else value
value = parsed.isoformat()
item[column] = value
output.append(item)
return output
def _digest(rows: list[dict[str, Any]]) -> str:
encoded = json.dumps(rows, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _parse_expectation(value: str) -> tuple[str, str, int]:
tenant_id, separator, expected = value.rpartition("=")
lifecycle, state_separator, version = expected.rpartition(":")
if not separator or not state_separator or not tenant_id or not lifecycle:
raise ValueError("expected TENANT_ID=LIFECYCLE:VERSION")
try:
parsed_version = int(version)
except ValueError as exc:
raise ValueError("expected TENANT_ID=LIFECYCLE:VERSION") from exc
return tenant_id, lifecycle, parsed_version
def _transfer(source: sqlite3.Connection, target: Any) -> None:
from psycopg.types.json import Jsonb
nonempty = {
table: target.execute(f"SELECT count(*) AS count FROM {table}").fetchone()["count"]
for table in TABLES
}
occupied = {table: count for table, count in nonempty.items() if count}
if occupied:
raise RuntimeError(
f"target is not empty: {occupied}; use --verify-only or a fresh migrated database"
)
for table in TABLES:
columns = TABLES[table]
placeholders = ", ".join(["%s"] * len(columns))
override = " OVERRIDING SYSTEM VALUE" if table == "events" else ""
statement = f"INSERT INTO {table} ({', '.join(columns)}){override} VALUES ({placeholders})"
for row in _source_rows(source, table):
values: list[Any] = []
for column in columns:
value = row[column]
if value is not None and column in JSON_COLUMNS.get(table, set()):
value = Jsonb(json.loads(value))
values.append(value)
target.execute(statement, values)
target.execute(
"""SELECT setval(
pg_get_serial_sequence('events', 'seq'),
COALESCE((SELECT max(seq) FROM events), 1),
EXISTS (SELECT 1 FROM events)
)"""
)
def _verify(
source: sqlite3.Connection,
target: Any,
expectations: list[tuple[str, str, int]],
) -> dict[str, Any]:
tables: dict[str, dict[str, Any]] = {}
for table in TABLES:
source_rows = _normalise(table, _source_rows(source, table))
target_rows = _normalise(table, _target_rows(target, table))
if source_rows != target_rows:
raise RuntimeError(f"verification mismatch in {table}")
tables[table] = {"rows": len(source_rows), "sha256": _digest(source_rows)}
tenants = {
row["tenant_id"]: row for row in _normalise("tenants", _source_rows(source, "tenants"))
}
checked: list[dict[str, Any]] = []
for tenant_id, lifecycle, version in expectations:
row = tenants.get(tenant_id)
if row is None:
raise RuntimeError(f"expected tenant {tenant_id!r} is absent")
if row["lifecycle"] != lifecycle or row["version"] != version:
raise RuntimeError(
f"tenant {tenant_id!r} is {row['lifecycle']} v{row['version']}, "
f"expected {lifecycle} v{version}"
)
checked.append({"tenant_id": tenant_id, "lifecycle": lifecycle, "version": version})
return {
"result": "verified",
"scope": "all tenant-engine tables and rows",
"tables": tables,
"expected_tenants": checked,
"note": "Digests prove this copy only; retain the stopped SQLite volume through soak.",
}
def main() -> None:
parser = argparse.ArgumentParser(
description="Copy and verify a stopped tenant-engine SQLite store in PostgreSQL"
)
parser.add_argument("--sqlite", required=True, help="path to the stopped SQLite database")
parser.add_argument("--url-file", required=True, help="file containing the migration-role URL")
parser.add_argument("--verify-only", action="store_true")
parser.add_argument(
"--expect-tenant",
action="append",
default=[],
metavar="TENANT_ID=LIFECYCLE:VERSION",
)
parser.add_argument("--evidence-output", help="optional non-secret JSON evidence path")
args = parser.parse_args()
try:
import psycopg
from psycopg.rows import dict_row
except ImportError as exc: # pragma: no cover - deployment guard
raise RuntimeError("install tenant-engine[postgres] to transfer data") from exc
expectations = [_parse_expectation(value) for value in args.expect_tenant]
sqlite_path = Path(args.sqlite).resolve()
if not sqlite_path.is_file():
raise RuntimeError(f"SQLite database does not exist: {sqlite_path}")
dsn = Path(args.url_file).read_text(encoding="utf-8").strip()
if not dsn:
raise RuntimeError("migration database URL file is empty")
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) as target:
with target.transaction():
if not args.verify_only:
_transfer(source, target)
evidence = _verify(source, target, expectations)
finally:
source.close()
rendered = json.dumps(evidence, indent=2, sort_keys=True) + "\n"
if args.evidence_output:
Path(args.evidence_output).write_text(rendered, encoding="utf-8")
print(rendered, end="")
if __name__ == "__main__":
main()