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
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
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