Compare commits
No commits in common. "9e271a1954ed27470db7fdd8a0dc447ed06f399d" and "7a5da57bb861b1229bd562023a6067b4a9f2b3f2" have entirely different histories.
9e271a1954
...
7a5da57bb8
22
README.md
22
README.md
|
|
@ -127,29 +127,9 @@ 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 + Capabilities) |
|
| `/api/me/context` | GET | `X-Auth-Token` | TenantContext (Tenant + Human Actor) |
|
||||||
| `/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 \
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
"""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
|
||||||
|
|
@ -30,40 +27,6 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
"""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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -15,12 +15,6 @@ 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 "$@"
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- 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);
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
"""Import all module registrations — side effect registers capabilities."""
|
|
||||||
|
|
||||||
from . import platform, tenant_ops # noqa: F401
|
|
||||||
|
|
||||||
__all__ = ["platform", "tenant_ops"]
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"""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"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"""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"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
"""Current user, tenant context and entitlements endpoints."""
|
"""Current user and tenant context 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
|
||||||
|
|
@ -36,26 +34,10 @@ 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)):
|
||||||
"""Endpoint requiring an active tenant."""
|
"""Example endpoint requiring an active tenant — for tests and AP0.3 prep."""
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ 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 check_db, connect_with_retry, db_params, get_connection
|
from db import db_params, get_connection
|
||||||
|
|
||||||
_LEADING_DIGITS = re.compile(r"^(\d+)")
|
_LEADING_DIGITS = re.compile(r"^(\d+)")
|
||||||
|
|
||||||
|
|
@ -134,6 +135,21 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from typing import Callable, List, Optional, Tuple
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import sqlparse
|
import sqlparse
|
||||||
|
|
||||||
from db import connect_with_retry, db_params, get_connection
|
from db import 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,6 +152,21 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
#!/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())
|
|
||||||
|
|
@ -10,7 +10,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,7 +26,6 @@ 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 {
|
||||||
|
|
@ -35,7 +33,6 @@ 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,
|
||||||
|
|
@ -89,15 +86,13 @@ 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=portal_role,
|
portal_role=session["portal_role"],
|
||||||
tenant_id=None,
|
tenant_id=None,
|
||||||
tenant_slug=None,
|
tenant_slug=None,
|
||||||
tenant_name=None,
|
tenant_name=None,
|
||||||
|
|
@ -105,7 +100,6 @@ 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()
|
||||||
|
|
@ -128,24 +122,19 @@ 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=portal_role,
|
portal_role=session["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=tenant_role,
|
tenant_role=membership["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()
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ 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:
|
||||||
|
|
@ -49,14 +48,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"""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"] == {}
|
|
||||||
|
|
@ -34,7 +34,6 @@ 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():
|
||||||
|
|
@ -47,7 +46,6 @@ 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():
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.3.0-ap0.3"
|
APP_VERSION = "0.2.0-ap0.2"
|
||||||
DB_SCHEMA_VERSION = "004"
|
DB_SCHEMA_VERSION = "003"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -75,22 +75,6 @@ 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
|
||||||
|
|
|
||||||
154
docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md
Normal file
154
docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# 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.*
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
# 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`.*
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
# 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`.*
|
|
||||||
|
|
@ -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.3</p>
|
<p>Operativer Program Director — Sprint 0 / AP0.2</p>
|
||||||
<p className="muted">Capability Registry & Entitlements</p>
|
<p className="muted">Auth, Tenant & Actor Foundation</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="card auth-card">
|
<section className="card auth-card">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user