Deploy durable tenant authority runtime
This commit is contained in:
parent
8bd0741104
commit
1e44cace1f
6 changed files with 210 additions and 2 deletions
10
Containerfile
Normal file
10
Containerfile
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -10,6 +10,7 @@ class Settings:
|
||||||
flex_auth_timeout_seconds: float
|
flex_auth_timeout_seconds: float
|
||||||
host: str
|
host: str
|
||||||
port: int
|
port: int
|
||||||
|
database_path: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
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_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")),
|
flex_auth_timeout_seconds=float(os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")),
|
||||||
host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"),
|
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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import uvicorn
|
||||||
|
|
||||||
from tenant_engine.app import create_app
|
from tenant_engine.app import create_app
|
||||||
from tenant_engine.config import Settings
|
from tenant_engine.config import Settings
|
||||||
|
from tenant_engine.sqlite_store import SQLiteTenantStore
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
settings = Settings.from_env()
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
127
src/tenant_engine/sqlite_store.py
Normal file
127
src/tenant_engine/sqlite_store.py
Normal file
|
|
@ -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)
|
||||||
11
tests/test_sqlite_store.py
Normal file
11
tests/test_sqlite_store.py
Normal file
|
|
@ -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"
|
||||||
56
workplans/TEN-WP-0004-production-runtime.md
Normal file
56
workplans/TEN-WP-0004-production-runtime.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue