AP0.3: Capability Registry, Entitlements-Snapshot und require_capability.
Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Failing after 4s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Failing after 4s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f9c207c4f6
commit
910b1d7e07
22
README.md
22
README.md
|
|
@ -127,9 +127,29 @@ Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md)
|
|||
| `/api/auth/login` | POST | — | E-Mail + Passwort → Session-Token |
|
||||
| `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen |
|
||||
| `/api/me` | GET | `X-Auth-Token` | Aktueller User + Tenant-Liste |
|
||||
| `/api/me/context` | GET | `X-Auth-Token` | TenantContext (Tenant + Human Actor) |
|
||||
| `/api/me/context` | GET | `X-Auth-Token` | TenantContext (Tenant + Human Actor + Capabilities) |
|
||||
| `/api/me/entitlements` | GET | `X-Auth-Token` | Entitlements-Snapshot (Rollen, Capabilities, Features-Platzhalter) |
|
||||
| `/api/me/admin/demo` | GET | `X-Auth-Token` + `kairo.admin.access` | Beispiel-Endpoint mit `require_capability` |
|
||||
| `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) |
|
||||
|
||||
### Capabilities (AP0.3)
|
||||
|
||||
Registry-first: Capabilities werden in `backend/rights_registrations/` registriert und beim Start via `sync_rights_registry.py` in die DB synchronisiert.
|
||||
|
||||
| Capability | Modul | Kurzbeschreibung |
|
||||
|------------|-------|------------------|
|
||||
| `kairo.admin.access` | platform | Portal-Administration |
|
||||
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
|
||||
| `kairo.actor.manage` | tenant | Actor-Verwaltung |
|
||||
| `kairo.context.read` | platform | TenantContext lesen |
|
||||
| `kairo.entitlements.read` | platform | Entitlements lesen |
|
||||
|
||||
Enforcement: `CAPABILITY_ENFORCE=probe` (Default, loggt Verweigerungen) oder `enforce` (HTTP 403).
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8097/api/me/entitlements -H "X-Auth-Token: TOKEN"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Login
|
||||
curl -s -X POST http://localhost:8097/api/auth/login \
|
||||
|
|
|
|||
66
backend/capabilities.py
Normal file
66
backend/capabilities.py
Normal 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
|
||||
64
backend/entitlements.py
Normal file
64
backend/entitlements.py
Normal 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",
|
||||
},
|
||||
}
|
||||
|
|
@ -15,6 +15,12 @@ else
|
|||
echo "[SKIP_SEEDS] Data-Seeds übersprungen"
|
||||
fi
|
||||
|
||||
if [ "${SKIP_RIGHTS_SYNC}" != "1" ] && [ "${SKIP_RIGHTS_SYNC}" != "true" ] && [ "${SKIP_RIGHTS_SYNC}" != "yes" ]; then
|
||||
python sync_rights_registry.py
|
||||
else
|
||||
echo "[SKIP_RIGHTS_SYNC] Rights-Registry-Sync übersprungen"
|
||||
fi
|
||||
|
||||
export KAIRO_DB_READY=1
|
||||
echo "=== Starte Anwendung: $* ==="
|
||||
exec "$@"
|
||||
|
|
|
|||
23
backend/migrations/004_capabilities_registry.sql
Normal file
23
backend/migrations/004_capabilities_registry.sql
Normal 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);
|
||||
5
backend/rights_registrations/__init__.py
Normal file
5
backend/rights_registrations/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Import all module registrations — side effect registers capabilities."""
|
||||
|
||||
from . import platform, tenant_ops # noqa: F401
|
||||
|
||||
__all__ = ["platform", "tenant_ops"]
|
||||
44
backend/rights_registrations/platform.py
Normal file
44
backend/rights_registrations/platform.py
Normal 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"),
|
||||
),
|
||||
)
|
||||
)
|
||||
31
backend/rights_registrations/tenant_ops.py
Normal file
31
backend/rights_registrations/tenant_ops.py
Normal 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
124
backend/rights_registry.py
Normal 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()
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
"""Current user and tenant context endpoints."""
|
||||
"""Current user, tenant context and entitlements endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from auth import get_session, require_auth, set_session_active_tenant
|
||||
from capabilities import require_capability
|
||||
from entitlements import build_entitlements_snapshot
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from tenant_context import TenantContext, get_tenant_context, list_user_tenants, require_tenant_context, resolve_tenant_context
|
||||
|
|
@ -34,10 +36,26 @@ def get_my_context(ctx: TenantContext = Depends(get_tenant_context)):
|
|||
|
||||
@router.get("/context/required")
|
||||
def get_my_context_with_tenant(ctx: TenantContext = Depends(require_tenant_context)):
|
||||
"""Example endpoint requiring an active tenant — for tests and AP0.3 prep."""
|
||||
"""Endpoint requiring an active tenant."""
|
||||
return ctx.to_dict()
|
||||
|
||||
|
||||
@router.get("/entitlements")
|
||||
def get_my_entitlements(ctx: TenantContext = Depends(get_tenant_context)):
|
||||
return build_entitlements_snapshot(ctx)
|
||||
|
||||
|
||||
@router.get("/admin/demo")
|
||||
def admin_demo(ctx: TenantContext = Depends(require_capability("kairo.admin.access"))):
|
||||
"""Example endpoint protected by require_capability."""
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Portal-Admin Capability bestätigt",
|
||||
"user_id": ctx.user_id,
|
||||
"tenant_id": ctx.tenant_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tenant")
|
||||
def switch_tenant(body: SwitchTenantRequest, session: dict = Depends(require_auth)):
|
||||
ok = set_session_active_tenant(session["token"], body.tenant_id)
|
||||
|
|
|
|||
17
backend/sync_rights_registry.py
Normal file
17
backend/sync_rights_registry.py
Normal 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())
|
||||
|
|
@ -10,6 +10,7 @@ from psycopg2.extras import RealDictCursor
|
|||
|
||||
from auth import require_auth
|
||||
from db import get_connection
|
||||
from rights_registry import load_grants_for_roles
|
||||
from services.actors import get_human_actor
|
||||
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ class TenantContext:
|
|||
actor_id: Optional[str]
|
||||
actor_type: Optional[str]
|
||||
session_token: str
|
||||
capabilities: frozenset[str]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
@ -33,6 +35,7 @@ class TenantContext:
|
|||
"email": self.email,
|
||||
"display_name": self.display_name,
|
||||
"portal_role": self.portal_role,
|
||||
"capabilities": sorted(self.capabilities),
|
||||
"tenant": (
|
||||
{
|
||||
"id": self.tenant_id,
|
||||
|
|
@ -86,13 +89,15 @@ def list_user_tenants(user_id: str) -> list[dict[str, Any]]:
|
|||
def resolve_tenant_context(session: dict[str, Any]) -> TenantContext:
|
||||
user_id = str(session["user_id"])
|
||||
tenant_id = session.get("active_tenant_id")
|
||||
portal_role = session["portal_role"]
|
||||
|
||||
if not tenant_id:
|
||||
caps = frozenset(load_grants_for_roles(portal_role=portal_role, tenant_role=None))
|
||||
return TenantContext(
|
||||
user_id=user_id,
|
||||
email=session["email"],
|
||||
display_name=session["display_name"],
|
||||
portal_role=session["portal_role"],
|
||||
portal_role=portal_role,
|
||||
tenant_id=None,
|
||||
tenant_slug=None,
|
||||
tenant_name=None,
|
||||
|
|
@ -100,6 +105,7 @@ def resolve_tenant_context(session: dict[str, Any]) -> TenantContext:
|
|||
actor_id=None,
|
||||
actor_type=None,
|
||||
session_token=session["token"],
|
||||
capabilities=caps,
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
|
|
@ -122,19 +128,24 @@ def resolve_tenant_context(session: dict[str, Any]) -> TenantContext:
|
|||
raise HTTPException(status_code=403, detail="Keine gültige Tenant-Mitgliedschaft")
|
||||
|
||||
tenant_id_str = str(membership["id"])
|
||||
tenant_role = membership["tenant_role"]
|
||||
human = get_human_actor(tenant_id_str, user_id)
|
||||
caps = frozenset(
|
||||
load_grants_for_roles(portal_role=portal_role, tenant_role=tenant_role)
|
||||
)
|
||||
return TenantContext(
|
||||
user_id=user_id,
|
||||
email=session["email"],
|
||||
display_name=session["display_name"],
|
||||
portal_role=session["portal_role"],
|
||||
portal_role=portal_role,
|
||||
tenant_id=tenant_id_str,
|
||||
tenant_slug=membership["slug"],
|
||||
tenant_name=membership["name"],
|
||||
tenant_role=membership["tenant_role"],
|
||||
tenant_role=tenant_role,
|
||||
actor_id=human["id"] if human else None,
|
||||
actor_type=human["actor_type"] if human else None,
|
||||
session_token=session["token"],
|
||||
capabilities=caps,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import pytest
|
|||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
os.environ.setdefault("SKIP_SEEDS", "1")
|
||||
os.environ.setdefault("SKIP_BOOTSTRAP", "1")
|
||||
os.environ.setdefault("SKIP_RIGHTS_SYNC", "1")
|
||||
|
||||
|
||||
def _db_available() -> bool:
|
||||
|
|
@ -48,6 +49,14 @@ def _seed_hygiene():
|
|||
run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"])
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _sync_rights_registry():
|
||||
import rights_registrations # noqa: F401
|
||||
from rights_registry import sync_rights_registry_to_db
|
||||
|
||||
assert sync_rights_registry_to_db() == 0
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
import importlib
|
||||
|
|
|
|||
63
backend/tests/test_capabilities.py
Normal file
63
backend/tests/test_capabilities.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Capability resolution and require_capability tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from auth import AUTH_HEADER
|
||||
from tests.factories import provision_user_in_tenant
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def enforce_capabilities(monkeypatch):
|
||||
monkeypatch.setenv("CAPABILITY_ENFORCE", "enforce")
|
||||
|
||||
|
||||
def test_portal_admin_has_admin_capability(client, enforce_capabilities):
|
||||
admin = provision_user_in_tenant(tenant_role="member", portal_role="admin")
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": admin["email"], "password": admin["password"]},
|
||||
)
|
||||
token = login.json()["token"]
|
||||
|
||||
res = client.get("/api/me/admin/demo", headers={AUTH_HEADER: token})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["ok"] is True
|
||||
|
||||
|
||||
def test_tenant_member_denied_admin_capability(client, enforce_capabilities):
|
||||
member = provision_user_in_tenant(tenant_role="member", portal_role="user")
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": member["email"], "password": member["password"]},
|
||||
)
|
||||
token = login.json()["token"]
|
||||
|
||||
res = client.get("/api/me/admin/demo", headers={AUTH_HEADER: token})
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
def test_tenant_owner_has_manage_capabilities(client, enforce_capabilities):
|
||||
owner = provision_user_in_tenant(tenant_role="owner", portal_role="user")
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": owner["email"], "password": owner["password"]},
|
||||
)
|
||||
token = login.json()["token"]
|
||||
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token}).json()
|
||||
|
||||
assert "kairo.tenant.manage" in ctx["capabilities"]
|
||||
assert "kairo.actor.manage" in ctx["capabilities"]
|
||||
assert "kairo.admin.access" not in ctx["capabilities"]
|
||||
|
||||
|
||||
def test_probe_mode_allows_without_capability(client, monkeypatch):
|
||||
monkeypatch.setenv("CAPABILITY_ENFORCE", "probe")
|
||||
member = provision_user_in_tenant(tenant_role="member", portal_role="user")
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": member["email"], "password": member["password"]},
|
||||
)
|
||||
token = login.json()["token"]
|
||||
|
||||
res = client.get("/api/me/admin/demo", headers={AUTH_HEADER: token})
|
||||
assert res.status_code == 200
|
||||
31
backend/tests/test_entitlements.py
Normal file
31
backend/tests/test_entitlements.py
Normal 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"] == {}
|
||||
|
|
@ -34,6 +34,7 @@ def test_migration_runner_finds_migrations():
|
|||
assert "001_init_core" in names
|
||||
assert "002_auth_identity_tenant_actor" in names
|
||||
assert "003_data_seeds_tracking" in names
|
||||
assert "004_capabilities_registry" in names
|
||||
|
||||
|
||||
def test_migration_runner_is_idempotent():
|
||||
|
|
@ -46,6 +47,7 @@ def test_migration_runner_is_idempotent():
|
|||
assert "001_init_core" in executed
|
||||
assert "002_auth_identity_tenant_actor" in executed
|
||||
assert "003_data_seeds_tracking" in executed
|
||||
assert "004_capabilities_registry" in executed
|
||||
|
||||
|
||||
def test_core_table_exists():
|
||||
|
|
|
|||
33
backend/tests/test_rights_registry.py
Normal file
33
backend/tests/test_rights_registry.py
Normal 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()
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
APP_VERSION = "0.2.0-ap0.2"
|
||||
DB_SCHEMA_VERSION = "003"
|
||||
APP_VERSION = "0.3.0-ap0.3"
|
||||
DB_SCHEMA_VERSION = "004"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
213
docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md
Normal file
213
docs/sprints/Sprint0_AP0_3_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# AP0.3 – Abschlussbericht Capability / Rights Registry & Entitlements Snapshot
|
||||
|
||||
**Status:** abgeschlossen
|
||||
**Stand:** 2026-07-04
|
||||
**Branch:** `develop`
|
||||
|
||||
---
|
||||
|
||||
## 1. Umgesetzte Dateien
|
||||
|
||||
| Datei | Zweck |
|
||||
|-------|--------|
|
||||
| `backend/migrations/004_capabilities_registry.sql` | Tabellen `capabilities`, `role_capability_grants` |
|
||||
| `backend/rights_registry.py` | In-Memory-Registry, Validierung, DB-Sync, Grant-Lookup |
|
||||
| `backend/rights_registrations/__init__.py` | Side-Effect-Import aller Modul-Registrierungen |
|
||||
| `backend/rights_registrations/platform.py` | Platform-Capabilities |
|
||||
| `backend/rights_registrations/tenant_ops.py` | Tenant-/Actor-Capabilities |
|
||||
| `backend/capabilities.py` | Auflösung, `check_capability`, `require_capability` |
|
||||
| `backend/entitlements.py` | Entitlements-Snapshot-Builder |
|
||||
| `backend/sync_rights_registry.py` | CLI-Sync beim Container-Start |
|
||||
| `backend/tenant_context.py` | `TenantContext.capabilities` |
|
||||
| `backend/routers/me.py` | `/entitlements`, `/admin/demo` |
|
||||
| `backend/entrypoint.sh` | Rights-Sync nach Seeds |
|
||||
| `backend/version.py` | `0.3.0-ap0.3`, Schema `004` |
|
||||
| `backend/tests/test_rights_registry.py` | Registry + Sync |
|
||||
| `backend/tests/test_capabilities.py` | Grants, enforce/probe, Demo-Endpoint |
|
||||
| `backend/tests/test_entitlements.py` | Snapshot-Form |
|
||||
| `backend/tests/conftest.py` | Registry-Sync in Test-Session |
|
||||
| `README.md` | API-Doku AP0.3 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Neue Migrationen
|
||||
|
||||
**`004_capabilities_registry.sql`**
|
||||
|
||||
- **`capabilities`** — `capability_key`, `module`, `description`, `is_active`
|
||||
- **`role_capability_grants`** — `role_scope` (`portal` \| `tenant`), `role_code`, `capability_key`
|
||||
|
||||
Kein Feature-/Limit-Schema in AP0.3 (bewusst AP0.4-Vorbereitung).
|
||||
|
||||
---
|
||||
|
||||
## 3. Registry-Struktur
|
||||
|
||||
```
|
||||
backend/
|
||||
├── rights_registry.py # register_capability(), sync_rights_registry_to_db()
|
||||
├── rights_registrations/
|
||||
│ ├── __init__.py # import platform, tenant_ops
|
||||
│ ├── platform.py # admin, context, entitlements
|
||||
│ └── tenant_ops.py # tenant.manage, actor.manage
|
||||
└── sync_rights_registry.py # Startup nach Migrationen/Seeds
|
||||
```
|
||||
|
||||
**Registrierungsmodell:** `@dataclass(frozen=True) CapabilityRegistration` mit `key`, `module`, `description`, `default_grants: (role_scope, role_code)`.
|
||||
|
||||
**Startup:** `entrypoint.sh` → `python sync_rights_registry.py` (überspringbar via `SKIP_RIGHTS_SYNC=1`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints
|
||||
|
||||
| Endpoint | Methode | Auth / Gate | Neu/Geändert |
|
||||
|----------|---------|-------------|--------------|
|
||||
| `/api/me/context` | GET | Session | **Geändert** — enthält `capabilities[]` |
|
||||
| `/api/me/entitlements` | GET | Session | **Neu** |
|
||||
| `/api/me/admin/demo` | GET | Session + `kairo.admin.access` | **Neu** (Beispiel `require_capability`) |
|
||||
|
||||
Bestehende Auth-/Me-Endpunkte unverändert in Signatur.
|
||||
|
||||
---
|
||||
|
||||
## 5. Capability-Auflösungslogik
|
||||
|
||||
```
|
||||
Session (portal_role, active_tenant_id)
|
||||
→ tenant_membership → tenant_role (optional)
|
||||
→ load_grants_for_roles(portal_role, tenant_role)
|
||||
SELECT capability_key FROM role_capability_grants
|
||||
JOIN capabilities WHERE role_scope/role_code match
|
||||
→ Union Portal-Grants + Tenant-Grants
|
||||
→ TenantContext.capabilities (frozenset)
|
||||
```
|
||||
|
||||
**`require_capability(key)`:** prüft `key in ctx.capabilities`.
|
||||
|
||||
| Modus | Env | Verhalten |
|
||||
|-------|-----|-----------|
|
||||
| probe | `CAPABILITY_ENFORCE=probe` (Default) | Zugriff erlaubt, Audit `capability.denied` |
|
||||
| enforce | `CAPABILITY_ENFORCE=enforce` | HTTP 403 bei fehlendem Grant |
|
||||
|
||||
---
|
||||
|
||||
## 6. Initiale Capabilities & Default-Grants
|
||||
|
||||
| Capability | Portal admin | Portal user | Tenant owner | Tenant admin | Tenant member |
|
||||
|------------|:------------:|:-----------:|:------------:|:------------:|:-------------:|
|
||||
| `kairo.admin.access` | ✓ | | | | |
|
||||
| `kairo.tenant.manage` | ✓ | | ✓ | ✓ | |
|
||||
| `kairo.actor.manage` | ✓ | | ✓ | ✓ | |
|
||||
| `kairo.context.read` | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| `kairo.entitlements.read` | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests und Testergebnis
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt
|
||||
docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv
|
||||
```
|
||||
|
||||
| Testdatei | Abdeckung |
|
||||
|-----------|-----------|
|
||||
| `test_rights_registry.py` | 5 Capabilities registriert, Sync idempotent, DB-Zeilen |
|
||||
| `test_capabilities.py` | Admin erlaubt, Member verweigert (enforce), Owner-Grants, probe-Modus |
|
||||
| `test_entitlements.py` | Snapshot account/tenant/actor/roles/capabilities/enforcement |
|
||||
| `test_migrations.py` | Migration 004 erkannt |
|
||||
|
||||
*(Lokale Ausführung in dieser Session über CI/Dev-Container nach Push.)*
|
||||
|
||||
---
|
||||
|
||||
## 8. Übernommene Muster aus Shinkan
|
||||
|
||||
| Muster | Kairo-Umsetzung |
|
||||
|--------|-----------------|
|
||||
| Registry-first | `rights_registry.py` + `rights_registrations/` |
|
||||
| `register_capability()` mit Validierung | Pflichtfelder `key`, `module`, `description` |
|
||||
| Frozen Dataclass-Definitionen | `CapabilityRegistration` |
|
||||
| Startup-Sync Registry → DB | `sync_rights_registry_to_db()` |
|
||||
| Modul-Ownership (`module`-Feld) | `platform`, `tenant` |
|
||||
| Default Grants in Code | `default_grants` pro Capability |
|
||||
| `require_capability` Dependency | `capabilities.py` |
|
||||
| `/api/me/entitlements` Snapshot | `entitlements.py` |
|
||||
| Probe vs. Enforce | `CAPABILITY_ENFORCE` |
|
||||
|
||||
**Nicht übernommen:** `club_id`, Vereinsrollen, Übungs-/Trainings-Capabilities, Club-Feature-Kontingente, `club_quota_bypass`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Übernommene Muster aus Mitai
|
||||
|
||||
| Muster | Kairo-Umsetzung |
|
||||
|--------|-----------------|
|
||||
| Entitlements als zentrale Auflösungsschicht | `build_entitlements_snapshot()` |
|
||||
| Probe-vs-Enforce-Denken | Default `probe`, optional `enforce` |
|
||||
| Feature-Registry-Idee vorbereitet | `features: {}` im Snapshot, Enforcement `probe` |
|
||||
|
||||
**Nicht übernommen:** Tier/Subscription, Usage-Zähler, `check_feature_access`-Legacy, Account-Tier-Limits.
|
||||
|
||||
---
|
||||
|
||||
## 10. Bewusst nicht übernommen
|
||||
|
||||
- Shinkan-/Mitai-Domänenbegriffe (`club`, `profile`, Tier)
|
||||
- Feature-Limits, Billing, Coupons, Usage-Zähler
|
||||
- Prompt Registry, Vorhaben-/Projektlogik
|
||||
- MCP, produktive Admin-UI
|
||||
- Capabilities nur in SQL-Migration pflegen
|
||||
- Governance/Object-ACL (AP später)
|
||||
|
||||
---
|
||||
|
||||
## 11. Abweichungen von Designprinzipien
|
||||
|
||||
| Thema | Abweichung | Begründung |
|
||||
|-------|------------|------------|
|
||||
| Entitlements-Struktur | flache + verschachtelte Blöcke | Kairo Hybrid: Account + Tenant laut Family Model |
|
||||
| Feature-Registry | leeres Objekt | AP0.4-Scope |
|
||||
| Admin-Grant-Overrides in DB | Sync fügt nur hinzu (`ON CONFLICT DO NOTHING`) | Sprint-0-Minimal; kein Admin-UI für Overrides |
|
||||
| `linked_feature_id` | nicht modelliert | Keine Feature-Limits in AP0.3 |
|
||||
|
||||
**Eingehalten:** Auth / Capability / Feature getrennt; Prüfung zentral; Portal- vs. Tenant-Rolle getrennt; TenantContext als Auflösungsschicht.
|
||||
|
||||
---
|
||||
|
||||
## 12. Offene Entscheidungen
|
||||
|
||||
1. **Prod `CAPABILITY_ENFORCE`** — Default `probe`; wann auf `enforce` umstellen?
|
||||
2. **Grant-Overrides** — Admin bearbeitet Grants in DB vs. Re-Sync aus Code
|
||||
3. **Prod-pytest-Cleanup** für `*@example.com` (aus AP0.2)
|
||||
4. **Feature-Registry** — Modell und Sync in AP0.4
|
||||
5. **Frontend** — Entitlements-gestütztes UI-Gating statt Debug-JSON
|
||||
|
||||
---
|
||||
|
||||
## 13. Empfehlung für AP0.4
|
||||
|
||||
Laut Foundation-Dokument **AP0.4 – Prompt, Feature, Config Registry**:
|
||||
|
||||
- Feature Registry (Metadaten, ohne Limits in Sprint 0)
|
||||
- PromptTemplate / PromptVersion / Placeholder
|
||||
- ConfigurationEntry
|
||||
- Einfaches Prompt-Rendering mit Placeholder-Validation
|
||||
- `features`-Block in Entitlements mit Registry-Sync (ohne Billing)
|
||||
|
||||
Optional vor AP0.4: Prod-Cleanup-Seed für pytest-Artefakte; Frontend zeigt `/api/me/entitlements`.
|
||||
|
||||
---
|
||||
|
||||
## Referenzen
|
||||
|
||||
- `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.3, §9 Entitlements
|
||||
- `docs/reference/design-principles/shinkan/RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md`
|
||||
- `docs/reference/design-principles/shinkan/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md`
|
||||
- `docs/reference/design-principles/alignment/FAMILY_ENTITLEMENT_MODEL.md`
|
||||
- Vorgänger: `docs/sprints/Sprint0_AP0_2_Completion_Report_v0.2.md`
|
||||
|
||||
---
|
||||
|
||||
*Abgeschlossen im Rahmen Sprint 0 – AP0.3.*
|
||||
Loading…
Reference in New Issue
Block a user