From f8b3ae30119afc4b66485b0e08a2787d932ce356 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 22:46:12 +0200 Subject: [PATCH 1/6] DB-Verbindung: bei Auth-Fehlern sofort abbrechen, Doku zum Volume-Wechsel. Co-authored-by: Cursor --- backend/db.py | 37 +++++++++++++++++++++++++++++++++++++ backend/run_migrations.py | 18 +----------------- backend/run_seeds.py | 17 +---------------- docs/DEPLOYMENT.md | 16 ++++++++++++++++ 4 files changed, 55 insertions(+), 33 deletions(-) diff --git a/backend/db.py b/backend/db.py index d94520c..ec650a8 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,6 +1,9 @@ """PostgreSQL connection helpers.""" +from __future__ import annotations + import os +import time import psycopg2 from psycopg2.extensions import connection @@ -27,6 +30,40 @@ def get_connection() -> connection: ) +def is_retryable_db_error(exc: Exception) -> bool: + """Nur temporäre Verbindungsprobleme erneut versuchen (nicht Auth/Config).""" + msg = str(exc).lower() + if "password authentication failed" in msg: + return False + if "does not exist" in msg and any(token in msg for token in ("role", "database")): + return False + if "no pg_hba.conf entry" in msg: + return False + return True + + +def connect_with_retry(max_retries: int = 30, sleep_seconds: float = 2.0) -> connection: + p = db_params() + last_exc: Exception | None = None + for attempt in range(max_retries): + try: + conn = get_connection() + conn.autocommit = False + print(f"[OK] Connected to database: {p['dbname']}") + return conn + except psycopg2.OperationalError as exc: + last_exc = exc + if not is_retryable_db_error(exc): + raise + if attempt >= max_retries - 1: + raise + print(f"Waiting for database... ({attempt + 1}/{max_retries})") + time.sleep(sleep_seconds) + if last_exc: + raise last_exc + raise RuntimeError("database connection failed") + + def check_db() -> bool: conn = get_connection() try: diff --git a/backend/run_migrations.py b/backend/run_migrations.py index 9de7202..f74afac 100644 --- a/backend/run_migrations.py +++ b/backend/run_migrations.py @@ -8,13 +8,12 @@ import re import shutil import subprocess import sys -import time from typing import List, Tuple import psycopg2 import sqlparse -from db import db_params, get_connection +from db import check_db, connect_with_retry, db_params, get_connection _LEADING_DIGITS = re.compile(r"^(\d+)") @@ -135,21 +134,6 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool: return False -def connect_with_retry(max_retries: int = 30): - p = db_params() - for attempt in range(max_retries): - try: - conn = get_connection() - conn.autocommit = False - print(f"[OK] Connected to database: {p['dbname']}") - return conn - except psycopg2.OperationalError: - if attempt >= max_retries - 1: - raise - print(f"Waiting for database... ({attempt + 1}/{max_retries})") - time.sleep(2) - - def migrations_directory() -> str: docker_path = "/app/migrations" if os.path.isdir(docker_path): diff --git a/backend/run_seeds.py b/backend/run_seeds.py index 04ae38f..24c5b94 100644 --- a/backend/run_seeds.py +++ b/backend/run_seeds.py @@ -14,7 +14,7 @@ from typing import Callable, List, Optional, Tuple import psycopg2 import sqlparse -from db import db_params, get_connection +from db import connect_with_retry, db_params, get_connection _SEED_PREFIX = re.compile(r"^seed_(\d+)_(.+)$") _DEV_MARKER = ".dev." @@ -152,21 +152,6 @@ def run_python_seed(filepath: str) -> None: run_fn() -def connect_with_retry(max_retries: int = 30): - p = db_params() - for attempt in range(max_retries): - try: - conn = get_connection() - conn.autocommit = False - print(f"[OK] Connected to database: {p['dbname']}") - return conn - except psycopg2.OperationalError: - if attempt >= max_retries - 1: - raise - print(f"Waiting for database... ({attempt + 1}/{max_retries})") - time.sleep(2) - - def run_seed(conn, seed_name: str, filepath: str, kind: str) -> tuple[bool, object]: print(f"Running seed: {seed_name}") try: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index fcbcf95..2912438 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -75,6 +75,22 @@ Aktuell keine Medien-Speicherung. Bei Bedarf: NAS-Mount + `docker-compose.overri --- +## Dev-Datenbank wechseln + +PostgreSQL im Compose-Stack initialisiert User/Passwort **nur beim ersten Start** des Volumes (`dev-kairo-db-data`). +Wenn du `DB_NAME`, `DB_USER` oder `DB_PASSWORD` in `.env` änderst, muss das Volume neu angelegt werden: + +```bash +cd /home/lars/docker/kairo-dev +docker compose -f docker-compose.dev-env.yml down -v +docker compose -f docker-compose.dev-env.yml up -d --wait +curl -sf http://localhost:8097/api/health +``` + +Danach laufen Migrationen und Dev-Seeds automatisch (u. a. `lars@stommer.com`). + +--- + ## Manuelles Deploy ```bash -- 2.43.0 From f9c207c4f6b05027674f2ca905fb1a78a83c7331 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 22:50:05 +0200 Subject: [PATCH 2/6] AP0.2 Abschlussbericht v0.2 (final) erstellen. Co-authored-by: Cursor --- .../Sprint0_AP0_2_Completion_Report_v0.1.md | 154 ----------- .../Sprint0_AP0_2_Completion_Report_v0.2.md | 248 ++++++++++++++++++ 2 files changed, 248 insertions(+), 154 deletions(-) delete mode 100644 docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md create mode 100644 docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md diff --git a/docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md deleted file mode 100644 index abe0a91..0000000 --- a/docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md +++ /dev/null @@ -1,154 +0,0 @@ -# AP0.2 – Abschlussbericht Auth, Identity, Tenant & Actor Foundation - -**Status:** abgeschlossen (Implementierung) -**Stand:** 2026-07-04 -**Branch:** `develop` (noch nicht auf `main` gemergt) - ---- - -## 1. Umgesetzte Dateien - -| Datei | Zweck | -|-------|--------| -| `backend/migrations/002_auth_identity_tenant_actor.sql` | Schema User, Session, Tenant, Membership, Actor, Audit | -| `backend/auth.py` | bcrypt, Sessions, login/logout, `require_auth`, `require_portal_admin` | -| `backend/tenant_context.py` | `TenantContext`, `get_tenant_context`, `require_tenant_context` | -| `backend/bootstrap.py` | Env-basierter Admin/Tenant/ Actor-Seed | -| `backend/services/audit.py` | Audit-Log für Auth-Aktionen | -| `backend/services/actors.py` | Actor-Erzeugung + Human-Lookup | -| `backend/routers/auth.py` | Login/Logout | -| `backend/routers/me.py` | `/api/me`, `/api/me/context`, Tenant-Wechsel | -| `backend/main.py` | Router, Bootstrap nach Migrationen | -| `backend/tests/conftest.py` | DB-Fixtures | -| `backend/tests/factories.py` | Testdaten | -| `backend/tests/test_auth.py` | Login, Logout, Session, Context | -| `backend/tests/test_tenant_actor.py` | Actor-Typen, Rollen-Trennung | -| `backend/version.py` | `0.2.0-ap0.2`, Schema `002` | -| `docker-compose.dev-env.yml`, `docker-compose.yml` | Bootstrap/Session-Env | -| `.env.example`, `README.md` | Doku | - ---- - -## 2. Neue Migrationen - -- **`002_auth_identity_tenant_actor.sql`** - - `users` — E-Mail, bcrypt-Hash, `portal_role` (`user` \| `admin`) - - `sessions` — Token, `user_id`, `active_tenant_id`, Ablauf - - `tenants` — Slug, Name, aktiv - - `tenant_memberships` — `tenant_role` (`owner` \| `admin` \| `member`) - - `actors` — `human`, `agent`, `working_group`, `external_system` - - `audit_log` — Auth-Ereignisse - ---- - -## 3. Neue Endpoints - -| Endpoint | Methode | Auth | -|----------|---------|------| -| `/api/auth/login` | POST | — | -| `/api/auth/logout` | POST | `X-Auth-Token` | -| `/api/me` | GET | `X-Auth-Token` | -| `/api/me/context` | GET | `X-Auth-Token` | -| `/api/me/context/required` | GET | `X-Auth-Token` + aktiver Tenant | -| `/api/me/tenant` | POST | `X-Auth-Token` | - -OpenAPI: `/api/docs` (Dev) - ---- - -## 4. Auth-Fluss - -``` -POST /api/auth/login { email, password } - → bcrypt verify - → INSERT sessions (opaque token, expires_at, active_tenant_id = erste Membership) - → audit: auth.login - → Response: { token, expires_at, user } - -Request mit Header X-Auth-Token - → get_session(token) JOIN users - → require_auth → session dict (user_id aus DB, nie aus Client-Header) - -POST /api/auth/logout - → DELETE session - → audit: auth.logout -``` - ---- - -## 5. TenantContext-Auflösung - -``` -require_auth → session (user_id, active_tenant_id, portal_role, …) - → tenant_memberships + tenants prüfen (nur aktive) - → human actor: actors WHERE tenant_id + user_id + type=human - → TenantContext( - portal_role, # Plattform - tenant_role, # Mandant - tenant_id/slug/name, - actor_id/type - ) -``` - -Tenant-Wechsel: `POST /api/me/tenant` aktualisiert `sessions.active_tenant_id` nur bei gültiger Membership. - ---- - -## 6. Tests und Testergebnis - -| Testdatei | Abdeckung | -|-----------|-----------| -| `test_auth.py` | Login, Logout, Session ungültig, `/api/me`, Context, Tenant-Wechsel verweigert | -| `test_tenant_actor.py` | Human↔User, Agent/WG/External ohne User, Portal- vs. Tenant-Rolle | -| `test_migrations.py` | Migration 002 erkannt + idempotent | - -Lokal ausführen (Backend-Container): - -```bash -docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt -docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv -``` - -*(In dieser Session kein Docker lokal — Verifikation über CI nach Push auf `develop`.)* - ---- - -## 7. Abweichungen von den Designprinzipien - -| Prinzip | Abweichung | Begründung | -|---------|------------|------------| -| Mitai `profiles` | Kairo nutzt `users` | Klarere Trennung User ≠ Actor; kein Multi-Profil-Legacy | -| Legacy SHA256-Upgrade | nur bcrypt | Grüne Wiese AP0.2 | -| `require_auth_flexible` (Query-Token) | nicht implementiert | Nicht AP0.2-Scope; SSE/Download später | -| Account-Lifecycle-Gates | fehlen | AP0.3+ | -| Capabilities in TenantContext | leer / nicht modelliert | Bewusst Nicht-Scope | - -Eingehalten: Server-Sessions, `X-Auth-Token`, `Depends(require_auth)` separat, user_id aus Session, Portal- vs. Tenant-Rolle getrennt, TenantContext als eigene Schicht. - ---- - -## 8. Offene Entscheidungen - -1. **Passwort-Policy / Rate-Limiting** für Login — noch nicht implementiert. -2. **Session-Invalidierung** bei Passwort-Reset (Feature kommt später). -3. **Tenant-Erstellung via API** — aktuell nur Bootstrap/DB; Admin-API in späterem AP. -4. **Frontend Auth-UI** — AP0.2 backend-only; SPA-Anbindung folgt. -5. **Prod-Bootstrap** — `KAIRO_BOOTSTRAP_*` in Prod `.env` setzen oder einmalig manuell seeden. - ---- - -## 9. Empfehlung für AP0.3 - -Laut Foundation-Dokument ursprünglich „Capabilities & Rights Registry“ — der User-Auftrag kombinierte Auth+Tenant bereits in AP0.2. - -**AP0.3 Vorschlag:** - -- Capability/Rights Registry (DB + Sync) -- `require_capability()` Dependency -- TenantContext um `capabilities: list[str]` erweitern -- Keine Feature-Limits / Billing -- Optional: Frontend Login-Form + Token-Speicherung - ---- - -*Erstellt im Rahmen Sprint 0 – AP0.2.* diff --git a/docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md b/docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md new file mode 100644 index 0000000..da29b6f --- /dev/null +++ b/docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md @@ -0,0 +1,248 @@ +# AP0.2 – Abschlussbericht Auth, Identity, Tenant & Actor Foundation + +**Status:** abgeschlossen +**Stand:** 2026-07-04 (final) +**Branch:** `develop` · Prod-Deploy auf `main` erfolgt und stabil + +--- + +## 1. Scope und Einordnung + +AP0.2 liefert die **Auth-, Identity-, Tenant- und Actor-Grundlage** für Kairo. Gegenüber dem ursprünglichen Foundation-Dokument (`Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md`) wurde der für AP0.3 vorgesehene **Auth-/TenantContext-Teil bereits in AP0.2** umgesetzt. + +| Anforderung (AP0.2 / erweitert) | Status | +|----------------------------------|--------| +| Tenant, User, Membership | ✓ | +| Actor (human, agent, working_group, external_system) | ✓ | +| Server-Sessions, Login/Logout | ✓ | +| TenantContext + Tenant-Wechsel | ✓ | +| Portal-Rolle vs. Tenant-Rolle getrennt | ✓ | +| Seed für Admin (Env + Erstregistrierung + Dev-Seed) | ✓ | +| Audit bei Auth-Aktionen | ✓ | +| Frontend Login/Register (minimal) | ✓ | +| Idempotentes Data-Seed-System | ✓ (Erweiterung) | + +**Bewusst nicht in AP0.2:** Capabilities/Rights Registry, Rate-Limiting, Tenant-Admin-API, Passwort-Reset. + +--- + +## 2. Umgesetzte Artefakte + +### Backend – Schema & Migrationen + +| Datei | Zweck | +|-------|--------| +| `backend/migrations/002_auth_identity_tenant_actor.sql` | User, Session, Tenant, Membership, Actor, Audit | +| `backend/migrations/003_data_seeds_tracking.sql` | Tabelle `data_seeds` für Seed-Tracking | +| `backend/run_migrations.py` | Schema-Migrationen (`schema_migrations`) | +| `backend/run_seeds.py` | Data-Seeds (Checksum, Dev-Seeds bei jedem Start) | +| `backend/entrypoint.sh` | Migrationen + Seeds vor Uvicorn-Start | + +### Backend – Domäne & Services + +| Datei | Zweck | +|-------|--------| +| `backend/auth.py` | bcrypt, Sessions, `require_auth`, `require_portal_admin` | +| `backend/tenant_context.py` | `TenantContext`, `get_tenant_context`, `require_tenant_context` | +| `backend/bootstrap.py` | Env-Bootstrap (`KAIRO_BOOTSTRAP_*`) | +| `backend/services/registration.py` | Erstregistrierung, `provision_system_admin` | +| `backend/services/dev_admin.py` | Dev-Admin sicherstellen (nur Nicht-Prod) | +| `backend/services/actors.py` | Actor-Erzeugung, Human-Lookup | +| `backend/services/audit.py` | Audit-Log | + +### Backend – API + +| Datei | Zweck | +|-------|--------| +| `backend/routers/auth.py` | Login, Logout, Register, Setup-Status | +| `backend/routers/me.py` | `/api/me`, `/api/me/context`, Tenant-Wechsel | +| `backend/main.py` | Router, Startup (Fallback ohne Entrypoint) | + +### Data-Seeds + +| Seed | Umgebung | Zweck | +|------|----------|--------| +| `seed_001_cleanup_pytest_artifacts.dev.sql` | Dev | Entfernt `*@example.com`, verwaiste Test-Tenants | +| `seed_002_bootstrap_admin.py` | Dev + Prod | Admin aus `KAIRO_BOOTSTRAP_*` wenn DB leer | +| `seed_003_ensure_dev_admin.dev.py` | Dev | Stellt `lars@stommer.com` als Portal-Admin sicher | + +Doku: `docs/MIGRATIONS.md` + +### Frontend + +| Datei | Zweck | +|-------|--------| +| `frontend/src/App.jsx` | Login/Register-Tabs, Session-Anzeige, Setup-Status | +| `frontend/src/app.css` | Auth-UI, Tabs, Hinweise | + +### Tests + +| Datei | Abdeckung | +|-------|-----------| +| `test_auth.py` | Login, Logout, Session, `/api/me`, Context, Tenant-Wechsel | +| `test_tenant_actor.py` | Actor-Typen, Portal- vs. Tenant-Rolle | +| `test_registration.py` | Erstregistrierung, Setup-Status | +| `test_seeds.py` | Seed-Runner, Cleanup, Dev-Admin | +| `test_migrations.py` | Migrationen 001–003, Idempotenz | + +### Infrastruktur & Doku + +| Datei | Zweck | +|-------|--------| +| `docker-compose.dev-env.yml`, `docker-compose.yml` | Postgres pro Stack, Bootstrap/Session-Env, Healthcheck | +| `backend/Dockerfile` | Entrypoint für Startup-Reihenfolge | +| `.gitea/workflows/deploy-dev.yml` | Deploy Dev + Logs bei Fehler | +| `.gitea/workflows/test.yml` | pytest + Seed-Cleanup nach CI | +| `README.md`, `docs/DEPLOYMENT.md` | Local Dev, Auth, DB-Volume-Wechsel | + +**Version:** `APP_VERSION = 0.2.0-ap0.2`, `DB_SCHEMA_VERSION = 003` + +--- + +## 3. Datenmodell (Migration 002) + +- **`users`** — E-Mail, bcrypt-Hash, `portal_role` (`user` \| `admin`) +- **`sessions`** — opaque Token, `user_id`, `active_tenant_id`, Ablauf +- **`tenants`** — Slug (unique), Name, aktiv +- **`tenant_memberships`** — `tenant_role` (`owner` \| `admin` \| `member`) +- **`actors`** — `human`, `agent`, `working_group`, `external_system`; Human verknüpft mit `user_id` +- **`audit_log`** — Auth-Ereignisse (JSONB-Details) + +--- + +## 4. API-Endpunkte + +| Endpoint | Methode | Auth | Beschreibung | +|----------|---------|------|--------------| +| `/api/auth/setup-status` | GET | — | `registration_open`, `has_users`, `user_count` | +| `/api/auth/register` | POST | — | Erster User → Portal-Admin (nur leere DB) | +| `/api/auth/login` | POST | — | E-Mail + Passwort → Session-Token | +| `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen | +| `/api/me` | GET | `X-Auth-Token` | User + Tenant-Liste | +| `/api/me/context` | GET | `X-Auth-Token` | TenantContext (Tenant + Human Actor) | +| `/api/me/context/required` | GET | `X-Auth-Token` | Wie Context, 403 ohne aktiven Tenant | +| `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) | + +OpenAPI (Dev): `/api/docs` + +--- + +## 5. Auth-Fluss + +``` +POST /api/auth/login { email, password } + → bcrypt verify + → INSERT sessions (token, expires_at, active_tenant_id = erste Membership) + → audit: auth.login + → Response: { token, expires_at, user } + +Request mit Header X-Auth-Token + → get_session(token) JOIN users + → require_auth → session (user_id aus DB, nie aus Client-Body) + +POST /api/auth/logout → DELETE session → audit: auth.logout +``` + +**Ersteinrichtung (Priorität):** + +1. **Dev:** Seed `seed_003` — `lars@stommer.com` (Portal-Admin, bei jedem Start) +2. **Prod/Dev:** Seed `seed_002` — `KAIRO_BOOTSTRAP_*` wenn DB leer +3. **UI:** `POST /api/auth/register` — nur wenn `user_count == 0` + +--- + +## 6. TenantContext + +``` +require_auth → session (user_id, active_tenant_id, portal_role) + → tenant_memberships + tenants (nur aktiv) + → human actor: actors WHERE tenant_id + user_id + type = human + → TenantContext(portal_role, tenant_role, tenant, actor) +``` + +Tenant-Wechsel: `POST /api/me/tenant { tenant_id }` — nur bei gültiger Membership. + +--- + +## 7. Deployment & Betrieb + +| | Development | Production | +|---|-------------|------------| +| Verzeichnis | `/home/lars/docker/kairo-dev` | `/home/lars/docker/kairo` | +| Ports | 3097 / 8097 | 3004 / 8004 | +| Postgres | Eigener Container + Volume pro Stack | Eigener Container + Volume | +| Dev-Seeds | Ja (Cleanup + Dev-Admin) | Nein (`.dev.*` übersprungen) | +| Admin-Zugang | `lars@stommer.com` (Seed) | `KAIRO_BOOTSTRAP_*` in `.env` | + +**Startup-Reihenfolge:** Entrypoint → Schema-Migrationen → Data-Seeds → Uvicorn. + +**DB-Zugangsdaten ändern:** Volume neu anlegen (`docker compose down -v`) — siehe `docs/DEPLOYMENT.md`. + +**Verifikation (2026-07-04):** Dev und Prod stabil nach Deploy; Dev-DB-Wechsel mit frischem Volume erfolgreich. + +--- + +## 8. Tests & CI + +```bash +docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt +docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv +``` + +| Bereich | Status | +|---------|--------| +| pytest-backend (Dev/Prod im Container) | ✓ | +| compose-smoke (PR, isolierte Ports) | ✓ | +| k6 Health-Baseline | ✓ | +| Playwright smoke | ✓ | +| Seed-Cleanup nach pytest (Dev) | ✓ | + +**Hinweis Betrieb:** pytest gegen deployte Instanz erzeugt `*@example.com`-User. Dev-Seed räumt auf; auf Prod bleiben Test-User bestehen (Cleanup-Seed läuft dort nicht). Prod-DB bei Bedarf manuell prüfen. + +--- + +## 9. Abweichungen von Designprinzipien + +| Thema | Kairo AP0.2 | Begründung | +|-------|-------------|------------| +| Mitai `profiles` | `users` + separate `actors` | User ≠ Actor, kein Multi-Profil-Legacy | +| Passwort-Hash | nur bcrypt | Grüne Wiese | +| Query-Token / SSE | nicht implementiert | Später | +| Capabilities in TenantContext | fehlen | AP0.3 | +| Zentrale Postgres-Instanz | je Stack eigener Container | Isolation wie Shinkan/Mitai | + +**Eingehalten:** Server-Sessions, `X-Auth-Token`, `Depends(require_auth)`, Portal- vs. Tenant-Rolle, TenantContext-Schicht, nummerierte Migrationen. + +--- + +## 10. Offene Punkte (nach AP0.2) + +1. **Rate-Limiting / Passwort-Policy** für Login +2. **Passwort-Reset** + Session-Invalidierung +3. **Tenant-/User-Verwaltung per API** (Admin-Routen) +4. **Prod-pytest-Cleanup** — optional Seed für `*@example.com` auch in Prod +5. **Frontend** — produktive Shell statt Debug-JSON (UX, AP0.3+) + +--- + +## 11. Empfehlung AP0.3 + +Gemäß Foundation-Dokument: + +- **Capability / Rights Registry** (DB + Runtime-Sync) +- **`require_capability()`** als FastAPI-Dependency +- **TenantContext** um `capabilities: list[str]` erweitern +- Keine Feature-Limits / Billing in Sprint 0 + +--- + +## 12. Referenzen + +- Assignment-Kontext: `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.2 +- Vorgänger: `docs/sprints/Sprint0_AP0_1_Completion_Report_v0.1.md` +- Migrationen/Seeds: `docs/MIGRATIONS.md` +- Deployment: `docs/DEPLOYMENT.md` + +--- + +*Abgeschlossen im Rahmen Sprint 0 – AP0.2. Ersetzt `Sprint0_AP0_2_Completion_Report_v0.1.md`.* -- 2.43.0 From 910b1d7e079cdd413deec59f70649775523cc7de Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 22:54:58 +0200 Subject: [PATCH 3/6] AP0.3: Capability Registry, Entitlements-Snapshot und require_capability. Co-authored-by: Cursor --- README.md | 22 +- backend/capabilities.py | 66 ++++++ backend/entitlements.py | 64 ++++++ backend/entrypoint.sh | 6 + .../migrations/004_capabilities_registry.sql | 23 ++ backend/rights_registrations/__init__.py | 5 + backend/rights_registrations/platform.py | 44 ++++ backend/rights_registrations/tenant_ops.py | 31 +++ backend/rights_registry.py | 124 ++++++++++ backend/routers/me.py | 22 +- backend/sync_rights_registry.py | 17 ++ backend/tenant_context.py | 17 +- backend/tests/conftest.py | 9 + backend/tests/test_capabilities.py | 63 ++++++ backend/tests/test_entitlements.py | 31 +++ backend/tests/test_migrations.py | 2 + backend/tests/test_rights_registry.py | 33 +++ backend/version.py | 4 +- .../Sprint0_AP0_3_Completion_Report_v0.1.md | 213 ++++++++++++++++++ 19 files changed, 788 insertions(+), 8 deletions(-) create mode 100644 backend/capabilities.py create mode 100644 backend/entitlements.py create mode 100644 backend/migrations/004_capabilities_registry.sql create mode 100644 backend/rights_registrations/__init__.py create mode 100644 backend/rights_registrations/platform.py create mode 100644 backend/rights_registrations/tenant_ops.py create mode 100644 backend/rights_registry.py create mode 100644 backend/sync_rights_registry.py create mode 100644 backend/tests/test_capabilities.py create mode 100644 backend/tests/test_entitlements.py create mode 100644 backend/tests/test_rights_registry.py create mode 100644 docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md diff --git a/README.md b/README.md index 6f9cd06..afe3885 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,29 @@ Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md) | `/api/auth/login` | POST | — | E-Mail + Passwort → Session-Token | | `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen | | `/api/me` | GET | `X-Auth-Token` | Aktueller User + Tenant-Liste | -| `/api/me/context` | GET | `X-Auth-Token` | TenantContext (Tenant + Human Actor) | +| `/api/me/context` | GET | `X-Auth-Token` | TenantContext (Tenant + Human Actor + Capabilities) | +| `/api/me/entitlements` | GET | `X-Auth-Token` | Entitlements-Snapshot (Rollen, Capabilities, Features-Platzhalter) | +| `/api/me/admin/demo` | GET | `X-Auth-Token` + `kairo.admin.access` | Beispiel-Endpoint mit `require_capability` | | `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) | +### Capabilities (AP0.3) + +Registry-first: Capabilities werden in `backend/rights_registrations/` registriert und beim Start via `sync_rights_registry.py` in die DB synchronisiert. + +| Capability | Modul | Kurzbeschreibung | +|------------|-------|------------------| +| `kairo.admin.access` | platform | Portal-Administration | +| `kairo.tenant.manage` | tenant | Tenant-Verwaltung | +| `kairo.actor.manage` | tenant | Actor-Verwaltung | +| `kairo.context.read` | platform | TenantContext lesen | +| `kairo.entitlements.read` | platform | Entitlements lesen | + +Enforcement: `CAPABILITY_ENFORCE=probe` (Default, loggt Verweigerungen) oder `enforce` (HTTP 403). + +```bash +curl -s http://localhost:8097/api/me/entitlements -H "X-Auth-Token: TOKEN" +``` + ```bash # Login curl -s -X POST http://localhost:8097/api/auth/login \ diff --git a/backend/capabilities.py b/backend/capabilities.py new file mode 100644 index 0000000..a5509ac --- /dev/null +++ b/backend/capabilities.py @@ -0,0 +1,66 @@ +"""Central capability resolution and FastAPI enforcement dependencies.""" + +from __future__ import annotations + +import os +from typing import Any, Callable + +from fastapi import Depends, HTTPException + +from rights_registry import load_grants_for_roles +from services.audit import log_audit +from tenant_context import TenantContext, require_tenant_context + +CAPABILITY_ENFORCE_ENV = "CAPABILITY_ENFORCE" + + +def capability_enforcement_mode() -> str: + raw = os.getenv(CAPABILITY_ENFORCE_ENV, "probe").strip().lower() + return "enforce" if raw in ("1", "true", "enforce", "yes") else "probe" + + +def resolve_capabilities(*, portal_role: str, tenant_role: str | None) -> frozenset[str]: + return frozenset(load_grants_for_roles(portal_role=portal_role, tenant_role=tenant_role)) + + +def has_capability(ctx: TenantContext, capability_key: str) -> bool: + return capability_key in ctx.capabilities + + +def check_capability(ctx: TenantContext, capability_key: str) -> dict[str, Any]: + allowed = has_capability(ctx, capability_key) + return { + "capability_key": capability_key, + "allowed": allowed, + "reason": None if allowed else "missing_grant", + } + + +def require_capability(capability_key: str) -> Callable[..., TenantContext]: + """FastAPI dependency — blocks in enforce mode, probes in probe mode.""" + + def _dependency(ctx: TenantContext = Depends(require_tenant_context)) -> TenantContext: + result = check_capability(ctx, capability_key) + if result["allowed"]: + return ctx + + log_audit( + "capability.denied", + user_id=ctx.user_id, + tenant_id=ctx.tenant_id, + details={ + "capability_key": capability_key, + "mode": capability_enforcement_mode(), + "portal_role": ctx.portal_role, + "tenant_role": ctx.tenant_role, + }, + ) + + if capability_enforcement_mode() == "enforce": + raise HTTPException( + status_code=403, + detail=f"Capability fehlt: {capability_key}", + ) + return ctx + + return _dependency diff --git a/backend/entitlements.py b/backend/entitlements.py new file mode 100644 index 0000000..d1088f2 --- /dev/null +++ b/backend/entitlements.py @@ -0,0 +1,64 @@ +"""Entitlements snapshot for GET /api/me/entitlements.""" + +from __future__ import annotations + +from typing import Any + +from capabilities import capability_enforcement_mode, check_capability +from rights_registry import get_registered_capabilities +from tenant_context import TenantContext + + +def _capability_map(ctx: TenantContext) -> dict[str, dict[str, Any]]: + registered = {reg.key for reg in get_registered_capabilities()} + snapshot: dict[str, dict[str, Any]] = {} + for key in sorted(registered): + snapshot[key] = check_capability(ctx, key) + return snapshot + + +def build_entitlements_snapshot(ctx: TenantContext) -> dict[str, Any]: + tenant_block = None + if ctx.tenant_id: + tenant_block = { + "tenant_id": ctx.tenant_id, + "slug": ctx.tenant_slug, + "name": ctx.tenant_name, + "role": ctx.tenant_role, + "capabilities": { + key: value + for key, value in _capability_map(ctx).items() + if key in ctx.capabilities + }, + "features": {}, + } + + return { + "account": { + "user_id": ctx.user_id, + "email": ctx.email, + "display_name": ctx.display_name, + "portal_role": ctx.portal_role, + "capabilities": _capability_map(ctx), + "features": {}, + }, + "tenant": tenant_block, + "actor": ( + { + "actor_id": ctx.actor_id, + "actor_type": ctx.actor_type, + } + if ctx.actor_id + else None + ), + "roles": { + "portal": ctx.portal_role, + "tenant": ctx.tenant_role, + }, + "capabilities": sorted(ctx.capabilities), + "features": {}, + "enforcement": { + "capabilities": capability_enforcement_mode(), + "features": "probe", + }, + } diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index b1506e3..89e3195 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -15,6 +15,12 @@ else echo "[SKIP_SEEDS] Data-Seeds übersprungen" fi +if [ "${SKIP_RIGHTS_SYNC}" != "1" ] && [ "${SKIP_RIGHTS_SYNC}" != "true" ] && [ "${SKIP_RIGHTS_SYNC}" != "yes" ]; then + python sync_rights_registry.py +else + echo "[SKIP_RIGHTS_SYNC] Rights-Registry-Sync übersprungen" +fi + export KAIRO_DB_READY=1 echo "=== Starte Anwendung: $* ===" exec "$@" diff --git a/backend/migrations/004_capabilities_registry.sql b/backend/migrations/004_capabilities_registry.sql new file mode 100644 index 0000000..e891d43 --- /dev/null +++ b/backend/migrations/004_capabilities_registry.sql @@ -0,0 +1,23 @@ +-- AP0.3: Capability catalog and role → capability grants + +CREATE TABLE capabilities ( + capability_key VARCHAR(128) PRIMARY KEY, + module VARCHAR(64) NOT NULL, + description TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE role_capability_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_scope VARCHAR(16) NOT NULL + CHECK (role_scope IN ('portal', 'tenant')), + role_code VARCHAR(32) NOT NULL, + capability_key VARCHAR(128) NOT NULL REFERENCES capabilities(capability_key) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (role_scope, role_code, capability_key) +); + +CREATE INDEX idx_role_capability_grants_lookup + ON role_capability_grants(role_scope, role_code); diff --git a/backend/rights_registrations/__init__.py b/backend/rights_registrations/__init__.py new file mode 100644 index 0000000..45a0c03 --- /dev/null +++ b/backend/rights_registrations/__init__.py @@ -0,0 +1,5 @@ +"""Import all module registrations — side effect registers capabilities.""" + +from . import platform, tenant_ops # noqa: F401 + +__all__ = ["platform", "tenant_ops"] diff --git a/backend/rights_registrations/platform.py b/backend/rights_registrations/platform.py new file mode 100644 index 0000000..53bcd36 --- /dev/null +++ b/backend/rights_registrations/platform.py @@ -0,0 +1,44 @@ +"""Platform-level Kairo capabilities.""" + +from __future__ import annotations + +from rights_registry import CapabilityRegistration, register_capability + +register_capability( + CapabilityRegistration( + key="kairo.admin.access", + module="platform", + description="Zugriff auf Portal-Administration und Admin-Endpunkte", + default_grants=(("portal", "admin"),), + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.context.read", + module="platform", + description="TenantContext und Session-Kontext lesen", + default_grants=( + ("portal", "admin"), + ("portal", "user"), + ("tenant", "owner"), + ("tenant", "admin"), + ("tenant", "member"), + ), + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.entitlements.read", + module="platform", + description="Entitlements-Snapshot lesen", + default_grants=( + ("portal", "admin"), + ("portal", "user"), + ("tenant", "owner"), + ("tenant", "admin"), + ("tenant", "member"), + ), + ) +) diff --git a/backend/rights_registrations/tenant_ops.py b/backend/rights_registrations/tenant_ops.py new file mode 100644 index 0000000..4410c4f --- /dev/null +++ b/backend/rights_registrations/tenant_ops.py @@ -0,0 +1,31 @@ +"""Tenant and actor management capabilities.""" + +from __future__ import annotations + +from rights_registry import CapabilityRegistration, register_capability + +register_capability( + CapabilityRegistration( + key="kairo.tenant.manage", + module="tenant", + description="Tenant-Einstellungen und Mandantenverwaltung", + default_grants=( + ("portal", "admin"), + ("tenant", "owner"), + ("tenant", "admin"), + ), + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.actor.manage", + module="tenant", + description="Actors im aktiven Tenant verwalten", + default_grants=( + ("portal", "admin"), + ("tenant", "owner"), + ("tenant", "admin"), + ), + ) +) diff --git a/backend/rights_registry.py b/backend/rights_registry.py new file mode 100644 index 0000000..9683f7c --- /dev/null +++ b/backend/rights_registry.py @@ -0,0 +1,124 @@ +"""In-memory capability registry with startup sync to PostgreSQL.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from db import get_connection + +RoleGrant = tuple[str, str] # (role_scope, role_code) + + +@dataclass(frozen=True) +class CapabilityRegistration: + key: str + module: str + description: str + default_grants: tuple[RoleGrant, ...] = () + + +_REGISTRY: dict[str, CapabilityRegistration] = {} + + +def register_capability(registration: CapabilityRegistration) -> None: + if not registration.key or not registration.key.strip(): + raise ValueError("Capability key is required") + if not registration.module or not registration.module.strip(): + raise ValueError("Capability module is required") + if not registration.description or not registration.description.strip(): + raise ValueError("Capability description is required") + if registration.key in _REGISTRY: + raise ValueError(f"Capability already registered: {registration.key}") + _REGISTRY[registration.key] = registration + + +def get_registered_capabilities() -> tuple[CapabilityRegistration, ...]: + return tuple(_REGISTRY.values()) + + +def clear_registry_for_tests() -> None: + """Test helper — reset in-memory registry.""" + _REGISTRY.clear() + + +def sync_rights_registry_to_db() -> int: + """Upsert capabilities and ensure default grants from registry.""" + if not _REGISTRY: + print("[rights_registry] Keine Capabilities registriert — Sync übersprungen") + return 0 + + conn = get_connection() + try: + with conn.cursor() as cur: + for reg in _REGISTRY.values(): + cur.execute( + """ + INSERT INTO capabilities (capability_key, module, description, is_active) + VALUES (%s, %s, %s, TRUE) + ON CONFLICT (capability_key) DO UPDATE SET + module = EXCLUDED.module, + description = EXCLUDED.description, + is_active = TRUE, + updated_at = NOW() + """, + (reg.key, reg.module, reg.description), + ) + for role_scope, role_code in reg.default_grants: + cur.execute( + """ + INSERT INTO role_capability_grants (role_scope, role_code, capability_key) + VALUES (%s, %s, %s) + ON CONFLICT (role_scope, role_code, capability_key) DO NOTHING + """, + (role_scope, role_code, reg.key), + ) + conn.commit() + print(f"[rights_registry] Sync OK — {len(_REGISTRY)} Capability(s)") + return 0 + except Exception as exc: + conn.rollback() + print(f"[rights_registry] Sync FAIL: {exc}") + return 1 + finally: + conn.close() + + +def load_grants_for_roles( + *, + portal_role: str, + tenant_role: str | None, +) -> set[str]: + """Resolve capability keys granted by portal and optional tenant role.""" + conn = get_connection() + try: + keys: set[str] = set() + with conn.cursor() as cur: + cur.execute( + """ + SELECT c.capability_key + FROM role_capability_grants g + JOIN capabilities c ON c.capability_key = g.capability_key + WHERE g.role_scope = 'portal' + AND g.role_code = %s + AND c.is_active = TRUE + """, + (portal_role,), + ) + keys.update(row[0] for row in cur.fetchall()) + + if tenant_role: + cur.execute( + """ + SELECT c.capability_key + FROM role_capability_grants g + JOIN capabilities c ON c.capability_key = g.capability_key + WHERE g.role_scope = 'tenant' + AND g.role_code = %s + AND c.is_active = TRUE + """, + (tenant_role,), + ) + keys.update(row[0] for row in cur.fetchall()) + return keys + finally: + conn.close() diff --git a/backend/routers/me.py b/backend/routers/me.py index b5834c9..21b3a9d 100644 --- a/backend/routers/me.py +++ b/backend/routers/me.py @@ -1,8 +1,10 @@ -"""Current user and tenant context endpoints.""" +"""Current user, tenant context and entitlements endpoints.""" from __future__ import annotations from auth import get_session, require_auth, set_session_active_tenant +from capabilities import require_capability +from entitlements import build_entitlements_snapshot from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from tenant_context import TenantContext, get_tenant_context, list_user_tenants, require_tenant_context, resolve_tenant_context @@ -34,10 +36,26 @@ def get_my_context(ctx: TenantContext = Depends(get_tenant_context)): @router.get("/context/required") def get_my_context_with_tenant(ctx: TenantContext = Depends(require_tenant_context)): - """Example endpoint requiring an active tenant — for tests and AP0.3 prep.""" + """Endpoint requiring an active tenant.""" return ctx.to_dict() +@router.get("/entitlements") +def get_my_entitlements(ctx: TenantContext = Depends(get_tenant_context)): + return build_entitlements_snapshot(ctx) + + +@router.get("/admin/demo") +def admin_demo(ctx: TenantContext = Depends(require_capability("kairo.admin.access"))): + """Example endpoint protected by require_capability.""" + return { + "ok": True, + "message": "Portal-Admin Capability bestätigt", + "user_id": ctx.user_id, + "tenant_id": ctx.tenant_id, + } + + @router.post("/tenant") def switch_tenant(body: SwitchTenantRequest, session: dict = Depends(require_auth)): ok = set_session_active_tenant(session["token"], body.tenant_id) diff --git a/backend/sync_rights_registry.py b/backend/sync_rights_registry.py new file mode 100644 index 0000000..f090675 --- /dev/null +++ b/backend/sync_rights_registry.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Sync in-memory rights registry to PostgreSQL.""" + +from __future__ import annotations + +import sys + + +def main() -> int: + import rights_registrations # noqa: F401 — side-effect registration + from rights_registry import sync_rights_registry_to_db + + return sync_rights_registry_to_db() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tenant_context.py b/backend/tenant_context.py index 209f1c8..ea9a7e9 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -10,6 +10,7 @@ from psycopg2.extras import RealDictCursor from auth import require_auth from db import get_connection +from rights_registry import load_grants_for_roles from services.actors import get_human_actor @@ -26,6 +27,7 @@ class TenantContext: actor_id: Optional[str] actor_type: Optional[str] session_token: str + capabilities: frozenset[str] def to_dict(self) -> dict[str, Any]: return { @@ -33,6 +35,7 @@ class TenantContext: "email": self.email, "display_name": self.display_name, "portal_role": self.portal_role, + "capabilities": sorted(self.capabilities), "tenant": ( { "id": self.tenant_id, @@ -86,13 +89,15 @@ def list_user_tenants(user_id: str) -> list[dict[str, Any]]: def resolve_tenant_context(session: dict[str, Any]) -> TenantContext: user_id = str(session["user_id"]) tenant_id = session.get("active_tenant_id") + portal_role = session["portal_role"] if not tenant_id: + caps = frozenset(load_grants_for_roles(portal_role=portal_role, tenant_role=None)) return TenantContext( user_id=user_id, email=session["email"], display_name=session["display_name"], - portal_role=session["portal_role"], + portal_role=portal_role, tenant_id=None, tenant_slug=None, tenant_name=None, @@ -100,6 +105,7 @@ def resolve_tenant_context(session: dict[str, Any]) -> TenantContext: actor_id=None, actor_type=None, session_token=session["token"], + capabilities=caps, ) conn = get_connection() @@ -122,19 +128,24 @@ def resolve_tenant_context(session: dict[str, Any]) -> TenantContext: raise HTTPException(status_code=403, detail="Keine gültige Tenant-Mitgliedschaft") tenant_id_str = str(membership["id"]) + tenant_role = membership["tenant_role"] human = get_human_actor(tenant_id_str, user_id) + caps = frozenset( + load_grants_for_roles(portal_role=portal_role, tenant_role=tenant_role) + ) return TenantContext( user_id=user_id, email=session["email"], display_name=session["display_name"], - portal_role=session["portal_role"], + portal_role=portal_role, tenant_id=tenant_id_str, tenant_slug=membership["slug"], tenant_name=membership["name"], - tenant_role=membership["tenant_role"], + tenant_role=tenant_role, actor_id=human["id"] if human else None, actor_type=human["actor_type"] if human else None, session_token=session["token"], + capabilities=caps, ) finally: conn.close() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e765d80..b95fb4c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,6 +9,7 @@ import pytest os.environ.setdefault("SKIP_DB_MIGRATE", "1") os.environ.setdefault("SKIP_SEEDS", "1") os.environ.setdefault("SKIP_BOOTSTRAP", "1") +os.environ.setdefault("SKIP_RIGHTS_SYNC", "1") def _db_available() -> bool: @@ -48,6 +49,14 @@ def _seed_hygiene(): run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) +@pytest.fixture(scope="session", autouse=True) +def _sync_rights_registry(): + import rights_registrations # noqa: F401 + from rights_registry import sync_rights_registry_to_db + + assert sync_rights_registry_to_db() == 0 + + @pytest.fixture() def client(): import importlib diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py new file mode 100644 index 0000000..b09e52d --- /dev/null +++ b/backend/tests/test_capabilities.py @@ -0,0 +1,63 @@ +"""Capability resolution and require_capability tests.""" + +from __future__ import annotations + +from auth import AUTH_HEADER +from tests.factories import provision_user_in_tenant + + +@pytest.fixture() +def enforce_capabilities(monkeypatch): + monkeypatch.setenv("CAPABILITY_ENFORCE", "enforce") + + +def test_portal_admin_has_admin_capability(client, enforce_capabilities): + admin = provision_user_in_tenant(tenant_role="member", portal_role="admin") + login = client.post( + "/api/auth/login", + json={"email": admin["email"], "password": admin["password"]}, + ) + token = login.json()["token"] + + res = client.get("/api/me/admin/demo", headers={AUTH_HEADER: token}) + assert res.status_code == 200 + assert res.json()["ok"] is True + + +def test_tenant_member_denied_admin_capability(client, enforce_capabilities): + member = provision_user_in_tenant(tenant_role="member", portal_role="user") + login = client.post( + "/api/auth/login", + json={"email": member["email"], "password": member["password"]}, + ) + token = login.json()["token"] + + res = client.get("/api/me/admin/demo", headers={AUTH_HEADER: token}) + assert res.status_code == 403 + + +def test_tenant_owner_has_manage_capabilities(client, enforce_capabilities): + owner = provision_user_in_tenant(tenant_role="owner", portal_role="user") + login = client.post( + "/api/auth/login", + json={"email": owner["email"], "password": owner["password"]}, + ) + token = login.json()["token"] + ctx = client.get("/api/me/context", headers={AUTH_HEADER: token}).json() + + assert "kairo.tenant.manage" in ctx["capabilities"] + assert "kairo.actor.manage" in ctx["capabilities"] + assert "kairo.admin.access" not in ctx["capabilities"] + + +def test_probe_mode_allows_without_capability(client, monkeypatch): + monkeypatch.setenv("CAPABILITY_ENFORCE", "probe") + member = provision_user_in_tenant(tenant_role="member", portal_role="user") + login = client.post( + "/api/auth/login", + json={"email": member["email"], "password": member["password"]}, + ) + token = login.json()["token"] + + res = client.get("/api/me/admin/demo", headers={AUTH_HEADER: token}) + assert res.status_code == 200 diff --git a/backend/tests/test_entitlements.py b/backend/tests/test_entitlements.py new file mode 100644 index 0000000..8e423eb --- /dev/null +++ b/backend/tests/test_entitlements.py @@ -0,0 +1,31 @@ +"""Entitlements snapshot tests.""" + +from __future__ import annotations + +from auth import AUTH_HEADER +from tests.factories import provision_user_in_tenant + + +def test_entitlements_snapshot_shape(client): + user = provision_user_in_tenant(tenant_role="owner", portal_role="admin") + login = client.post( + "/api/auth/login", + json={"email": user["email"], "password": user["password"]}, + ) + token = login.json()["token"] + + res = client.get("/api/me/entitlements", headers={AUTH_HEADER: token}) + assert res.status_code == 200 + body = res.json() + + assert "account" in body + assert body["account"]["portal_role"] == "admin" + assert "capabilities" in body["account"] + assert body["tenant"]["tenant_id"] == user["tenant_id"] + assert body["actor"]["actor_id"] == user["actor_id"] + assert body["roles"]["portal"] == "admin" + assert body["roles"]["tenant"] == "owner" + assert "kairo.admin.access" in body["capabilities"] + assert body["enforcement"]["capabilities"] in ("probe", "enforce") + assert body["enforcement"]["features"] == "probe" + assert body["features"] == {} diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 9b011c0..e365c2c 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -34,6 +34,7 @@ def test_migration_runner_finds_migrations(): assert "001_init_core" in names assert "002_auth_identity_tenant_actor" in names assert "003_data_seeds_tracking" in names + assert "004_capabilities_registry" in names def test_migration_runner_is_idempotent(): @@ -46,6 +47,7 @@ def test_migration_runner_is_idempotent(): assert "001_init_core" in executed assert "002_auth_identity_tenant_actor" in executed assert "003_data_seeds_tracking" in executed + assert "004_capabilities_registry" in executed def test_core_table_exists(): diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py new file mode 100644 index 0000000..e29625f --- /dev/null +++ b/backend/tests/test_rights_registry.py @@ -0,0 +1,33 @@ +"""Rights registry and DB sync tests.""" + +from __future__ import annotations + +import rights_registrations # noqa: F401 +from db import get_connection +from rights_registry import get_registered_capabilities, sync_rights_registry_to_db + + +def test_registry_contains_initial_capabilities(): + keys = {reg.key for reg in get_registered_capabilities()} + assert keys == { + "kairo.admin.access", + "kairo.tenant.manage", + "kairo.actor.manage", + "kairo.context.read", + "kairo.entitlements.read", + } + + +def test_sync_is_idempotent(): + assert sync_rights_registry_to_db() == 0 + assert sync_rights_registry_to_db() == 0 + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM capabilities") + assert cur.fetchone()[0] == 5 + cur.execute("SELECT COUNT(*) FROM role_capability_grants") + assert cur.fetchone()[0] >= 5 + finally: + conn.close() diff --git a/backend/version.py b/backend/version.py index 721587c..a69deba 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.2.0-ap0.2" -DB_SCHEMA_VERSION = "003" +APP_VERSION = "0.3.0-ap0.3" +DB_SCHEMA_VERSION = "004" APP_NAME = "jinkendo-kairo" diff --git a/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md new file mode 100644 index 0000000..e872297 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md @@ -0,0 +1,213 @@ +# AP0.3 – Abschlussbericht Capability / Rights Registry & Entitlements Snapshot + +**Status:** abgeschlossen +**Stand:** 2026-07-04 +**Branch:** `develop` + +--- + +## 1. Umgesetzte Dateien + +| Datei | Zweck | +|-------|--------| +| `backend/migrations/004_capabilities_registry.sql` | Tabellen `capabilities`, `role_capability_grants` | +| `backend/rights_registry.py` | In-Memory-Registry, Validierung, DB-Sync, Grant-Lookup | +| `backend/rights_registrations/__init__.py` | Side-Effect-Import aller Modul-Registrierungen | +| `backend/rights_registrations/platform.py` | Platform-Capabilities | +| `backend/rights_registrations/tenant_ops.py` | Tenant-/Actor-Capabilities | +| `backend/capabilities.py` | Auflösung, `check_capability`, `require_capability` | +| `backend/entitlements.py` | Entitlements-Snapshot-Builder | +| `backend/sync_rights_registry.py` | CLI-Sync beim Container-Start | +| `backend/tenant_context.py` | `TenantContext.capabilities` | +| `backend/routers/me.py` | `/entitlements`, `/admin/demo` | +| `backend/entrypoint.sh` | Rights-Sync nach Seeds | +| `backend/version.py` | `0.3.0-ap0.3`, Schema `004` | +| `backend/tests/test_rights_registry.py` | Registry + Sync | +| `backend/tests/test_capabilities.py` | Grants, enforce/probe, Demo-Endpoint | +| `backend/tests/test_entitlements.py` | Snapshot-Form | +| `backend/tests/conftest.py` | Registry-Sync in Test-Session | +| `README.md` | API-Doku AP0.3 | + +--- + +## 2. Neue Migrationen + +**`004_capabilities_registry.sql`** + +- **`capabilities`** — `capability_key`, `module`, `description`, `is_active` +- **`role_capability_grants`** — `role_scope` (`portal` \| `tenant`), `role_code`, `capability_key` + +Kein Feature-/Limit-Schema in AP0.3 (bewusst AP0.4-Vorbereitung). + +--- + +## 3. Registry-Struktur + +``` +backend/ +├── rights_registry.py # register_capability(), sync_rights_registry_to_db() +├── rights_registrations/ +│ ├── __init__.py # import platform, tenant_ops +│ ├── platform.py # admin, context, entitlements +│ └── tenant_ops.py # tenant.manage, actor.manage +└── sync_rights_registry.py # Startup nach Migrationen/Seeds +``` + +**Registrierungsmodell:** `@dataclass(frozen=True) CapabilityRegistration` mit `key`, `module`, `description`, `default_grants: (role_scope, role_code)`. + +**Startup:** `entrypoint.sh` → `python sync_rights_registry.py` (überspringbar via `SKIP_RIGHTS_SYNC=1`). + +--- + +## 4. Endpoints + +| Endpoint | Methode | Auth / Gate | Neu/Geändert | +|----------|---------|-------------|--------------| +| `/api/me/context` | GET | Session | **Geändert** — enthält `capabilities[]` | +| `/api/me/entitlements` | GET | Session | **Neu** | +| `/api/me/admin/demo` | GET | Session + `kairo.admin.access` | **Neu** (Beispiel `require_capability`) | + +Bestehende Auth-/Me-Endpunkte unverändert in Signatur. + +--- + +## 5. Capability-Auflösungslogik + +``` +Session (portal_role, active_tenant_id) + → tenant_membership → tenant_role (optional) + → load_grants_for_roles(portal_role, tenant_role) + SELECT capability_key FROM role_capability_grants + JOIN capabilities WHERE role_scope/role_code match + → Union Portal-Grants + Tenant-Grants + → TenantContext.capabilities (frozenset) +``` + +**`require_capability(key)`:** prüft `key in ctx.capabilities`. + +| Modus | Env | Verhalten | +|-------|-----|-----------| +| probe | `CAPABILITY_ENFORCE=probe` (Default) | Zugriff erlaubt, Audit `capability.denied` | +| enforce | `CAPABILITY_ENFORCE=enforce` | HTTP 403 bei fehlendem Grant | + +--- + +## 6. Initiale Capabilities & Default-Grants + +| Capability | Portal admin | Portal user | Tenant owner | Tenant admin | Tenant member | +|------------|:------------:|:-----------:|:------------:|:------------:|:-------------:| +| `kairo.admin.access` | ✓ | | | | | +| `kairo.tenant.manage` | ✓ | | ✓ | ✓ | | +| `kairo.actor.manage` | ✓ | | ✓ | ✓ | | +| `kairo.context.read` | ✓ | ✓ | ✓ | ✓ | ✓ | +| `kairo.entitlements.read` | ✓ | ✓ | ✓ | ✓ | ✓ | + +--- + +## 7. Tests und Testergebnis + +```bash +docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt +docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv +``` + +| Testdatei | Abdeckung | +|-----------|-----------| +| `test_rights_registry.py` | 5 Capabilities registriert, Sync idempotent, DB-Zeilen | +| `test_capabilities.py` | Admin erlaubt, Member verweigert (enforce), Owner-Grants, probe-Modus | +| `test_entitlements.py` | Snapshot account/tenant/actor/roles/capabilities/enforcement | +| `test_migrations.py` | Migration 004 erkannt | + +*(Lokale Ausführung in dieser Session über CI/Dev-Container nach Push.)* + +--- + +## 8. Übernommene Muster aus Shinkan + +| Muster | Kairo-Umsetzung | +|--------|-----------------| +| Registry-first | `rights_registry.py` + `rights_registrations/` | +| `register_capability()` mit Validierung | Pflichtfelder `key`, `module`, `description` | +| Frozen Dataclass-Definitionen | `CapabilityRegistration` | +| Startup-Sync Registry → DB | `sync_rights_registry_to_db()` | +| Modul-Ownership (`module`-Feld) | `platform`, `tenant` | +| Default Grants in Code | `default_grants` pro Capability | +| `require_capability` Dependency | `capabilities.py` | +| `/api/me/entitlements` Snapshot | `entitlements.py` | +| Probe vs. Enforce | `CAPABILITY_ENFORCE` | + +**Nicht übernommen:** `club_id`, Vereinsrollen, Übungs-/Trainings-Capabilities, Club-Feature-Kontingente, `club_quota_bypass`. + +--- + +## 9. Übernommene Muster aus Mitai + +| Muster | Kairo-Umsetzung | +|--------|-----------------| +| Entitlements als zentrale Auflösungsschicht | `build_entitlements_snapshot()` | +| Probe-vs-Enforce-Denken | Default `probe`, optional `enforce` | +| Feature-Registry-Idee vorbereitet | `features: {}` im Snapshot, Enforcement `probe` | + +**Nicht übernommen:** Tier/Subscription, Usage-Zähler, `check_feature_access`-Legacy, Account-Tier-Limits. + +--- + +## 10. Bewusst nicht übernommen + +- Shinkan-/Mitai-Domänenbegriffe (`club`, `profile`, Tier) +- Feature-Limits, Billing, Coupons, Usage-Zähler +- Prompt Registry, Vorhaben-/Projektlogik +- MCP, produktive Admin-UI +- Capabilities nur in SQL-Migration pflegen +- Governance/Object-ACL (AP später) + +--- + +## 11. Abweichungen von Designprinzipien + +| Thema | Abweichung | Begründung | +|-------|------------|------------| +| Entitlements-Struktur | flache + verschachtelte Blöcke | Kairo Hybrid: Account + Tenant laut Family Model | +| Feature-Registry | leeres Objekt | AP0.4-Scope | +| Admin-Grant-Overrides in DB | Sync fügt nur hinzu (`ON CONFLICT DO NOTHING`) | Sprint-0-Minimal; kein Admin-UI für Overrides | +| `linked_feature_id` | nicht modelliert | Keine Feature-Limits in AP0.3 | + +**Eingehalten:** Auth / Capability / Feature getrennt; Prüfung zentral; Portal- vs. Tenant-Rolle getrennt; TenantContext als Auflösungsschicht. + +--- + +## 12. Offene Entscheidungen + +1. **Prod `CAPABILITY_ENFORCE`** — Default `probe`; wann auf `enforce` umstellen? +2. **Grant-Overrides** — Admin bearbeitet Grants in DB vs. Re-Sync aus Code +3. **Prod-pytest-Cleanup** für `*@example.com` (aus AP0.2) +4. **Feature-Registry** — Modell und Sync in AP0.4 +5. **Frontend** — Entitlements-gestütztes UI-Gating statt Debug-JSON + +--- + +## 13. Empfehlung für AP0.4 + +Laut Foundation-Dokument **AP0.4 – Prompt, Feature, Config Registry**: + +- Feature Registry (Metadaten, ohne Limits in Sprint 0) +- PromptTemplate / PromptVersion / Placeholder +- ConfigurationEntry +- Einfaches Prompt-Rendering mit Placeholder-Validation +- `features`-Block in Entitlements mit Registry-Sync (ohne Billing) + +Optional vor AP0.4: Prod-Cleanup-Seed für pytest-Artefakte; Frontend zeigt `/api/me/entitlements`. + +--- + +## Referenzen + +- `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.3, §9 Entitlements +- `docs/reference/design-principles/shinkan/RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md` +- `docs/reference/design-principles/shinkan/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md` +- `docs/reference/design-principles/alignment/FAMILY_ENTITLEMENT_MODEL.md` +- Vorgänger: `docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md` + +--- + +*Abgeschlossen im Rahmen Sprint 0 – AP0.3.* -- 2.43.0 From eaa0e25524b946c952ba7306d7e4cf188b6f9a85 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 22:57:10 +0200 Subject: [PATCH 4/6] Fix: pytest-Import in test_capabilities ergaenzen. Co-authored-by: Cursor --- backend/tests/test_capabilities.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py index b09e52d..e1fd9e3 100644 --- a/backend/tests/test_capabilities.py +++ b/backend/tests/test_capabilities.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from auth import AUTH_HEADER from tests.factories import provision_user_in_tenant -- 2.43.0 From 4069cb0e0123be2d0f4e6f820aba573d0bb00b79 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 23:01:46 +0200 Subject: [PATCH 5/6] Frontend: Sprint-Anzeige auf AP0.3 aktualisieren. Co-authored-by: Cursor --- frontend/src/App.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5d004e4..4706721 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -183,8 +183,8 @@ export default function App() {

