Ap03 #4

Merged
Lars merged 6 commits from develop into main 2026-07-04 23:04:11 +02:00
26 changed files with 1128 additions and 197 deletions

View File

@ -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/login` | POST | — | E-Mail + Passwort → Session-Token |
| `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen | | `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen |
| `/api/me` | GET | `X-Auth-Token` | Aktueller User + Tenant-Liste | | `/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) | | `/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 ```bash
# Login # Login
curl -s -X POST http://localhost:8097/api/auth/login \ curl -s -X POST http://localhost:8097/api/auth/login \

66
backend/capabilities.py Normal file
View File

@ -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

View File

@ -1,6 +1,9 @@
"""PostgreSQL connection helpers.""" """PostgreSQL connection helpers."""
from __future__ import annotations
import os import os
import time
import psycopg2 import psycopg2
from psycopg2.extensions import connection 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: def check_db() -> bool:
conn = get_connection() conn = get_connection()
try: try:

64
backend/entitlements.py Normal file
View File

@ -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",
},
}

View File

@ -15,6 +15,12 @@ else
echo "[SKIP_SEEDS] Data-Seeds übersprungen" echo "[SKIP_SEEDS] Data-Seeds übersprungen"
fi 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 export KAIRO_DB_READY=1
echo "=== Starte Anwendung: $* ===" echo "=== Starte Anwendung: $* ==="
exec "$@" exec "$@"

View File

@ -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);

View File

@ -0,0 +1,5 @@
"""Import all module registrations — side effect registers capabilities."""
from . import platform, tenant_ops # noqa: F401
__all__ = ["platform", "tenant_ops"]

View File

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

View File

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

124
backend/rights_registry.py Normal file
View File

@ -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()

View File

@ -1,8 +1,10 @@
"""Current user and tenant context endpoints.""" """Current user, tenant context and entitlements endpoints."""
from __future__ import annotations from __future__ import annotations
from auth import get_session, require_auth, set_session_active_tenant 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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from tenant_context import TenantContext, get_tenant_context, list_user_tenants, require_tenant_context, resolve_tenant_context 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") @router.get("/context/required")
def get_my_context_with_tenant(ctx: TenantContext = Depends(require_tenant_context)): 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() 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") @router.post("/tenant")
def switch_tenant(body: SwitchTenantRequest, session: dict = Depends(require_auth)): def switch_tenant(body: SwitchTenantRequest, session: dict = Depends(require_auth)):
ok = set_session_active_tenant(session["token"], body.tenant_id) ok = set_session_active_tenant(session["token"], body.tenant_id)

View File

@ -8,13 +8,12 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import time
from typing import List, Tuple from typing import List, Tuple
import psycopg2 import psycopg2
import sqlparse 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+)") _LEADING_DIGITS = re.compile(r"^(\d+)")
@ -135,21 +134,6 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool:
return False 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: def migrations_directory() -> str:
docker_path = "/app/migrations" docker_path = "/app/migrations"
if os.path.isdir(docker_path): if os.path.isdir(docker_path):

View File

@ -14,7 +14,7 @@ from typing import Callable, List, Optional, Tuple
import psycopg2 import psycopg2
import sqlparse 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+)_(.+)$") _SEED_PREFIX = re.compile(r"^seed_(\d+)_(.+)$")
_DEV_MARKER = ".dev." _DEV_MARKER = ".dev."
@ -152,21 +152,6 @@ def run_python_seed(filepath: str) -> None:
run_fn() 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]: def run_seed(conn, seed_name: str, filepath: str, kind: str) -> tuple[bool, object]:
print(f"Running seed: {seed_name}") print(f"Running seed: {seed_name}")
try: try:

View File

@ -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())

View File

