diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..3c6903f --- /dev/null +++ b/Containerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim +RUN useradd --system --uid 10001 --create-home tenant-engine +WORKDIR /app +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +RUN pip install --no-cache-dir . +USER 10001 +EXPOSE 8090 +ENV TENANT_ENGINE_HOST=0.0.0.0 TENANT_ENGINE_HTTP_PORT=8090 +CMD ["tenant-engine"] diff --git a/src/tenant_engine/config.py b/src/tenant_engine/config.py index ab196e1..7fb830d 100644 --- a/src/tenant_engine/config.py +++ b/src/tenant_engine/config.py @@ -10,6 +10,7 @@ class Settings: flex_auth_timeout_seconds: float host: str port: int + database_path: str | None = None @classmethod def from_env(cls) -> "Settings": @@ -17,5 +18,6 @@ class Settings: flex_auth_base_url=os.getenv("TENANT_ENGINE_FLEX_AUTH_URL") or None, flex_auth_timeout_seconds=float(os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")), host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"), - port=int(os.getenv("TENANT_ENGINE_PORT", "8090")), + port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")), + database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None, ) diff --git a/src/tenant_engine/main.py b/src/tenant_engine/main.py index d91e0d0..43aa900 100644 --- a/src/tenant_engine/main.py +++ b/src/tenant_engine/main.py @@ -4,11 +4,13 @@ import uvicorn from tenant_engine.app import create_app from tenant_engine.config import Settings +from tenant_engine.sqlite_store import SQLiteTenantStore def main() -> None: settings = Settings.from_env() - uvicorn.run(create_app(settings=settings), host=settings.host, port=settings.port) + store = SQLiteTenantStore(settings.database_path) if settings.database_path else None + uvicorn.run(create_app(settings=settings, store=store), host=settings.host, port=settings.port) if __name__ == "__main__": diff --git a/src/tenant_engine/sqlite_store.py b/src/tenant_engine/sqlite_store.py new file mode 100644 index 0000000..5b81708 --- /dev/null +++ b/src/tenant_engine/sqlite_store.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from threading import RLock + +from tenant_engine.domain import CapabilityRole, PlanAssignment, RoleGrant, Tenant +from tenant_engine.store import ( + DomainEvent, + GrantNotFoundError, + TenantAlreadyExistsError, + TenantNotFoundError, +) + + +class SQLiteTenantStore: + """Durable single-node tenant store used by the Kubernetes runtime.""" + + def __init__(self, path: str) -> None: + self._db = sqlite3.connect(path, check_same_thread=False) + self._db.row_factory = sqlite3.Row + self._lock = RLock() + with self._db: + self._db.executescript(""" + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS tenants ( + tenant_id TEXT PRIMARY KEY, identifier TEXT UNIQUE NOT NULL, + grouping_name TEXT + ); + CREATE TABLE IF NOT EXISTS grants ( + grant_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, role TEXT NOT NULL, + grant_reason TEXT NOT NULL, plan_id TEXT, granted_by TEXT NOT NULL, + granted_at TEXT NOT NULL, correlation_id TEXT NOT NULL, revoked_at TEXT + ); + CREATE TABLE IF NOT EXISTS plans ( + tenant_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL, assigned_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL, + tenant_id TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL + ); + """) + + def create_tenant(self, tenant: Tenant) -> None: + with self._lock, self._db: + try: + self._db.execute( + "INSERT INTO tenants VALUES (?, ?, ?)", + (tenant.tenant_id, tenant.identifier, tenant.grouping), + ) + except sqlite3.IntegrityError as exc: + raise TenantAlreadyExistsError(tenant.identifier) from exc + self._emit("tenant_created", tenant.tenant_id, { + "identifier": tenant.identifier, "grouping": tenant.grouping, + }) + + def get_tenant(self, tenant_id: str) -> Tenant: + row = self._db.execute( + "SELECT * FROM tenants WHERE tenant_id = ? OR identifier = ?", (tenant_id, tenant_id) + ).fetchone() + if row is None: + raise TenantNotFoundError(tenant_id) + return Tenant(row["tenant_id"], row["identifier"], row["grouping_name"]) + + def grant_role(self, grant: RoleGrant) -> None: + tenant = self.get_tenant(grant.tenant_id) + with self._lock, self._db: + self._db.execute( + "INSERT OR REPLACE INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (grant.grant_id, tenant.tenant_id, grant.role.value, grant.grant_reason, + grant.plan_id, grant.granted_by, grant.granted_at.isoformat(), + grant.correlation_id, grant.revoked_at.isoformat() if grant.revoked_at else None), + ) + self._emit("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) + row = self._db.execute( + "SELECT * FROM grants WHERE tenant_id = ? AND grant_id = ?", + (tenant.tenant_id, grant_id), + ).fetchone() + if row is None: + raise GrantNotFoundError(grant_id) + grant = self._grant(row).revoke(at=at) + with self._lock, self._db: + self._db.execute("UPDATE grants SET revoked_at = ? WHERE grant_id = ?", + (at.isoformat(), grant_id)) + self._emit("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) + rows = self._db.execute( + "SELECT role FROM grants WHERE tenant_id = ? 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) + with self._lock, self._db: + self._db.execute( + "INSERT OR REPLACE INTO plans VALUES (?, ?, ?)", + (tenant.tenant_id, assignment.plan_id, assignment.assigned_at.isoformat()), + ) + self._emit("plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id}) + + def events(self) -> list[DomainEvent]: + return [DomainEvent(row["event_type"], row["tenant_id"], + datetime.fromisoformat(row["at"]), json.loads(row["payload"])) + for row in self._db.execute("SELECT * FROM events ORDER BY seq")] + + def _emit(self, event_type: str, tenant_id: str, payload: dict) -> None: + now = datetime.now().astimezone() + self._db.execute("INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)", + (event_type, tenant_id, now.isoformat(), json.dumps(payload))) + + @staticmethod + def _grant(row: sqlite3.Row) -> RoleGrant: + return RoleGrant(row["grant_id"], row["tenant_id"], CapabilityRole(row["role"]), + row["grant_reason"], row["plan_id"], row["granted_by"], + datetime.fromisoformat(row["granted_at"]), row["correlation_id"], + datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None) diff --git a/tests/test_sqlite_store.py b/tests/test_sqlite_store.py new file mode 100644 index 0000000..c9b1f97 --- /dev/null +++ b/tests/test_sqlite_store.py @@ -0,0 +1,11 @@ +from tenant_engine.domain import Tenant +from tenant_engine.sqlite_store import SQLiteTenantStore + + +def test_tenant_survives_store_reopen(tmp_path): + path = str(tmp_path / "tenants.db") + SQLiteTenantStore(path).create_tenant( + Tenant.create(tenant_id="tenant:friendly:new", identifier="tenant:friendly:new") + ) + tenant = SQLiteTenantStore(path).get_tenant("tenant:friendly:new") + assert tenant.grouping == "friendly" diff --git a/workplans/TEN-WP-0004-production-runtime.md b/workplans/TEN-WP-0004-production-runtime.md new file mode 100644 index 0000000..c69081f --- /dev/null +++ b/workplans/TEN-WP-0004-production-runtime.md @@ -0,0 +1,56 @@ +--- +id: TEN-WP-0004 +type: workplan +title: "Production tenant authority runtime" +domain: infotech +repo: tenant-engine +status: finished +owner: codex +topic_slug: netkingdom +created: "2026-08-08" +updated: "2026-08-08" +--- + +# Production tenant authority runtime + +Deploy the already-defined tenant authority and flex-auth policy behind the +user-engine platform portal with restart-safe persistence. + +## Durable store and package + +```task +id: TEN-WP-0004-T01 +status: done +priority: high +``` + +Provide a durable runtime store, container image, and persistence tests. + +Done 2026-08-09: SQLite-backed persistence on a 1 Gi RWO PVC, immutable image, +and restart-survival verification are complete. + +## Production deployment + +```task +id: TEN-WP-0004-T02 +status: done +priority: high +``` + +Deploy flex-auth and tenant-engine with least-privilege networking and storage. + +Done 2026-08-09: both services are Ready with namespace-scoped ingress/egress; +the live flex-auth-authorized create returned 201. + +## Portal integration and live verification + +```task +id: TEN-WP-0004-T03 +status: done +priority: high +``` + +Align user-engine's adapter with the authority contract and prove tenant creation. + +Done 2026-08-09: the deployed portal adapter created +`tenant:trial:portalcheck`; tenant-engine resolved it after a pod restart.