master user anlegen #2

Merged
Lars merged 8 commits from develop into main 2026-07-04 22:35:08 +02:00
25 changed files with 1340 additions and 3 deletions
Showing only changes of commit 564008be13 - Show all commits

View File

@ -8,6 +8,11 @@
# APP_URL=https://dev.kairo.jinkendo.de # APP_URL=https://dev.kairo.jinkendo.de
# ALLOWED_ORIGINS=https://dev.kairo.jinkendo.de,http://192.168.2.49:3097 # ALLOWED_ORIGINS=https://dev.kairo.jinkendo.de,http://192.168.2.49:3097
# ENVIRONMENT=development # ENVIRONMENT=development
# SESSION_DAYS=30
# KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
# KAIRO_BOOTSTRAP_ADMIN_PASSWORD=change-me-in-dev-env
# KAIRO_BOOTSTRAP_TENANT_SLUG=default
# KAIRO_BOOTSTRAP_TENANT_NAME=Default Tenant
# ─── PROD (docker-compose.yml) ─────────────────────────────────────────────── # ─── PROD (docker-compose.yml) ───────────────────────────────────────────────
DB_NAME=kairo DB_NAME=kairo

View File

@ -99,6 +99,38 @@ docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests
UI: http://localhost:3097 · API: http://localhost:8097 UI: http://localhost:3097 · API: http://localhost:8097
Interaktive API-Doku (Dev): http://localhost:8097/api/docs
### Auth (AP0.2)
Bootstrap (nur wenn noch kein User existiert — `.env` setzen):
```env
KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
KAIRO_BOOTSTRAP_ADMIN_PASSWORD=…
KAIRO_BOOTSTRAP_TENANT_SLUG=default
```
| Endpoint | Methode | Auth | Beschreibung |
|----------|---------|------|--------------|
| `/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/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) |
```bash
# Login
curl -s -X POST http://localhost:8097/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@kairo.local","password":"…"}'
# Geschützter Endpoint
curl -s http://localhost:8097/api/me -H "X-Auth-Token: TOKEN"
```
Regeln: `user_id` kommt aus der Session, nicht aus Client-Headern. Portalrolle (`portal_role`) und Tenantrolle (`tenant_role`) sind getrennt.
## AP0.1 Stand Projektgrundlage ## AP0.1 Stand Projektgrundlage
| Bereich | Status | | Bereich | Status |
@ -107,5 +139,5 @@ UI: http://localhost:3097 · API: http://localhost:8097
| Docker Compose (Prod + Dev) | erledigt | | Docker Compose (Prod + Dev) | erledigt |
| Backend (FastAPI, Migrationen, `/api/health`) | erledigt | | Backend (FastAPI, Migrationen, `/api/health`) | erledigt |
| Frontend minimal (React + nginx Proxy) | erledigt | | Frontend minimal (React + nginx Proxy) | erledigt |
| pytest (Health + Migrationen) | erledigt | | pytest (Health + Migrationen + Auth/Tenant/Actor) | erledigt (AP0.2) |
| Gitea Actions (Deploy + Test) | erledigt | | Gitea Actions (Deploy + Test) | erledigt |

243
backend/auth.py Normal file
View File

@ -0,0 +1,243 @@
"""Authentication: bcrypt passwords, server-side sessions, FastAPI dependencies."""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import bcrypt
from fastapi import Depends, Header, HTTPException
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
AUTH_HEADER = "X-Auth-Token"
SESSION_DAYS = int(os.getenv("SESSION_DAYS", "30"))
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, stored_hash: str) -> bool:
if not stored_hash:
return False
try:
return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8"))
except ValueError:
return False
def make_token() -> str:
return secrets.token_urlsafe(32)
def _session_expiry() -> datetime:
return datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
def get_user_by_email(email: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, email, password_hash, display_name, portal_role, is_active
FROM users
WHERE LOWER(email) = LOWER(%s)
""",
(email.strip(),),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
conn.close()
def get_user_by_id(user_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, email, display_name, portal_role, is_active, created_at
FROM users
WHERE id = %s
""",
(user_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
conn.close()
def get_session(token: str) -> Optional[dict[str, Any]]:
if not token:
return None
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
s.id AS session_id,
s.token,
s.user_id,
s.active_tenant_id,
s.expires_at,
u.email,
u.display_name,
u.portal_role,
u.is_active AS user_is_active
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = %s AND s.expires_at > NOW()
""",
(token,),
)
row = cur.fetchone()
if not row:
return None
session = dict(row)
if not session.get("user_is_active"):
return None
return session
finally:
conn.close()
def _default_active_tenant(user_id: str) -> Optional[str]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT tm.tenant_id
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = %s
AND tm.is_active = TRUE
AND t.is_active = TRUE
ORDER BY tm.created_at ASC
LIMIT 1
""",
(user_id,),
)
row = cur.fetchone()
return str(row[0]) if row else None
finally:
conn.close()
def create_session(user_id: str, active_tenant_id: Optional[str] = None) -> dict[str, Any]:
token = make_token()
tenant_id = active_tenant_id or _default_active_tenant(user_id)
expires_at = _session_expiry()
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO sessions (token, user_id, active_tenant_id, expires_at)
VALUES (%s, %s, %s, %s)
RETURNING token, expires_at, active_tenant_id
""",
(token, user_id, tenant_id, expires_at),
)
row = dict(cur.fetchone())
conn.commit()
row["user_id"] = user_id
return row
finally:
conn.close()
def delete_session(token: str) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM sessions WHERE token = %s", (token,))
conn.commit()
finally:
conn.close()
def set_session_active_tenant(token: str, tenant_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE sessions s
SET active_tenant_id = %s
FROM users u
WHERE s.token = %s
AND s.user_id = u.id
AND EXISTS (
SELECT 1 FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = s.user_id
AND tm.tenant_id = %s
AND tm.is_active = TRUE
AND t.is_active = TRUE
)
""",
(tenant_id, token, tenant_id),
)
updated = cur.rowcount > 0
conn.commit()
return updated
finally:
conn.close()
def login(email: str, password: str) -> dict[str, Any]:
user = get_user_by_email(email)
if not user or not user.get("is_active"):
log_audit("auth.login_failed", details={"email": email.strip().lower()})
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
if not verify_password(password, user["password_hash"]):
log_audit("auth.login_failed", user_id=str(user["id"]), details={"email": user["email"]})
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
session = create_session(str(user["id"]))
log_audit("auth.login", user_id=str(user["id"]), details={"email": user["email"]})
return {
"token": session["token"],
"expires_at": session["expires_at"].isoformat(),
"user": {
"id": str(user["id"]),
"email": user["email"],
"display_name": user["display_name"],
"portal_role": user["portal_role"],
},
}
def logout(token: str) -> None:
session = get_session(token)
if session:
log_audit(
"auth.logout",
user_id=str(session["user_id"]),
tenant_id=str(session["active_tenant_id"]) if session.get("active_tenant_id") else None,
)
delete_session(token)
def require_auth(x_auth_token: Optional[str] = Header(default=None, alias=AUTH_HEADER)) -> dict[str, Any]:
session = get_session(x_auth_token or "")
if not session:
raise HTTPException(status_code=401, detail="Nicht eingeloggt")
return session
def require_portal_admin(session: dict[str, Any] = Depends(require_auth)) -> dict[str, Any]:
if session.get("portal_role") != "admin":
raise HTTPException(status_code=403, detail="Nur für Portal-Admins")
return session

88
backend/bootstrap.py Normal file
View File

@ -0,0 +1,88 @@
"""Bootstrap first admin user, tenant, membership and human actor from environment."""
from __future__ import annotations
import os
from psycopg2.extras import RealDictCursor
from auth import create_session, hash_password
from db import get_connection
from services.actors import create_actor
from services.audit import log_audit
def _env(name: str) -> str:
return os.getenv(name, "").strip()
def bootstrap_admin_if_needed() -> None:
if _env("SKIP_BOOTSTRAP").lower() in ("1", "true", "yes"):
return
email = _env("KAIRO_BOOTSTRAP_ADMIN_EMAIL")
password = _env("KAIRO_BOOTSTRAP_ADMIN_PASSWORD")
if not email or not password:
return
tenant_slug = _env("KAIRO_BOOTSTRAP_TENANT_SLUG") or "default"
tenant_name = _env("KAIRO_BOOTSTRAP_TENANT_NAME") or "Default Tenant"
display_name = _env("KAIRO_BOOTSTRAP_ADMIN_NAME") or "Kairo Admin"
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT COUNT(*) AS n FROM users")
if cur.fetchone()["n"] > 0:
return
cur.execute(
"""
INSERT INTO tenants (slug, name)
VALUES (%s, %s)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id
""",
(tenant_slug, tenant_name),
)
tenant_row = cur.fetchone()
if not tenant_row:
cur.execute("SELECT id FROM tenants WHERE slug = %s", (tenant_slug,))
tenant_row = cur.fetchone()
tenant_id = str(tenant_row["id"])
cur.execute(
"""
INSERT INTO users (email, password_hash, display_name, portal_role)
VALUES (%s, %s, %s, 'admin')
RETURNING id
""",
(email.lower(), hash_password(password), display_name),
)
user_id = str(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
VALUES (%s, %s, 'owner')
ON CONFLICT (tenant_id, user_id) DO NOTHING
""",
(tenant_id, user_id),
)
conn.commit()
finally:
conn.close()
create_actor(
tenant_id=tenant_id,
actor_type="human",
name=display_name,
user_id=user_id,
)
log_audit(
"bootstrap.admin_created",
user_id=user_id,
tenant_id=tenant_id,
details={"email": email.lower(), "tenant_slug": tenant_slug},
)
print(f"[bootstrap] Admin user and tenant '{tenant_slug}' created")

View File

@ -21,6 +21,10 @@ else:
print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})") print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
import bootstrap
bootstrap.bootstrap_admin_if_needed()
allowed_origins = [ allowed_origins = [
origin.strip() origin.strip()
for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:3097").split(",") for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:3097").split(",")
@ -42,6 +46,11 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
from routers import auth, me # noqa: E402
app.include_router(auth.router)
app.include_router(me.router)
@app.get("/api/health") @app.get("/api/health")
def api_health(): def api_health():

View File

@ -0,0 +1,81 @@
-- AP0.2: Auth, Identity, Tenant & Actor Foundation
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
portal_role VARCHAR(32) NOT NULL DEFAULT 'user'
CHECK (portal_role IN ('user', 'admin')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255) 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 tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_role VARCHAR(32) NOT NULL DEFAULT 'member'
CHECK (tenant_role IN ('owner', 'admin', 'member')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, user_id)
);
CREATE INDEX idx_tenant_memberships_user ON tenant_memberships(user_id);
CREATE TABLE actors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
actor_type VARCHAR(32) NOT NULL
CHECK (actor_type IN ('human', 'agent', 'working_group', 'external_system')),
name VARCHAR(255) NOT NULL,
user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (
(actor_type = 'human' AND user_id IS NOT NULL)
OR (actor_type <> 'human' AND user_id IS NULL)
)
);
CREATE UNIQUE INDEX idx_actors_human_user_tenant
ON actors(tenant_id, user_id)
WHERE actor_type = 'human';
CREATE INDEX idx_actors_tenant ON actors(tenant_id);
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token VARCHAR(128) NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
active_tenant_id UUID NULL REFERENCES tenants(id) ON DELETE SET NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sessions_token ON sessions(token);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action VARCHAR(64) NOT NULL,
user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
tenant_id UUID NULL REFERENCES tenants(id) ON DELETE SET NULL,
details JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);

View File

@ -3,3 +3,5 @@ uvicorn[standard]==0.29.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
sqlparse>=0.5.0 sqlparse>=0.5.0
pydantic==2.7.1 pydantic==2.7.1
email-validator>=2.1.0
bcrypt>=4.1.0

View File

27
backend/routers/auth.py Normal file
View File

@ -0,0 +1,27 @@
"""Auth endpoints: login, logout."""
from __future__ import annotations
from auth import login, logout, require_auth
from fastapi import APIRouter, Depends, Response
from pydantic import BaseModel, EmailStr, Field
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=256)
@router.post("/login")
def auth_login(body: LoginRequest):
return login(body.email, body.password)
@router.post("/logout")
def auth_logout(response: Response, session: dict = Depends(require_auth)):
token = session.get("token")
if token:
logout(token)
return {"ok": True}

49
backend/routers/me.py Normal file
View File

@ -0,0 +1,49 @@
"""Current user and tenant context endpoints."""
from __future__ import annotations
from auth import get_session, require_auth, set_session_active_tenant
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
router = APIRouter(prefix="/api/me", tags=["me"])
class SwitchTenantRequest(BaseModel):
tenant_id: str
@router.get("")
def get_me(session: dict = Depends(require_auth)):
tenants = list_user_tenants(str(session["user_id"]))
return {
"id": str(session["user_id"]),
"email": session["email"],
"display_name": session["display_name"],
"portal_role": session["portal_role"],
"active_tenant_id": str(session["active_tenant_id"]) if session.get("active_tenant_id") else None,
"tenants": tenants,
}
@router.get("/context")
def get_my_context(ctx: TenantContext = Depends(get_tenant_context)):
return ctx.to_dict()
@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."""
return ctx.to_dict()
@router.post("/tenant")
def switch_tenant(body: SwitchTenantRequest, session: dict = Depends(require_auth)):
ok = set_session_active_tenant(session["token"], body.tenant_id)
if not ok:
raise HTTPException(status_code=403, detail="Tenant nicht erlaubt")
fresh = get_session(session["token"])
if not fresh:
raise HTTPException(status_code=401, detail="Session ungültig")
return resolve_tenant_context(fresh).to_dict()

View File

View File

@ -0,0 +1,68 @@
"""Actor creation helpers — User and Actor remain separate concepts."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
ActorType = Literal["human", "agent", "working_group", "external_system"]
def create_actor(
*,
tenant_id: str,
actor_type: ActorType,
name: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
if actor_type == "human" and not user_id:
raise ValueError("Human actors require user_id")
if actor_type != "human" and user_id:
raise ValueError("Non-human actors must not have user_id")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO actors (tenant_id, actor_type, name, user_id)
VALUES (%s, %s, %s, %s)
RETURNING id, tenant_id, actor_type, name, user_id, is_active, created_at
""",
(tenant_id, actor_type, name, user_id),
)
row = dict(cur.fetchone())
conn.commit()
for key in ("id", "tenant_id", "user_id"):
if row.get(key):
row[key] = str(row[key])
return row
finally:
conn.close()
def get_human_actor(tenant_id: str, user_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, actor_type, name, user_id, is_active, created_at
FROM actors
WHERE tenant_id = %s AND user_id = %s AND actor_type = 'human'
""",
(tenant_id, user_id),
)
row = cur.fetchone()
if not row:
return None
result = dict(row)
for key in ("id", "tenant_id", "user_id"):
if result.get(key):
result[key] = str(result[key])
return result
finally:
conn.close()

30
backend/services/audit.py Normal file
View File

@ -0,0 +1,30 @@
"""Minimal audit logging for critical auth actions (AP0.2)."""
from __future__ import annotations
import json
from typing import Any, Optional
from db import get_connection
def log_audit(
action: str,
*,
user_id: Optional[str] = None,
tenant_id: Optional[str] = None,
details: Optional[dict[str, Any]] = None,
) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO audit_log (action, user_id, tenant_id, details)
VALUES (%s, %s, %s, %s::jsonb)
""",
(action, user_id, tenant_id, json.dumps(details or {})),
)
conn.commit()
finally:
conn.close()

151
backend/tenant_context.py Normal file
View File

@ -0,0 +1,151 @@
"""TenantContext resolution — single layer, not scattered header logic in routers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
from fastapi import Depends, HTTPException
from psycopg2.extras import RealDictCursor
from auth import require_auth
from db import get_connection
from services.actors import get_human_actor
@dataclass(frozen=True)
class TenantContext:
user_id: str
email: str
display_name: str
portal_role: str
tenant_id: Optional[str]
tenant_slug: Optional[str]
tenant_name: Optional[str]
tenant_role: Optional[str]
actor_id: Optional[str]
actor_type: Optional[str]
session_token: str
def to_dict(self) -> dict[str, Any]:
return {
"user_id": self.user_id,
"email": self.email,
"display_name": self.display_name,
"portal_role": self.portal_role,
"tenant": (
{
"id": self.tenant_id,
"slug": self.tenant_slug,
"name": self.tenant_name,
"role": self.tenant_role,
}
if self.tenant_id
else None
),
"actor": (
{
"id": self.actor_id,
"type": self.actor_type,
}
if self.actor_id
else None
),
}
def list_user_tenants(user_id: str) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
t.id,
t.slug,
t.name,
tm.tenant_role,
tm.is_active AS membership_active
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = %s AND tm.is_active = TRUE AND t.is_active = TRUE
ORDER BY t.name
""",
(user_id,),
)
rows = []
for row in cur.fetchall():
item = dict(row)
item["id"] = str(item["id"])
rows.append(item)
return rows
finally:
conn.close()
def resolve_tenant_context(session: dict[str, Any]) -> TenantContext:
user_id = str(session["user_id"])
tenant_id = session.get("active_tenant_id")
if not tenant_id:
return TenantContext(
user_id=user_id,
email=session["email"],
display_name=session["display_name"],
portal_role=session["portal_role"],
tenant_id=None,
tenant_slug=None,
tenant_name=None,
tenant_role=None,
actor_id=None,
actor_type=None,
session_token=session["token"],
)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT t.id, t.slug, t.name, tm.tenant_role
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = %s
AND tm.tenant_id = %s
AND tm.is_active = TRUE
AND t.is_active = TRUE
""",
(user_id, tenant_id),
)
membership = cur.fetchone()
if not membership:
raise HTTPException(status_code=403, detail="Keine gültige Tenant-Mitgliedschaft")
tenant_id_str = str(membership["id"])
human = get_human_actor(tenant_id_str, user_id)
return TenantContext(
user_id=user_id,
email=session["email"],
display_name=session["display_name"],
portal_role=session["portal_role"],
tenant_id=tenant_id_str,
tenant_slug=membership["slug"],
tenant_name=membership["name"],
tenant_role=membership["tenant_role"],
actor_id=human["id"] if human else None,
actor_type=human["actor_type"] if human else None,
session_token=session["token"],
)
finally:
conn.close()
def get_tenant_context(session: dict[str, Any] = Depends(require_auth)) -> TenantContext:
return resolve_tenant_context(session)
def require_tenant_context(session: dict[str, Any] = Depends(require_auth)) -> TenantContext:
ctx = resolve_tenant_context(session)
if not ctx.tenant_id:
raise HTTPException(status_code=403, detail="Kein aktiver Tenant — Tenant wählen")
return ctx

49
backend/tests/conftest.py Normal file
View File

@ -0,0 +1,49 @@
"""Shared pytest fixtures for AP0.2 auth tests."""
from __future__ import annotations
import os
import pytest
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
os.environ.setdefault("SKIP_BOOTSTRAP", "1")
def _db_available() -> bool:
try:
import psycopg2
conn = psycopg2.connect(
host=os.getenv("DB_HOST", "localhost"),
port=os.getenv("DB_PORT", "5432"),
dbname=os.getenv("DB_NAME", "kairo_dev"),
user=os.getenv("DB_USER", "kairo_dev"),
password=os.getenv("DB_PASSWORD", "dev_password"),
)
conn.close()
return True
except Exception:
return False
pytestmark = pytest.mark.skipif(not _db_available(), reason="PostgreSQL nicht erreichbar")
@pytest.fixture(scope="session", autouse=True)
def _run_migrations():
import run_migrations
assert run_migrations.main() == 0
@pytest.fixture()
def client():
import importlib
import main as main_module
importlib.reload(main_module)
from fastapi.testclient import TestClient
return TestClient(main_module.app)

View File

@ -0,0 +1,91 @@
"""Test data factories (PostgreSQL required)."""
from __future__ import annotations
import uuid
from auth import hash_password
from db import get_connection
from services.actors import create_actor
def create_tenant(*, slug: str | None = None, name: str = "Test Tenant") -> str:
slug = slug or f"t-{uuid.uuid4().hex[:10]}"
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO tenants (slug, name) VALUES (%s, %s) RETURNING id",
(slug, name),
)
tenant_id = str(cur.fetchone()[0])
conn.commit()
return tenant_id
finally:
conn.close()
def create_user(
*,
email: str | None = None,
password: str = "test-password-123",
display_name: str = "Test User",
portal_role: str = "user",
) -> dict:
email = email or f"user-{uuid.uuid4().hex[:8]}@test.local"
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO users (email, password_hash, display_name, portal_role)
VALUES (%s, %s, %s, %s)
RETURNING id, email, display_name, portal_role
""",
(email, hash_password(password), display_name, portal_role),
)
row = cur.fetchone()
conn.commit()
return {
"id": str(row[0]),
"email": row[1],
"display_name": row[2],
"portal_role": row[3],
"password": password,
}
finally:
conn.close()
def add_membership(*, tenant_id: str, user_id: str, tenant_role: str = "member") -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
VALUES (%s, %s, %s)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET tenant_role = EXCLUDED.tenant_role
""",
(tenant_id, user_id, tenant_role),
)
conn.commit()
finally:
conn.close()
def provision_user_in_tenant(
*,
tenant_role: str = "member",
portal_role: str = "user",
) -> dict:
tenant_id = create_tenant()
user = create_user(portal_role=portal_role)
add_membership(tenant_id=tenant_id, user_id=user["id"], tenant_role=tenant_role)
actor = create_actor(
tenant_id=tenant_id,
actor_type="human",
name=user["display_name"],
user_id=user["id"],
)
return {**user, "tenant_id": tenant_id, "actor_id": actor["id"]}

View File

@ -0,0 +1,80 @@
"""Auth endpoint and session tests."""
from __future__ import annotations
from auth import AUTH_HEADER, get_session
from tests.factories import provision_user_in_tenant
def test_login_logout_and_session(client):
user = provision_user_in_tenant()
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
assert login.status_code == 200
payload = login.json()
assert "token" in payload
assert payload["user"]["email"] == user["email"]
token = payload["token"]
me = client.get("/api/me", headers={AUTH_HEADER: token})
assert me.status_code == 200
assert me.json()["email"] == user["email"]
assert me.json()["active_tenant_id"] == user["tenant_id"]
logout = client.post("/api/auth/logout", headers={AUTH_HEADER: token})
assert logout.status_code == 200
assert get_session(token) is None
me_after = client.get("/api/me", headers={AUTH_HEADER: token})
assert me_after.status_code == 401
def test_login_invalid_password(client):
user = provision_user_in_tenant()
response = client.post(
"/api/auth/login",
json={"email": user["email"], "password": "wrong-password"},
)
assert response.status_code == 401
def test_me_requires_auth(client):
assert client.get("/api/me").status_code == 401
def test_me_context_returns_tenant_and_actor(client):
user = provision_user_in_tenant(tenant_role="admin")
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
token = login.json()["token"]
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token})
assert ctx.status_code == 200
body = ctx.json()
assert body["tenant"]["id"] == user["tenant_id"]
assert body["tenant"]["role"] == "admin"
assert body["actor"]["id"] == user["actor_id"]
assert body["actor"]["type"] == "human"
def test_tenant_switch_rejects_invalid_membership(client):
user = provision_user_in_tenant()
other = provision_user_in_tenant()
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
token = login.json()["token"]
switch = client.post(
"/api/me/tenant",
headers={AUTH_HEADER: token},
json={"tenant_id": other["tenant_id"]},
)
assert switch.status_code == 403

View File

@ -7,6 +7,7 @@ from fastapi.testclient import TestClient
@pytest.fixture() @pytest.fixture()
def client(monkeypatch): def client(monkeypatch):
monkeypatch.setenv("SKIP_DB_MIGRATE", "1") monkeypatch.setenv("SKIP_DB_MIGRATE", "1")
monkeypatch.setenv("SKIP_BOOTSTRAP", "1")
import importlib import importlib
import main as main_module import main as main_module

View File

@ -32,6 +32,7 @@ def test_migration_runner_finds_migrations():
files = run_migrations.migration_files(migrations_dir) files = run_migrations.migration_files(migrations_dir)
names = [name for name, _ in files] names = [name for name, _ in files]
assert "001_init_core" in names assert "001_init_core" in names
assert "002_auth_identity_tenant_actor" in names
def test_migration_runner_is_idempotent(): def test_migration_runner_is_idempotent():
@ -42,6 +43,7 @@ def test_migration_runner_is_idempotent():
executed = run_migrations.executed_migrations(conn) executed = run_migrations.executed_migrations(conn)
conn.close() conn.close()
assert "001_init_core" in executed assert "001_init_core" in executed
assert "002_auth_identity_tenant_actor" in executed
def test_core_table_exists(): def test_core_table_exists():

View File

@ -0,0 +1,46 @@
"""Actor and TenantContext tests."""
from __future__ import annotations
from auth import AUTH_HEADER
from services.actors import create_actor
from tests.factories import create_tenant, provision_user_in_tenant
def test_human_actor_linked_to_user(client):
user = provision_user_in_tenant()
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
token = login.json()["token"]
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token}).json()
assert ctx["actor"]["type"] == "human"
assert ctx["actor"]["id"] == user["actor_id"]
def test_agent_actor_without_user(client):
tenant_id = create_tenant()
agent = create_actor(tenant_id=tenant_id, actor_type="agent", name="Planner Agent")
assert agent["user_id"] is None
assert agent["actor_type"] == "agent"
def test_working_group_and_external_system_actors(client):
tenant_id = create_tenant()
wg = create_actor(tenant_id=tenant_id, actor_type="working_group", name="Steering Group")
ext = create_actor(tenant_id=tenant_id, actor_type="external_system", name="Jira")
assert wg["actor_type"] == "working_group"
assert ext["actor_type"] == "external_system"
def test_portal_role_separate_from_tenant_role(client):
user = provision_user_in_tenant(tenant_role="member", portal_role="admin")
login = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
token = login.json()["token"]
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token}).json()
assert ctx["portal_role"] == "admin"
assert ctx["tenant"]["role"] == "member"

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.1.0-ap0.1" APP_VERSION = "0.2.0-ap0.2"
DB_SCHEMA_VERSION = "001" DB_SCHEMA_VERSION = "002"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -34,6 +34,11 @@ services:
APP_URL: "${APP_URL:-https://dev.kairo.jinkendo.de}" APP_URL: "${APP_URL:-https://dev.kairo.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.kairo.jinkendo.de,http://192.168.2.49:3097,http://localhost:3097}" ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.kairo.jinkendo.de,http://192.168.2.49:3097,http://localhost:3097}"
ENVIRONMENT: "${ENVIRONMENT:-development}" ENVIRONMENT: "${ENVIRONMENT:-development}"
SESSION_DAYS: "${SESSION_DAYS:-30}"
KAIRO_BOOTSTRAP_ADMIN_EMAIL: "${KAIRO_BOOTSTRAP_ADMIN_EMAIL:-}"
KAIRO_BOOTSTRAP_ADMIN_PASSWORD: "${KAIRO_BOOTSTRAP_ADMIN_PASSWORD:-}"
KAIRO_BOOTSTRAP_TENANT_SLUG: "${KAIRO_BOOTSTRAP_TENANT_SLUG:-default}"
KAIRO_BOOTSTRAP_TENANT_NAME: "${KAIRO_BOOTSTRAP_TENANT_NAME:-Default Tenant}"
ports: ports:
- "${KAIRO_BACKEND_PORT:-8097}:8000" - "${KAIRO_BACKEND_PORT:-8097}:8000"
depends_on: depends_on:

View File

@ -35,6 +35,9 @@ services:
APP_URL: "${APP_URL:-https://kairo.jinkendo.de}" APP_URL: "${APP_URL:-https://kairo.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kairo.jinkendo.de}" ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kairo.jinkendo.de}"
ENVIRONMENT: "${ENVIRONMENT:-production}" ENVIRONMENT: "${ENVIRONMENT:-production}"
SESSION_DAYS: "${SESSION_DAYS:-30}"
KAIRO_BOOTSTRAP_ADMIN_EMAIL: "${KAIRO_BOOTSTRAP_ADMIN_EMAIL:-}"
KAIRO_BOOTSTRAP_ADMIN_PASSWORD: "${KAIRO_BOOTSTRAP_ADMIN_PASSWORD:-}"
ports: ports:
- "${KAIRO_BACKEND_PORT:-8004}:8000" - "${KAIRO_BACKEND_PORT:-8004}:8000"
depends_on: depends_on:

View File

@ -0,0 +1,121 @@
# AP0.1 Abschlussbericht Projektgrundlage
**Status:** abgeschlossen
**Stand:** 2026-07-04
**Branch:** `develop` (`b5efdbb`) · Prod-Stand identisch (`main` `7b96bab`)
---
## Umgesetzt
| Anforderung (AP0.1) | Umsetzung |
|---------------------|-----------|
| Backend-Skeleton + FastAPI | `backend/main.py`, `backend/Dockerfile` |
| Health Endpoint `GET /api/health` | Liefert `status`, `app`, `db` (+ `version`, `schema`) |
| PostgreSQL-Anbindung | `backend/db.py`, Compose-Services |
| Docker Compose lokal | `docker-compose.dev-env.yml`, `docker-compose.yml` |
| Migrationsverzeichnis | `backend/migrations/` |
| Migration Runner + `schema_migrations` | `backend/run_migrations.py` |
| Erste Migration `001_init_core.sql` | `kairo_app_meta` (Schema-Phase AP0.1) |
| Testbasis | `backend/tests/` (pytest) |
| Frontend optional minimal | React/Vite-SPA + nginx-Proxy (`frontend/`) |
| Verzeichnisse `infra/`, `scripts/`, `tests/` | vorhanden |
| README „Local Development“ | `README.md` § Local Development |
| Kein Sprint-1-Fachscope | Keine Tenant/User/Actor-Logik, nur Meta-Tabelle |
**Zusätzlich (über AP0.1-Minimum, bewusst Sprint-0-Infrastruktur):**
- Gitea Actions: `deploy-dev.yml`, `deploy-prod.yml`, `test.yml`
- CI: pytest im Container, compose-smoke (PR), k6-Baseline, Playwright smoke
- Deployment-Doku: `docs/DEPLOYMENT.md`
- Dev/Prod auf Raspberry Pi (Ports 8097/3097 bzw. 8004/3004)
---
## Tests
### Unit / Integration (pytest)
| Test | Datei | Status |
|------|-------|--------|
| App importierbar | `test_health.py::test_app_importable` | ✓ |
| Health Endpoint | `test_health.py::test_health_endpoint` | ✓ |
| Runner findet Migrationen | `test_migrations.py::test_migration_runner_finds_migrations` | ✓ |
| Runner trägt Migration ein + idempotent | `test_migrations.py::test_migration_runner_is_idempotent` | ✓ |
| Core-Tabelle existiert | `test_migrations.py::test_core_table_exists` | ✓ |
### CI (Self-hosted Runner, Dev)
| Job | Status (2026-07-04) |
|-----|---------------------|
| pytest-backend | ✓ |
| k6 /api/health Baseline | ✓ (nach K6_CI-Anpassung) |
| playwright-smoke | ✓ |
| lint-backend | ✓ |
| compose-smoke | ✓ (PR, isolierte CI-Ports) |
### Prod
| Schritt | Status |
|---------|--------|
| Deploy Production (manueller Merge `develop``main`) | ✓ |
| Test Suite Prod-Trigger | Fix auf `develop` (`b5efdbb`): Push auf `main` startet Tests |
---
## Lokaler Start
```bash
git checkout develop
docker compose -f docker-compose.dev-env.yml up --build
curl http://localhost:8097/api/health
curl http://localhost:3097/api/health
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
```
Migrationen laufen beim Backend-Start automatisch (`run_migrations.main()` in `main.py`, überspringbar via `SKIP_DB_MIGRATE=1` für Tests).
---
## Offene Fragen
1. **Reverse Proxy** — Domains `kairo.jinkendo.de` / `dev.kairo.jinkendo.de` noch nicht eingerichtet; CI und Deploy nutzen localhost (bewusst ausreichend für AP0.1).
2. **Prod `.env`** — Muss auf dem Pi unter `/home/lars/docker/kairo/.env` mit sicherem `DB_PASSWORD` liegen (Deploy klont Repo, erzeugt `.env` nicht).
3. **README „Startzustand“** — Abschnitt oben in `README.md` beschreibt noch „kein Anwendungscode“; AP0.1-Tabelle unten ist aktuell — Kosmetik-Update empfohlen.
4. **`workflow_run`-Trigger** — In Gitea/act unzuverlässig; Prod-Tests hängen an Push `main` (Fix `b5efdbb`). Merge/Deploy bleibt manuell durch Product Owner.
---
## Abweichungen von der Spezifikation
| Spezifikation | Ist | Bewertung |
|---------------|-----|-----------|
| Health nur `status`, `app`, `db` | zusätzlich `version`, `schema` | Erweiterung, abwärtskompatibel |
| `db: "ok"` | auch `"error"` bei DB-Ausfall, dann `status: "degraded"` | sinnvoller Betriebszustand |
| Frontend optional | minimal umgesetzt (Health-Anzeige + nginx `/api`-Proxy) | im Scope |
| Offene Entscheidungen dokumentiert | in diesem Bericht + `docs/DEPLOYMENT.md`, kein separates ADR für AP0.1 | ausreichend für Abschluss |
Keine fachliche Sprint-1-Logik vorgezogen.
---
## Empfehlung für AP0.2
**Nächster Auftrag:** AP0.2 Tenant, User, Actor (`Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.2)
Vorgehen:
1. Migration `002_tenant_user_actor.sql` — Tabellen laut Foundation, weiterhin ohne Vorhaben/Projekt-Domäne.
2. Seed für lokalen Admin-User (Dev only).
3. pytest für Migration + minimale Repository/Model-Schicht.
4. Health/version bei Schema-Bump (`DB_SCHEMA_VERSION` → `002`).
5. README-Startzustand bereinigen; optional Reverse Proxy parallel (nicht blockierend).
AP0.1 nicht erneut anfassen, außer README-Kosmetik oder Proxy-Wunsch.
---
*Erstellt im Rahmen Sprint 0 AP0.1 Akzeptanz.*

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