Jinkendo Kairo

-

Operativer Program Director — Sprint 0 / AP0.2

-

Auth, Tenant & Actor Foundation

+

Operativer Program Director — Sprint 0 / AP0.3

+

Capability Registry & Entitlements

-- 2.43.0 From 39e8084b1a2d3896844446e255b6732a862f7406 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 23:03:27 +0200 Subject: [PATCH 6/6] AP0.3 Abschlussbericht v0.2 (final) erstellen. Co-authored-by: Cursor --- .../Sprint0_AP0_3_Completion_Report_v0.1.md | 213 --------------- .../Sprint0_AP0_3_Completion_Report_v0.2.md | 246 ++++++++++++++++++ 2 files changed, 246 insertions(+), 213 deletions(-) delete mode 100644 docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md create mode 100644 docs/sprints/Sprint0_AP0_3_Completion_Report_v0.2.md diff --git a/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md deleted file mode 100644 index e872297..0000000 --- a/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md +++ /dev/null @@ -1,213 +0,0 @@ -# AP0.3 – Abschlussbericht Capability / Rights Registry & Entitlements Snapshot - -**Status:** abgeschlossen -**Stand:** 2026-07-04 -**Branch:** `develop` - ---- - -## 1. Umgesetzte Dateien - -| Datei | Zweck | -|-------|--------| -| `backend/migrations/004_capabilities_registry.sql` | Tabellen `capabilities`, `role_capability_grants` | -| `backend/rights_registry.py` | In-Memory-Registry, Validierung, DB-Sync, Grant-Lookup | -| `backend/rights_registrations/__init__.py` | Side-Effect-Import aller Modul-Registrierungen | -| `backend/rights_registrations/platform.py` | Platform-Capabilities | -| `backend/rights_registrations/tenant_ops.py` | Tenant-/Actor-Capabilities | -| `backend/capabilities.py` | Auflösung, `check_capability`, `require_capability` | -| `backend/entitlements.py` | Entitlements-Snapshot-Builder | -| `backend/sync_rights_registry.py` | CLI-Sync beim Container-Start | -| `backend/tenant_context.py` | `TenantContext.capabilities` | -| `backend/routers/me.py` | `/entitlements`, `/admin/demo` | -| `backend/entrypoint.sh` | Rights-Sync nach Seeds | -| `backend/version.py` | `0.3.0-ap0.3`, Schema `004` | -| `backend/tests/test_rights_registry.py` | Registry + Sync | -| `backend/tests/test_capabilities.py` | Grants, enforce/probe, Demo-Endpoint | -| `backend/tests/test_entitlements.py` | Snapshot-Form | -| `backend/tests/conftest.py` | Registry-Sync in Test-Session | -| `README.md` | API-Doku AP0.3 | - ---- - -## 2. Neue Migrationen - -**`004_capabilities_registry.sql`** - -- **`capabilities`** — `capability_key`, `module`, `description`, `is_active` -- **`role_capability_grants`** — `role_scope` (`portal` \| `tenant`), `role_code`, `capability_key` - -Kein Feature-/Limit-Schema in AP0.3 (bewusst AP0.4-Vorbereitung). - ---- - -## 3. Registry-Struktur - -``` -backend/ -├── rights_registry.py # register_capability(), sync_rights_registry_to_db() -├── rights_registrations/ -│ ├── __init__.py # import platform, tenant_ops -│ ├── platform.py # admin, context, entitlements -│ └── tenant_ops.py # tenant.manage, actor.manage -└── sync_rights_registry.py # Startup nach Migrationen/Seeds -``` - -**Registrierungsmodell:** `@dataclass(frozen=True) CapabilityRegistration` mit `key`, `module`, `description`, `default_grants: (role_scope, role_code)`. - -**Startup:** `entrypoint.sh` → `python sync_rights_registry.py` (überspringbar via `SKIP_RIGHTS_SYNC=1`). - ---- - -## 4. Endpoints - -| Endpoint | Methode | Auth / Gate | Neu/Geändert | -|----------|---------|-------------|--------------| -| `/api/me/context` | GET | Session | **Geändert** — enthält `capabilities[]` | -| `/api/me/entitlements` | GET | Session | **Neu** | -| `/api/me/admin/demo` | GET | Session + `kairo.admin.access` | **Neu** (Beispiel `require_capability`) | - -Bestehende Auth-/Me-Endpunkte unverändert in Signatur. - ---- - -## 5. Capability-Auflösungslogik - -``` -Session (portal_role, active_tenant_id) - → tenant_membership → tenant_role (optional) - → load_grants_for_roles(portal_role, tenant_role) - SELECT capability_key FROM role_capability_grants - JOIN capabilities WHERE role_scope/role_code match - → Union Portal-Grants + Tenant-Grants - → TenantContext.capabilities (frozenset) -``` - -**`require_capability(key)`:** prüft `key in ctx.capabilities`. - -| Modus | Env | Verhalten | -|-------|-----|-----------| -| probe | `CAPABILITY_ENFORCE=probe` (Default) | Zugriff erlaubt, Audit `capability.denied` | -| enforce | `CAPABILITY_ENFORCE=enforce` | HTTP 403 bei fehlendem Grant | - ---- - -## 6. Initiale Capabilities & Default-Grants - -| Capability | Portal admin | Portal user | Tenant owner | Tenant admin | Tenant member | -|------------|:------------:|:-----------:|:------------:|:------------:|:-------------:| -| `kairo.admin.access` | ✓ | | | | | -| `kairo.tenant.manage` | ✓ | | ✓ | ✓ | | -| `kairo.actor.manage` | ✓ | | ✓ | ✓ | | -| `kairo.context.read` | ✓ | ✓ | ✓ | ✓ | ✓ | -| `kairo.entitlements.read` | ✓ | ✓ | ✓ | ✓ | ✓ | - ---- - -## 7. Tests und Testergebnis - -```bash -docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt -docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv -``` - -| Testdatei | Abdeckung | -|-----------|-----------| -| `test_rights_registry.py` | 5 Capabilities registriert, Sync idempotent, DB-Zeilen | -| `test_capabilities.py` | Admin erlaubt, Member verweigert (enforce), Owner-Grants, probe-Modus | -| `test_entitlements.py` | Snapshot account/tenant/actor/roles/capabilities/enforcement | -| `test_migrations.py` | Migration 004 erkannt | - -*(Lokale Ausführung in dieser Session über CI/Dev-Container nach Push.)* - ---- - -## 8. Übernommene Muster aus Shinkan - -| Muster | Kairo-Umsetzung | -|--------|-----------------| -| Registry-first | `rights_registry.py` + `rights_registrations/` | -| `register_capability()` mit Validierung | Pflichtfelder `key`, `module`, `description` | -| Frozen Dataclass-Definitionen | `CapabilityRegistration` | -| Startup-Sync Registry → DB | `sync_rights_registry_to_db()` | -| Modul-Ownership (`module`-Feld) | `platform`, `tenant` | -| Default Grants in Code | `default_grants` pro Capability | -| `require_capability` Dependency | `capabilities.py` | -| `/api/me/entitlements` Snapshot | `entitlements.py` | -| Probe vs. Enforce | `CAPABILITY_ENFORCE` | - -**Nicht übernommen:** `club_id`, Vereinsrollen, Übungs-/Trainings-Capabilities, Club-Feature-Kontingente, `club_quota_bypass`. - ---- - -## 9. Übernommene Muster aus Mitai - -| Muster | Kairo-Umsetzung | -|--------|-----------------| -| Entitlements als zentrale Auflösungsschicht | `build_entitlements_snapshot()` | -| Probe-vs-Enforce-Denken | Default `probe`, optional `enforce` | -| Feature-Registry-Idee vorbereitet | `features: {}` im Snapshot, Enforcement `probe` | - -**Nicht übernommen:** Tier/Subscription, Usage-Zähler, `check_feature_access`-Legacy, Account-Tier-Limits. - ---- - -## 10. Bewusst nicht übernommen - -- Shinkan-/Mitai-Domänenbegriffe (`club`, `profile`, Tier) -- Feature-Limits, Billing, Coupons, Usage-Zähler -- Prompt Registry, Vorhaben-/Projektlogik -- MCP, produktive Admin-UI -- Capabilities nur in SQL-Migration pflegen -- Governance/Object-ACL (AP später) - ---- - -## 11. Abweichungen von Designprinzipien - -| Thema | Abweichung | Begründung | -|-------|------------|------------| -| Entitlements-Struktur | flache + verschachtelte Blöcke | Kairo Hybrid: Account + Tenant laut Family Model | -| Feature-Registry | leeres Objekt | AP0.4-Scope | -| Admin-Grant-Overrides in DB | Sync fügt nur hinzu (`ON CONFLICT DO NOTHING`) | Sprint-0-Minimal; kein Admin-UI für Overrides | -| `linked_feature_id` | nicht modelliert | Keine Feature-Limits in AP0.3 | - -**Eingehalten:** Auth / Capability / Feature getrennt; Prüfung zentral; Portal- vs. Tenant-Rolle getrennt; TenantContext als Auflösungsschicht. - ---- - -## 12. Offene Entscheidungen - -1. **Prod `CAPABILITY_ENFORCE`** — Default `probe`; wann auf `enforce` umstellen? -2. **Grant-Overrides** — Admin bearbeitet Grants in DB vs. Re-Sync aus Code -3. **Prod-pytest-Cleanup** für `*@example.com` (aus AP0.2) -4. **Feature-Registry** — Modell und Sync in AP0.4 -5. **Frontend** — Entitlements-gestütztes UI-Gating statt Debug-JSON - ---- - -## 13. Empfehlung für AP0.4 - -Laut Foundation-Dokument **AP0.4 – Prompt, Feature, Config Registry**: - -- Feature Registry (Metadaten, ohne Limits in Sprint 0) -- PromptTemplate / PromptVersion / Placeholder -- ConfigurationEntry -- Einfaches Prompt-Rendering mit Placeholder-Validation -- `features`-Block in Entitlements mit Registry-Sync (ohne Billing) - -Optional vor AP0.4: Prod-Cleanup-Seed für pytest-Artefakte; Frontend zeigt `/api/me/entitlements`. - ---- - -## Referenzen - -- `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.3, §9 Entitlements -- `docs/reference/design-principles/shinkan/RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md` -- `docs/reference/design-principles/shinkan/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md` -- `docs/reference/design-principles/alignment/FAMILY_ENTITLEMENT_MODEL.md` -- Vorgänger: `docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md` - ---- - -*Abgeschlossen im Rahmen Sprint 0 – AP0.3.* diff --git a/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.2.md b/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.2.md new file mode 100644 index 0000000..e553cc3 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_3_Completion_Report_v0.2.md @@ -0,0 +1,246 @@ +# AP0.3 – Abschlussbericht Capability / Rights Registry & Entitlements Snapshot + +**Status:** abgeschlossen +**Stand:** 2026-07-04 (final) +**Branch:** `develop` · Dev-Deploy und Test Suite grün · Prod stabil (Stand Nutzer) + +--- + +## 1. Scope und Einordnung + +AP0.3 liefert die **minimale Capability-/Rights-Grundlage** und den **Entitlements-Snapshot** für Sprint 0. Auth, TenantContext und Sessions stammen aus AP0.2; AP0.3 ergänzt Registry, DB-Sync, zentrale Capability-Prüfung und `/api/me/entitlements`. + +| Anforderung (AP0.3) | Status | +|---------------------|--------| +| Migration Capabilities + Role-Grants | ✓ | +| Runtime Registry + Modul-Registrierungen | ✓ | +| Startup-Sync Registry → DB | ✓ | +| TenantContext.capabilities | ✓ | +| `require_capability()` Dependency | ✓ | +| `GET /api/me/entitlements` | ✓ | +| Beispiel-Endpoint mit Capability-Gate | ✓ | +| Tests (Registry, Sync, Auflösung, Enforce/Probe) | ✓ | +| Trennung Auth / Capability / Feature | ✓ | + +**Bewusst nicht in AP0.3:** Feature-Limits, Billing, Usage-Zähler, Prompt Registry, produktive Admin-UI, Governance/Object-ACL. + +--- + +## 2. Umgesetzte Dateien + +| Datei | Zweck | +|-------|--------| +| `backend/migrations/004_capabilities_registry.sql` | Tabellen `capabilities`, `role_capability_grants` | +| `backend/rights_registry.py` | In-Memory-Registry, Validierung, DB-Sync, Grant-Lookup | +| `backend/rights_registrations/__init__.py` | Side-Effect-Import aller Modul-Registrierungen | +| `backend/rights_registrations/platform.py` | Platform-Capabilities | +| `backend/rights_registrations/tenant_ops.py` | Tenant-/Actor-Capabilities | +| `backend/capabilities.py` | Auflösung, `check_capability`, `require_capability` | +| `backend/entitlements.py` | Entitlements-Snapshot-Builder | +| `backend/sync_rights_registry.py` | CLI-Sync beim Container-Start | +| `backend/tenant_context.py` | `TenantContext.capabilities` | +| `backend/routers/me.py` | `/entitlements`, `/admin/demo` | +| `backend/entrypoint.sh` | Rights-Sync nach Seeds | +| `backend/version.py` | `0.3.0-ap0.3`, Schema `004` | +| `backend/tests/test_rights_registry.py` | Registry + Sync | +| `backend/tests/test_capabilities.py` | Grants, enforce/probe, Demo-Endpoint | +| `backend/tests/test_entitlements.py` | Snapshot-Form | +| `backend/tests/conftest.py` | Registry-Sync in Test-Session | +| `frontend/src/App.jsx` | Header AP0.3; `/api/me/context` zeigt Capabilities | +| `README.md` | API-Doku AP0.3 | + +**Wesentliche Commits:** `910b1d7` (Implementierung), `eaa0e25` (pytest-Fix), `4069cb0` (GUI-Sprint-Label). + +--- + +## 3. Neue Migrationen + +**`004_capabilities_registry.sql`** + +- **`capabilities`** — `capability_key`, `module`, `description`, `is_active` +- **`role_capability_grants`** — `role_scope` (`portal` \| `tenant`), `role_code`, `capability_key` + +Kein Feature-/Limit-Schema (Vorbereitung AP0.4). + +--- + +## 4. Registry-Struktur + +``` +backend/ +├── rights_registry.py +├── rights_registrations/ +│ ├── __init__.py +│ ├── platform.py # admin, context, entitlements +│ └── tenant_ops.py # tenant.manage, actor.manage +└── sync_rights_registry.py +``` + +**Modell:** `@dataclass(frozen=True) CapabilityRegistration` mit `key`, `module`, `description`, `default_grants`. + +**Startup:** `entrypoint.sh` → `python sync_rights_registry.py` (`SKIP_RIGHTS_SYNC=1` zum Überspringen). + +--- + +## 5. Endpoints + +| Endpoint | Methode | Auth / Gate | Status | +|----------|---------|-------------|--------| +| `/api/me/context` | GET | Session | geändert — `capabilities[]` | +| `/api/me/entitlements` | GET | Session | neu | +| `/api/me/admin/demo` | GET | Session + `kairo.admin.access` | neu (Beispiel `require_capability`) | + +OpenAPI (Dev): `/api/docs` + +--- + +## 6. Capability-Auflösungslogik + +``` +Session (portal_role, active_tenant_id) + → tenant_role aus Membership (optional) + → load_grants_for_roles(portal_role, tenant_role) + → Union Portal-Grants + Tenant-Grants + → TenantContext.capabilities (frozenset) +``` + +**`require_capability(key)`:** prüft Mitgliedschaft in `ctx.capabilities`. + +| Modus | Env | Verhalten | +|-------|-----|-----------| +| probe | `CAPABILITY_ENFORCE=probe` (Default) | Zugriff erlaubt; Verweigerung → Audit `capability.denied` | +| enforce | `CAPABILITY_ENFORCE=enforce` | HTTP 403 ohne Grant | + +--- + +## 7. Initiale Capabilities + +| Capability | Portal admin | Portal user | Tenant owner | Tenant admin | Tenant member | +|------------|:------------:|:-----------:|:------------:|:------------:|:-------------:| +| `kairo.admin.access` | ✓ | | | | | +| `kairo.tenant.manage` | ✓ | | ✓ | ✓ | | +| `kairo.actor.manage` | ✓ | | ✓ | ✓ | | +| `kairo.context.read` | ✓ | ✓ | ✓ | ✓ | ✓ | +| `kairo.entitlements.read` | ✓ | ✓ | ✓ | ✓ | ✓ | + +--- + +## 8. Tests und Verifikation + +### pytest (CI, Dev-Container) + +```bash +docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv +``` + +| Testdatei | Abdeckung | Status | +|-----------|-----------|--------| +| `test_rights_registry.py` | 5 Capabilities, Sync idempotent | ✓ | +| `test_capabilities.py` | Admin/Member enforce, Owner-Grants, probe | ✓ | +| `test_entitlements.py` | Snapshot-Struktur | ✓ | +| `test_migrations.py` | Migration 004 | ✓ | +| Gesamt Test Suite (Gitea) | nach Push `develop` | ✓ (Nutzer bestätigt) | + +### Manuell (Dev-GUI) + +| Prüfung | Ergebnis | +|---------|----------| +| Login `lars@stommer.com` | ✓ | +| `/api/me/context` → `capabilities` sichtbar | ✓ | +| API Health → `"schema": "004"` | ✓ | +| Header Sprint-Anzeige AP0.3 | ✓ (nach `4069cb0`) | + +**Hinweis:** Entitlements und Admin-Demo sind in der GUI noch nicht als eigene Karten — prüfbar über Swagger (`/api/docs`) oder DevTools. + +--- + +## 9. Übernommene Muster aus Shinkan + +| Muster | Kairo-Umsetzung | +|--------|-----------------| +| Registry-first | `rights_registry.py` + `rights_registrations/` | +| `register_capability()` mit Validierung | Pflichtfelder `key`, `module`, `description` | +| Frozen Dataclass-Definitionen | `CapabilityRegistration` | +| Startup-Sync Registry → DB | `sync_rights_registry_to_db()` | +| Modul-Ownership | `platform`, `tenant` | +| Default Grants in Code | `default_grants` pro Capability | +| `require_capability` Dependency | `capabilities.py` | +| `/api/me/entitlements` Snapshot | `entitlements.py` | +| Probe vs. Enforce | `CAPABILITY_ENFORCE` | + +**Nicht übernommen:** `club_id`, Vereinsrollen, Übungs-/Trainingsrechte, Club-Feature-Kontingente. + +--- + +## 10. Übernommene Muster aus Mitai + +| Muster | Kairo-Umsetzung | +|--------|-----------------| +| Entitlements als zentrale Schicht | `build_entitlements_snapshot()` | +| Probe-vs-Enforce-Denken | Default `probe`, optional `enforce` | +| Feature-Registry vorbereitet | `features: {}` im Snapshot | + +**Nicht übernommen:** Tier/Subscription, Usage-Zähler, Billing, Legacy `check_feature_access`. + +--- + +## 11. Bewusst nicht übernommen + +- Shinkan-/Mitai-Domänenbegriffe (`club`, `profile`, Tier) +- Feature-Limits, Billing, Coupons +- Prompt Registry, Vorhaben-/Projektlogik +- MCP, produktive Admin-UI (nur Debug-JSON + Swagger) +- Capabilities nur in SQL pflegen +- Governance/Object-ACL + +--- + +## 12. Abweichungen von Designprinzipien + +| Thema | Abweichung | Begründung | +|-------|------------|------------| +| Entitlements-Struktur | Account- + Tenant-Block | Kairo Hybrid laut Family Entitlement Model | +| Feature-Registry | leeres Objekt | AP0.4 | +| Grant-Overrides | Sync nur additive Grants | Sprint-0-Minimal | +| `linked_feature_id` | nicht modelliert | Keine Limits in AP0.3 | +| Frontend | kein Entitlements-Panel | Minimal-UI; AP0.4/UX optional | + +**Eingehalten:** zentrale Capability-Prüfung; Portal- vs. Tenant-Rolle getrennt; TenantContext als Auflösungsschicht. + +--- + +## 13. Offene Entscheidungen + +1. **Prod `CAPABILITY_ENFORCE=enforce`** — wann umstellen? +2. **Grant-Overrides in DB** vs. reiner Code-Kanon +3. **Prod-pytest-Cleanup** (`*@example.com`) — aus AP0.2 offen +4. **Frontend:** Entitlements-Karte + Admin-Demo-Button +5. **Feature-Registry** — AP0.4 + +--- + +## 14. Empfehlung für AP0.4 + +Laut Foundation **AP0.4 – Prompt, Feature, Config Registry**: + +- Feature Registry (Metadaten, ohne Billing-Limits in Sprint 0) +- PromptTemplate / PromptVersion / Placeholder +- ConfigurationEntry +- Prompt-Rendering mit Placeholder-Validation +- `features`-Block in Entitlements mit Registry-Sync + +Optional vor AP0.4: Prod-Cleanup-Seed; GUI-Entitlements; `CAPABILITY_ENFORCE=enforce` in Prod nach Kalibrierung. + +--- + +## Referenzen + +- `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.3, §9 +- `docs/reference/design-principles/shinkan/RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md` +- `docs/reference/design-principles/shinkan/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md` +- `docs/reference/design-principles/alignment/FAMILY_ENTITLEMENT_MODEL.md` +- Vorgänger: `docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md` + +--- + +*Abgeschlossen im Rahmen Sprint 0 – AP0.3. Ersetzt `Sprint0_AP0_3_Completion_Report_v0.1.md`.* -- 2.43.0