@ -10,6 +10,7 @@ from psycopg2.extras import RealDictCursor
from auth import require_auth from auth import require_auth
from db import get_connection from db import get_connection
from rights_registry import load_grants_for_roles
from services.actors import get_human_actor from services.actors import get_human_actor
@ -26,6 +27,7 @@ class TenantContext:
actor_id: Optional[str] actor_id: Optional[str]
actor_type: Optional[str] actor_type: Optional[str]
session_token: str session_token: str
capabilities: frozenset[str]
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@ -33,6 +35,7 @@ class TenantContext:
"email": self.email, "email": self.email,
"display_name": self.display_name, "display_name": self.display_name,
"portal_role": self.portal_role, "portal_role": self.portal_role,
"capabilities": sorted(self.capabilities),
"tenant": ( "tenant": (
{ {
"id": self.tenant_id, "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: def resolve_tenant_context(session: dict[str, Any]) -> TenantContext:
user_id = str(session["user_id"]) user_id = str(session["user_id"])
tenant_id = session.get("active_tenant_id") tenant_id = session.get("active_tenant_id")
portal_role = session["portal_role"]
if not tenant_id: if not tenant_id:
caps = frozenset(load_grants_for_roles(portal_role=portal_role, tenant_role=None))
return TenantContext( return TenantContext(
user_id=user_id, user_id=user_id,
email=session["email"], email=session["email"],
display_name=session["display_name"], display_name=session["display_name"],
portal_role=session["portal_role"], portal_role=portal_role,
tenant_id=None, tenant_id=None,
tenant_slug=None, tenant_slug=None,
tenant_name=None, tenant_name=None,
@ -100,6 +105,7 @@ def resolve_tenant_context(session: dict[str, Any]) -> TenantContext:
actor_id=None, actor_id=None,
actor_type=None, actor_type=None,
session_token=session["token"], session_token=session["token"],
capabilities=caps,
) )
conn = get_connection() 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") raise HTTPException(status_code=403, detail="Keine gültige Tenant-Mitgliedschaft")
tenant_id_str = str(membership["id"]) tenant_id_str = str(membership["id"])
tenant_role = membership["tenant_role"]
human = get_human_actor(tenant_id_str, user_id) 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( return TenantContext(
user_id=user_id, user_id=user_id,
email=session["email"], email=session["email"],
display_name=session["display_name"], display_name=session["display_name"],
portal_role=session["portal_role"], portal_role=portal_role,
tenant_id=tenant_id_str, tenant_id=tenant_id_str,
tenant_slug=membership["slug"], tenant_slug=membership["slug"],
tenant_name=membership["name"], tenant_name=membership["name"],
tenant_role=membership["tenant_role"], tenant_role=tenant_role,
actor_id=human["id"] if human else None, actor_id=human["id"] if human else None,
actor_type=human["actor_type"] if human else None, actor_type=human["actor_type"] if human else None,
session_token=session["token"], session_token=session["token"],
capabilities=caps,
) )
finally: finally:
conn.close() conn.close()

View File

@ -9,6 +9,7 @@ import pytest
os.environ.setdefault("SKIP_DB_MIGRATE", "1") os.environ.setdefault("SKIP_DB_MIGRATE", "1")
os.environ.setdefault("SKIP_SEEDS", "1") os.environ.setdefault("SKIP_SEEDS", "1")
os.environ.setdefault("SKIP_BOOTSTRAP", "1") os.environ.setdefault("SKIP_BOOTSTRAP", "1")
os.environ.setdefault("SKIP_RIGHTS_SYNC", "1")
def _db_available() -> bool: def _db_available() -> bool:
@ -48,6 +49,14 @@ def _seed_hygiene():
run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) 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() @pytest.fixture()
def client(): def client():
import importlib import importlib

View File

@ -0,0 +1,65 @@
"""Capability resolution and require_capability tests."""
from __future__ import annotations
import pytest
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

View File

@ -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"] == {}

View File

@ -34,6 +34,7 @@ def test_migration_runner_finds_migrations():
assert "001_init_core" in names assert "001_init_core" in names
assert "002_auth_identity_tenant_actor" in names assert "002_auth_identity_tenant_actor" in names
assert "003_data_seeds_tracking" in names assert "003_data_seeds_tracking" in names
assert "004_capabilities_registry" in names
def test_migration_runner_is_idempotent(): def test_migration_runner_is_idempotent():
@ -46,6 +47,7 @@ def test_migration_runner_is_idempotent():
assert "001_init_core" in executed assert "001_init_core" in executed
assert "002_auth_identity_tenant_actor" in executed assert "002_auth_identity_tenant_actor" in executed
assert "003_data_seeds_tracking" in executed assert "003_data_seeds_tracking" in executed
assert "004_capabilities_registry" in executed
def test_core_table_exists(): def test_core_table_exists():

View File

@ -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()

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.2.0-ap0.2" APP_VERSION = "0.3.0-ap0.3"
DB_SCHEMA_VERSION = "003" DB_SCHEMA_VERSION = "004"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -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 ## Manuelles Deploy
```bash ```bash

View File

@ -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.*

View File

@ -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 001003, 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`.*

View File

@ -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`.*

View File

@ -183,8 +183,8 @@ export default function App() {
<main className="shell"> <main className="shell">
<header className="hero"> <header className="hero">
<h1>Jinkendo Kairo</h1> <h1>Jinkendo Kairo</h1>
<p>Operativer Program Director Sprint 0 / AP0.2</p> <p>Operativer Program Director Sprint 0 / AP0.3</p>
<p className="muted">Auth, Tenant &amp; Actor Foundation</p> <p className="muted">Capability Registry &amp; Entitlements</p>
</header> </header>
<section className="card auth-card"> <section className="card auth-card">