Merge pull request 'master user anlegen' (#2) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 10s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Failing after 2s
Test Suite / playwright-smoke (push) Successful in 19s

Reviewed-on: #2
This commit is contained in:
Lars 2026-07-04 22:35:08 +02:00
commit 7a5da57bb8
41 changed files with 2709 additions and 27 deletions

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

@ -25,10 +25,15 @@ jobs:
fi fi
docker compose -f docker-compose.dev-env.yml build --no-cache backend frontend docker compose -f docker-compose.dev-env.yml build --no-cache backend frontend
echo "✓ Backend + Frontend gebaut (Frontend: npm run build im Dockerfile)" echo "✓ Backend + Frontend gebaut (Frontend: npm run build im Dockerfile)"
docker compose -f docker-compose.dev-env.yml up -d --wait if ! docker compose -f docker-compose.dev-env.yml up -d --wait; then
echo "✗ compose up --wait fehlgeschlagen — Backend-Logs:"
docker compose -f docker-compose.dev-env.yml logs backend --tail 150 || true
docker compose -f docker-compose.dev-env.yml ps || true
exit 1
fi
if ! curl -sf http://localhost:8097/api/health; then if ! curl -sf http://localhost:8097/api/health; then
echo "✗ DEV API nicht erreichbar — Backend-Logs:" echo "✗ DEV API nicht erreichbar — Backend-Logs:"
docker compose -f docker-compose.dev-env.yml logs backend --tail 120 || true docker compose -f docker-compose.dev-env.yml logs backend --tail 150 || true
exit 1 exit 1
fi fi
echo "✓ DEV API /api/health OK" echo "✓ DEV API /api/health OK"

View File

@ -62,7 +62,8 @@ jobs:
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc " docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
pip install -q -r requirements-dev.txt && pip install -q -r requirements-dev.txt &&
python -m pytest tests -ra -vv --tb=short python -m pytest tests -ra -vv --tb=short &&
python run_seeds.py --only seed_001_cleanup_pytest_artifacts --force
" "
echo "✓ pytest OK" echo "✓ pytest OK"

View File

@ -99,6 +99,49 @@ 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)
**Ersteinrichtung Dev** (automatisch per Seed nach jedem Backend-Start):
| Feld | Wert |
|------|------|
| E-Mail | `lars@stommer.com` |
| Passwort | `12345678` |
Alternativ: UI-Registrierung (nur wenn DB leer und Dev-Seed deaktiviert) oder Bootstrap per `.env`:
```env
KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
KAIRO_BOOTSTRAP_ADMIN_PASSWORD=…
KAIRO_BOOTSTRAP_TENANT_SLUG=default
```
Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md)
| Endpoint | Methode | Auth | Beschreibung |
|----------|---------|------|--------------|
| `/api/auth/setup-status` | GET | — | Ob Erstregistrierung offen ist |
| `/api/auth/register` | POST | — | Erster User → Portal-Admin + Tenant (nur wenn noch kein User) |
| `/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 +150,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 |

View File

@ -10,7 +10,9 @@ ENV PIP_DEFAULT_TIMEOUT=120
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN chmod +x /app/entrypoint.sh
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

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

38
backend/bootstrap.py Normal file
View File

@ -0,0 +1,38 @@
"""Bootstrap first admin user from environment (optional alternative to UI registration)."""
from __future__ import annotations
import os
from services.registration import provision_system_admin, user_count
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
if user_count() > 0:
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"
provision_system_admin(
email=email,
password=password,
display_name=display_name,
tenant_slug=tenant_slug,
tenant_name=tenant_name,
source="env_bootstrap",
)
print(f"[bootstrap] Systemadmin und Tenant '{tenant_slug}' angelegt")

20
backend/entrypoint.sh Normal file
View File

@ -0,0 +1,20 @@
#!/bin/sh
set -e
echo "=== Kairo Backend Startup ==="
if [ "${SKIP_DB_MIGRATE}" != "1" ] && [ "${SKIP_DB_MIGRATE}" != "true" ] && [ "${SKIP_DB_MIGRATE}" != "yes" ]; then
python run_migrations.py
else
echo "[SKIP_DB_MIGRATE] Migrationen übersprungen"
fi
if [ "${SKIP_SEEDS}" != "1" ] && [ "${SKIP_SEEDS}" != "true" ] && [ "${SKIP_SEEDS}" != "yes" ]; then
python run_seeds.py
else
echo "[SKIP_SEEDS] Data-Seeds übersprungen"
fi
export KAIRO_DB_READY=1
echo "=== Starte Anwendung: $* ==="
exec "$@"

View File

@ -11,15 +11,26 @@ from fastapi.middleware.cors import CORSMiddleware
from db import check_db from db import check_db
from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): if os.getenv("KAIRO_DB_READY") != "1":
print("[SKIP_DB_MIGRATE] Migrationen übersprungen") if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
else: import run_migrations
import run_migrations
exit_code = run_migrations.main() exit_code = run_migrations.main()
if exit_code != 0: if exit_code != 0:
print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})") print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
else:
print("[SKIP_DB_MIGRATE] Migrationen übersprungen")
if os.getenv("SKIP_SEEDS", "").strip().lower() not in ("1", "true", "yes"):
import run_seeds
exit_code = run_seeds.main()
if exit_code != 0:
print(f"[FAIL] Seeds fehlgeschlagen (Exit {exit_code})")
sys.exit(exit_code)
else:
print("[SKIP_SEEDS] Data-Seeds übersprungen")
allowed_origins = [ allowed_origins = [
origin.strip() origin.strip()
@ -42,6 +53,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

@ -0,0 +1,8 @@
-- Tracking für idempotente Data-Seeds (Checksum-basiert, bei Änderung erneut ausführbar)
CREATE TABLE IF NOT EXISTS data_seeds (
seed_name VARCHAR(255) PRIMARY KEY,
checksum VARCHAR(64) NOT NULL,
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

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

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

@ -0,0 +1,51 @@
"""Auth endpoints: login, logout, registration."""
from __future__ import annotations
from auth import login, logout, require_auth
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from services.registration import register_system_admin, setup_status
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel):
email: str = Field(min_length=3, max_length=255)
password: str = Field(min_length=8, max_length=256)
class RegisterRequest(BaseModel):
email: str = Field(min_length=3, max_length=255)
password: str = Field(min_length=8, max_length=256)
display_name: str = Field(min_length=1, max_length=255)
organization_name: str | None = Field(default=None, max_length=255)
@router.get("/setup-status")
def auth_setup_status():
return setup_status()
@router.post("/register")
def auth_register(body: RegisterRequest):
"""Erster User wird Portal-Systemadmin inkl. Default-Tenant."""
return register_system_admin(
email=body.email,
password=body.password,
display_name=body.display_name,
organization_name=body.organization_name,
)
@router.post("/login")
def auth_login(body: LoginRequest):
return login(body.email, body.password)
@router.post("/logout")
def auth_logout(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()

275
backend/run_seeds.py Normal file
View File

@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Apply idempotent data seeds with checksum-based tracking and re-run on change."""
from __future__ import annotations
import hashlib
import importlib.util
import os
import re
import sys
import time
from typing import Callable, List, Optional, Tuple
import psycopg2
import sqlparse
from db import db_params, get_connection
_SEED_PREFIX = re.compile(r"^seed_(\d+)_(.+)$")
_DEV_MARKER = ".dev."
def is_production() -> bool:
return os.getenv("ENVIRONMENT", "development").strip().lower() == "production"
def seeds_directory() -> str:
docker_path = "/app/seeds"
if os.path.isdir(docker_path):
return docker_path
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "seeds")
def init_data_seeds_table(conn) -> None:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS data_seeds (
seed_name VARCHAR(255) PRIMARY KEY,
checksum VARCHAR(64) NOT NULL,
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
conn.commit()
def _seed_sort_key(filename: str) -> Tuple[int, str]:
stem = filename
for suffix in (".sql", ".py"):
if stem.endswith(suffix):
stem = stem[: -len(suffix)]
break
match = _SEED_PREFIX.match(stem)
if match:
return (int(match.group(1)), stem)
return (0, stem)
def _seed_stem(filename: str) -> str:
if filename.endswith(".dev.sql"):
return filename[: -len(".dev.sql")]
if filename.endswith(".sql"):
return filename[: -len(".sql")]
if filename.endswith(".py"):
return filename[: -len(".py")]
return filename
def seed_files(seeds_dir: str) -> List[Tuple[str, str, str]]:
"""Return (seed_name, filepath, kind) sorted by numeric prefix."""
rows: List[Tuple[str, str, str]] = []
if not os.path.isdir(seeds_dir):
return rows
for filename in os.listdir(seeds_dir):
if filename.startswith("_") or not filename.startswith("seed_"):
continue
if filename.endswith(".sql"):
kind = "sql"
elif filename.endswith(".py"):
kind = "py"
else:
continue
stem = _seed_stem(filename)
rows.append((stem, os.path.join(seeds_dir, filename), kind))
rows.sort(key=lambda item: _seed_sort_key(item[0]))
return rows
def seed_applies_in_environment(filename: str) -> bool:
if _DEV_MARKER in filename and is_production():
return False
return True
def file_checksum(filepath: str) -> str:
digest = hashlib.sha256()
with open(filepath, "rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def applied_seed_checksum(conn, seed_name: str) -> Optional[str]:
with conn.cursor() as cur:
cur.execute("SELECT checksum FROM data_seeds WHERE seed_name = %s", (seed_name,))
row = cur.fetchone()
return row[0] if row else None
def record_seed(conn, seed_name: str, checksum: str) -> None:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO data_seeds (seed_name, checksum, executed_at, last_run_at)
VALUES (%s, %s, NOW(), NOW())
ON CONFLICT (seed_name) DO UPDATE SET
checksum = EXCLUDED.checksum,
last_run_at = NOW()
""",
(seed_name, checksum),
)
def _split_statements(sql_text: str) -> List[str]:
parts = sqlparse.split(sql_text.strip())
return [part.strip() for part in parts if part and part.strip()]
def run_sql_seed(conn, filepath: str) -> None:
with open(filepath, "r", encoding="utf-8") as handle:
body = handle.read()
statements = _split_statements(body)
with conn.cursor() as cur:
for stmt in statements:
cur.execute(stmt)
def run_python_seed(filepath: str) -> None:
module_name = f"kairo_seed_{hashlib.md5(filepath.encode()).hexdigest()[:12]}"
spec = importlib.util.spec_from_file_location(module_name, filepath)
if spec is None or spec.loader is None:
raise RuntimeError(f"Seed-Modul nicht ladbar: {filepath}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
run_fn: Callable[[], None] | None = getattr(module, "run", None)
if run_fn is None:
raise RuntimeError(f"Seed {filepath} hat keine run()-Funktion")
run_fn()
def connect_with_retry(max_retries: int = 30):
p = db_params()
for attempt in range(max_retries):
try:
conn = get_connection()
conn.autocommit = False
print(f"[OK] Connected to database: {p['dbname']}")
return conn
except psycopg2.OperationalError:
if attempt >= max_retries - 1:
raise
print(f"Waiting for database... ({attempt + 1}/{max_retries})")
time.sleep(2)
def run_seed(conn, seed_name: str, filepath: str, kind: str) -> tuple[bool, object]:
print(f"Running seed: {seed_name}")
try:
if kind == "sql":
run_sql_seed(conn, filepath)
else:
conn.commit()
conn.close()
run_python_seed(filepath)
conn = connect_with_retry(max_retries=5)
checksum = file_checksum(filepath)
record_seed(conn, seed_name, checksum)
conn.commit()
print(f" [OK] {seed_name}")
return True, conn
except Exception as exc:
try:
conn.rollback()
except Exception:
pass
print(f" [FAIL] {seed_name}: {exc}")
return False, conn
def pending_seeds(
conn,
seeds_dir: str,
*,
only: Optional[List[str]] = None,
force: bool = False,
) -> List[Tuple[str, str, str]]:
selected: List[Tuple[str, str, str]] = []
for seed_name, filepath, kind in seed_files(seeds_dir):
filename = os.path.basename(filepath)
if not seed_applies_in_environment(filename):
print(f" [SKIP] {seed_name} (nur Nicht-Prod)")
continue
if only and seed_name not in only:
continue
checksum = file_checksum(filepath)
is_dev_seed = _DEV_MARKER in filename
always_run = is_dev_seed and not is_production()
if force or always_run or applied_seed_checksum(conn, seed_name) != checksum:
selected.append((seed_name, filepath, kind))
return selected
def main(argv: Optional[List[str]] = None) -> int:
argv = argv if argv is not None else sys.argv[1:]
only: Optional[List[str]] = None
force = False
idx = 0
while idx < len(argv):
arg = argv[idx]
if arg == "--only" and idx + 1 < len(argv):
only = [part.strip() for part in argv[idx + 1].split(",") if part.strip()]
idx += 2
continue
if arg == "--force":
force = True
idx += 1
continue
print(f"[FAIL] Unbekanntes Argument: {arg}")
return 1
print("=" * 60)
print("Jinkendo Kairo — Data Seeds")
print("=" * 60)
seeds_dir = seeds_directory()
if not os.path.isdir(seeds_dir):
print(f"[OK] Kein seeds-Verzeichnis ({seeds_dir}) — nichts zu tun.")
return 0
try:
conn = connect_with_retry()
init_data_seeds_table(conn)
to_run = pending_seeds(conn, seeds_dir, only=only, force=force)
if not to_run:
print("[OK] Alle Seeds aktuell — nichts auszuführen.")
conn.close()
return 0
print(f"{len(to_run)} Seed(s) ausstehend:")
for seed_name, _, _ in to_run:
print(f" - {seed_name}")
for seed_name, filepath, kind in to_run:
ok, conn = run_seed(conn, seed_name, filepath, kind)
if not ok:
conn.close()
return 1
conn.close()
print("[OK] Seeds abgeschlossen.")
return 0
except Exception as exc:
print(f"[FAIL] {exc}")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,35 @@
-- Dev/CI: pytest-Artefakte entfernen (idempotent, sicher mehrfach ausführbar).
-- Nur in Nicht-Prod-Umgebungen (Dateiname *.dev.sql).
-- Reparatur: ungültige Human-Actors (user_id NULL verletzt CHECK constraint)
DELETE FROM actors WHERE actor_type = 'human' AND user_id IS NULL;
-- Abhängigkeiten vor User-Löschung (human actors: user_id NOT NULL constraint)
DELETE FROM actors
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
DELETE FROM tenant_memberships
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
DELETE FROM sessions
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
DELETE FROM audit_log
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
DELETE FROM users WHERE email ~* '@example\.com$';
-- Verwaiste pytest-Tenants (Factory-Slug t-{10 hex}), ohne Memberships
DELETE FROM actors a
USING tenants t
WHERE a.tenant_id = t.id
AND t.slug ~ '^t-[a-f0-9]{10}$'
AND NOT EXISTS (
SELECT 1 FROM tenant_memberships tm WHERE tm.tenant_id = t.id
);
DELETE FROM tenants t
WHERE t.slug ~ '^t-[a-f0-9]{10}$'
AND NOT EXISTS (
SELECT 1 FROM tenant_memberships tm WHERE tm.tenant_id = t.id
);

View File

@ -0,0 +1,9 @@
"""Bootstrap portal admin from KAIRO_BOOTSTRAP_* when no users exist."""
from __future__ import annotations
def run() -> None:
from bootstrap import bootstrap_admin_if_needed
bootstrap_admin_if_needed()

View File

@ -0,0 +1,9 @@
"""Dev: Portal-Admin lars@stommer.com sicherstellen (läuft bei jedem Start in Dev)."""
from __future__ import annotations
def run() -> None:
from services.dev_admin import ensure_dev_admin
ensure_dev_admin()

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

View File

@ -0,0 +1,187 @@
"""Dev-only admin seed — ensures a known portal admin exists."""
from __future__ import annotations
import os
from typing import Iterable
from auth import hash_password
from db import get_connection
from psycopg2.extras import RealDictCursor
from services.actors import create_actor, get_human_actor
from services.audit import log_audit
from services.registration import provision_system_admin, user_count
DEV_ADMIN_EMAIL = "lars@stommer.com"
DEV_ADMIN_PASSWORD = "12345678"
DEV_ADMIN_NAME = "Lars Stommer"
DEV_TENANT_SLUG = "stommer"
DEV_TENANT_NAME = "Stommer"
def _normalize_emails(emails: Iterable[str]) -> tuple[str, ...]:
return tuple(sorted({email.strip().lower() for email in emails if email.strip()}))
def delete_users_except(keep_emails: Iterable[str]) -> int:
"""Remove all users not in keep_emails (with FK-safe order). Returns deleted count."""
keep = _normalize_emails(keep_emails)
if not keep:
raise ValueError("keep_emails must not be empty")
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM actors WHERE actor_type = 'human' AND user_id IS NULL")
cur.execute(
"""
DELETE FROM actors
WHERE user_id IN (
SELECT id FROM users WHERE lower(email) <> ALL(%s)
)
""",
(list(keep),),
)
cur.execute(
"""
DELETE FROM tenant_memberships
WHERE user_id IN (
SELECT id FROM users WHERE lower(email) <> ALL(%s)
)
""",
(list(keep),),
)
cur.execute(
"""
DELETE FROM sessions
WHERE user_id IN (
SELECT id FROM users WHERE lower(email) <> ALL(%s)
)
""",
(list(keep),),
)
cur.execute(
"""
DELETE FROM audit_log
WHERE user_id IN (
SELECT id FROM users WHERE lower(email) <> ALL(%s)
)
""",
(list(keep),),
)
cur.execute(
"DELETE FROM users WHERE lower(email) <> ALL(%s) RETURNING email",
(list(keep),),
)
removed = cur.fetchall()
conn.commit()
return len(removed)
finally:
conn.close()
def _ensure_tenant(conn, *, slug: str, name: str) -> dict:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO tenants (slug, name)
VALUES (%s, %s)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id, slug, name
""",
(slug, name),
)
return dict(cur.fetchone())
def ensure_dev_admin(
*,
email: str = DEV_ADMIN_EMAIL,
password: str = DEV_ADMIN_PASSWORD,
display_name: str = DEV_ADMIN_NAME,
tenant_slug: str = DEV_TENANT_SLUG,
tenant_name: str = DEV_TENANT_NAME,
) -> dict:
"""Dev seed: remove other users, ensure portal admin + tenant + human actor."""
if os.getenv("ENVIRONMENT", "development").strip().lower() == "production":
raise RuntimeError("ensure_dev_admin darf nicht in Production laufen")
normalized_email = email.strip().lower()
removed = delete_users_except([normalized_email])
if removed:
print(f"[dev_admin] {removed} fremde User entfernt")
if user_count() == 0:
result = provision_system_admin(
email=normalized_email,
password=password,
display_name=display_name,
tenant_slug=tenant_slug,
tenant_name=tenant_name,
source="dev_seed",
)
print(f"[dev_admin] Portal-Admin angelegt: {normalized_email}")
return result
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
tenant = _ensure_tenant(conn, slug=tenant_slug, name=tenant_name)
tenant_id = str(tenant["id"])
cur.execute(
"""
UPDATE users
SET password_hash = %s,
display_name = %s,
portal_role = 'admin',
updated_at = NOW()
WHERE lower(email) = %s
RETURNING id, email, display_name, portal_role
""",
(hash_password(password), display_name.strip(), normalized_email),
)
row = cur.fetchone()
if not row:
raise RuntimeError(f"Dev-Admin {normalized_email} fehlt nach Bereinigung")
user = dict(row)
user_id = str(user["id"])
cur.execute(
"""
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
VALUES (%s, %s, 'owner')
ON CONFLICT (tenant_id, user_id) DO UPDATE SET tenant_role = 'owner'
""",
(tenant_id, user_id),
)
conn.commit()
finally:
conn.close()
if not get_human_actor(tenant_id, user_id):
create_actor(
tenant_id=tenant_id,
actor_type="human",
name=display_name.strip(),
user_id=user_id,
)
log_audit(
"auth.dev_admin_ensure",
user_id=user_id,
tenant_id=tenant_id,
details={"email": normalized_email, "removed_users": removed},
)
print(f"[dev_admin] Portal-Admin bestätigt: {normalized_email}")
return {
"user": {
"id": user_id,
"email": user["email"],
"display_name": user["display_name"],
"portal_role": user["portal_role"],
},
"tenant": {"id": tenant_id, "slug": tenant["slug"], "name": tenant["name"]},
}

View File

@ -0,0 +1,152 @@
"""First-user / system-admin provisioning (bootstrap + registration)."""
from __future__ import annotations
import os
import re
from typing import Any
from auth import create_session, get_user_by_email, hash_password
from db import get_connection
from psycopg2.extras import RealDictCursor
from fastapi import HTTPException
from services.actors import create_actor
from services.audit import log_audit
def user_count() -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM users")
return int(cur.fetchone()[0])
finally:
conn.close()
def registration_open() -> bool:
return user_count() == 0
def setup_status() -> dict[str, Any]:
count = user_count()
return {
"has_users": count > 0,
"registration_open": count == 0,
"user_count": count,
}
def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return (slug[:48] or "default")
def provision_system_admin(
*,
email: str,
password: str,
display_name: str,
tenant_slug: str | None = None,
tenant_name: str | None = None,
source: str = "bootstrap",
) -> dict[str, Any]:
"""Create portal admin, default tenant, owner membership and human actor."""
if user_count() > 0:
raise ValueError("users_already_exist")
normalized_email = email.strip().lower()
slug = tenant_slug or os.getenv("KAIRO_BOOTSTRAP_TENANT_SLUG") or "default"
name = tenant_name or os.getenv("KAIRO_BOOTSTRAP_TENANT_NAME") or "Default Tenant"
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO tenants (slug, name)
VALUES (%s, %s)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id, slug, name
""",
(slug, name),
)
tenant = dict(cur.fetchone())
cur.execute(
"""
INSERT INTO users (email, password_hash, display_name, portal_role)
VALUES (%s, %s, %s, 'admin')
RETURNING id, email, display_name, portal_role
""",
(normalized_email, hash_password(password), display_name.strip()),
)
user = dict(cur.fetchone())
user_id = str(user["id"])
tenant_id = str(tenant["id"])
cur.execute(
"""
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
VALUES (%s, %s, 'owner')
""",
(tenant_id, user_id),
)
conn.commit()
finally:
conn.close()
create_actor(
tenant_id=tenant_id,
actor_type="human",
name=display_name.strip(),
user_id=user_id,
)
log_audit(
"auth.register_system_admin",
user_id=user_id,
tenant_id=tenant_id,
details={"email": normalized_email, "source": source, "tenant_slug": slug},
)
session = create_session(user_id, tenant_id)
return {
"token": session["token"],
"expires_at": session["expires_at"].isoformat(),
"user": {
"id": user_id,
"email": user["email"],
"display_name": user["display_name"],
"portal_role": user["portal_role"],
},
"tenant": {"id": tenant_id, "slug": tenant["slug"], "name": tenant["name"]},
}
def register_system_admin(
*,
email: str,
password: str,
display_name: str,
organization_name: str | None = None,
) -> dict[str, Any]:
if get_user_by_email(email):
raise HTTPException(status_code=409, detail="E-Mail bereits registriert")
if not registration_open():
raise HTTPException(
status_code=403,
detail="Registrierung geschlossen — Systemadministrator ist bereits eingerichtet",
)
org = (organization_name or display_name or "Default Tenant").strip()
slug = _slugify(org)
return provision_system_admin(
email=email,
password=password,
display_name=display_name.strip(),
tenant_slug=slug,
tenant_name=org,
source="registration",
)

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

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

@ -0,0 +1,60 @@
"""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_SEEDS", "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(scope="session", autouse=True)
def _seed_hygiene():
"""Vor/nach Tests pytest-Artefakte aus der geteilten Dev-DB entfernen."""
import run_seeds
run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"])
yield
run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"])
@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]}@example.com"
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,8 @@ 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
assert "003_data_seeds_tracking" in names
def test_migration_runner_is_idempotent(): def test_migration_runner_is_idempotent():
@ -42,6 +44,8 @@ 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
assert "003_data_seeds_tracking" in executed
def test_core_table_exists(): def test_core_table_exists():

View File

@ -0,0 +1,69 @@
"""Registration and first system-admin tests."""
from __future__ import annotations
import uuid
from auth import AUTH_HEADER, get_session
from db import get_connection
from services.registration import user_count
def test_setup_status_open_without_users(client):
if user_count() > 0:
return
res = client.get("/api/auth/setup-status")
assert res.status_code == 200
body = res.json()
assert body["registration_open"] is True
assert body["has_users"] is False
def test_register_first_user_becomes_portal_admin(client):
if user_count() > 0:
return
suffix = uuid.uuid4().hex[:8]
email = f"admin-{suffix}@example.com"
res = client.post(
"/api/auth/register",
json={
"email": email,
"password": "secure-pass-123",
"display_name": "System Admin",
"organization_name": "Kairo Org",
},
)
assert res.status_code == 200, res.text
body = res.json()
assert body["user"]["portal_role"] == "admin"
assert body["token"]
assert get_session(body["token"]) is not None
me = client.get("/api/me", headers={AUTH_HEADER: body["token"]})
assert me.status_code == 200
assert me.json()["portal_role"] == "admin"
ctx = client.get("/api/me/context", headers={AUTH_HEADER: body["token"]})
assert ctx.status_code == 200
assert ctx.json()["tenant"]["role"] == "owner"
assert ctx.json()["actor"]["type"] == "human"
# cleanup: allow re-run in isolated env — skip delete in shared CI DB
closed = client.get("/api/auth/setup-status")
assert closed.json()["registration_open"] is False
def test_register_closed_when_users_exist(client):
if user_count() == 0:
return
res = client.post(
"/api/auth/register",
json={
"email": f"other-{uuid.uuid4().hex[:8]}@example.com",
"password": "secure-pass-123",
"display_name": "Other User",
},
)
assert res.status_code == 403

View File

@ -0,0 +1,54 @@
"""Data seed runner tests (require PostgreSQL)."""
from __future__ import annotations
import uuid
import run_seeds
from tests.factories import create_user
def test_seed_runner_finds_seeds():
seeds_dir = run_seeds.seeds_directory()
names = [name for name, _, _ in run_seeds.seed_files(seeds_dir)]
assert "seed_001_cleanup_pytest_artifacts" in names
assert "seed_002_bootstrap_admin" in names
def test_cleanup_seed_removes_example_com_users():
suffix = uuid.uuid4().hex[:8]
user = create_user(email=f"seed-test-{suffix}@example.com")
assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts", "--force"]) == 0
from db import get_connection
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM users WHERE id = %s", (user["id"],))
assert cur.fetchone()[0] == 0
finally:
conn.close()
def test_cleanup_seed_is_idempotent():
assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) == 0
assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) == 0
def test_dev_admin_seed_ensures_lars():
import os
if os.getenv("ENVIRONMENT", "development").strip().lower() == "production":
return
from auth import get_user_by_email, verify_password
from services.dev_admin import DEV_ADMIN_EMAIL, DEV_ADMIN_PASSWORD
assert run_seeds.main(["--only", "seed_003_ensure_dev_admin"]) == 0
user = get_user_by_email(DEV_ADMIN_EMAIL)
assert user is not None
assert user["portal_role"] == "admin"
assert verify_password(DEV_ADMIN_PASSWORD, user["password_hash"])

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 = "003"
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:
@ -49,8 +54,8 @@ services:
] ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 12 retries: 18
start_period: 30s start_period: 90s
restart: unless-stopped restart: unless-stopped
networks: networks:
- dev-kairo-network - dev-kairo-network

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:

54
docs/MIGRATIONS.md Normal file
View File

@ -0,0 +1,54 @@
# Migrationen & Data-Seeds
Kairo trennt **Schema-Migrationen** (einmalig) von **Data-Seeds** (idempotent, bei Änderung erneut ausführbar).
## Schema-Migrationen
| | |
|---|---|
| **Pfad** | `backend/migrations/NNN_beschreibung.sql` |
| **Tracking** | Tabelle `schema_migrations` |
| **Ausführung** | Beim Backend-Start (`run_migrations.py`), überspringbar via `SKIP_DB_MIGRATE=1` |
| **Regel** | Jede Datei wird **genau einmal** angewendet. Änderungen → neue nummerierte Datei. |
## Data-Seeds
| | |
|---|---|
| **Pfad** | `backend/seeds/seed_NNN_beschreibung.sql` oder `.py` |
| **Tracking** | Tabelle `data_seeds` (Name + SHA256-Checksum) |
| **Ausführung** | Nach Schema-Migrationen (`run_seeds.py`), überspringbar via `SKIP_SEEDS=1` |
| **Regel** | Seed läuft erneut, wenn die Datei **geändert** wurde (Checksum abweicht) oder `--force` gesetzt ist. |
### Umgebungsfilter
Dateien mit **`.dev.`** im Namen (z. B. `seed_001_….dev.sql`) laufen **nicht in Production** (`ENVIRONMENT=production`).
### Seeds manuell ausführen
```bash
docker compose -f docker-compose.dev-env.yml exec backend python run_seeds.py
docker compose -f docker-compose.dev-env.yml exec backend python run_seeds.py --only seed_001_cleanup_pytest_artifacts --force
```
## Aktuelle Seeds
| Seed | Typ | Zweck |
|------|-----|--------|
| `seed_001_cleanup_pytest_artifacts` | SQL (dev) | Entfernt pytest/CI-User (`*@example.com`) und verwaiste Test-Tenants |
| `seed_002_bootstrap_admin` | Python | Legt Systemadmin aus `KAIRO_BOOTSTRAP_*` an, wenn noch kein User existiert |
| `seed_003_ensure_dev_admin` | Python (dev) | Entfernt fremde Dev-User, stellt `lars@stommer.com` als Portal-Admin sicher (jeder Start) |
Dev-Seeds (`.dev.*`) werden in Nicht-Prod-Umgebungen **bei jedem Backend-Start** erneut ausgeführt.
## Neuen Seed anlegen
1. Datei `backend/seeds/seed_NNN_kurzname.sql` oder `.py` anlegen (Python: `def run() -> None:`).
2. SQL idempotent halten (`DELETE … WHERE …`, `INSERT … ON CONFLICT`, etc.).
3. Nur Dev/CI: `.dev.sql` / `.dev.py` Suffix verwenden.
4. Nach Deploy prüfen: `python run_seeds.py` — bei geänderter Datei wird der Seed automatisch erneut ausgeführt.
## Tests & CI
- pytest setzt `SKIP_SEEDS=1` beim App-Import; Cleanup läuft über Fixture + nach CI-pytest.
- Geteilte Dev-DB wird nach jedem Test-Lauf bereinigt (`run_seeds.py --only seed_001_cleanup_pytest_artifacts --force`).

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

View File

@ -1,8 +1,39 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
const TOKEN_KEY = 'kairo_auth_token'
function apiHeaders(token) {
const headers = { 'Content-Type': 'application/json' }
if (token) headers['X-Auth-Token'] = token
return headers
}
function parseError(body, fallback) {
if (!body) return fallback
if (typeof body.detail === 'string') return body.detail
if (Array.isArray(body.detail)) {
return body.detail.map((d) => d.msg || JSON.stringify(d)).join(', ')
}
return fallback
}
export default function App() { export default function App() {
const [health, setHealth] = useState(null) const [health, setHealth] = useState(null)
const [error, setError] = useState(null) const [healthError, setHealthError] = useState(null)
const [setup, setSetup] = useState(null)
const [setupLoading, setSetupLoading] = useState(true)
const [setupError, setSetupError] = useState(null)
const [authMode, setAuthMode] = useState('login')
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [organizationName, setOrganizationName] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [authError, setAuthError] = useState(null)
const [authBusy, setAuthBusy] = useState(false)
const [me, setMe] = useState(null)
const [context, setContext] = useState(null)
useEffect(() => { useEffect(() => {
fetch('/api/health') fetch('/api/health')
@ -11,21 +42,348 @@ export default function App() {
return res.json() return res.json()
}) })
.then(setHealth) .then(setHealth)
.catch((err) => setError(err.message)) .catch((err) => setHealthError(err.message))
async function loadSetupStatus() {
setSetupLoading(true)
setSetupError(null)
try {
const res = await fetch('/api/auth/setup-status')
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(
res.status === 404
? 'Endpoint /api/auth/setup-status nicht gefunden — Backend neu deployen?'
: parseError(body, `Setup-Status fehlgeschlagen (HTTP ${res.status})`),
)
}
if (typeof body.registration_open !== 'boolean') {
throw new Error('Ungültige Antwort vom Setup-Status — Backend-Version prüfen')
}
setSetup(body)
if (body.registration_open) setAuthMode('register')
} catch (err) {
setSetup(null)
setSetupError(err.message)
} finally {
setSetupLoading(false)
}
}
loadSetupStatus()
}, []) }, [])
const loadSession = useCallback(async (activeToken) => {
if (!activeToken) {
setMe(null)
setContext(null)
return
}
const meRes = await fetch('/api/me', { headers: apiHeaders(activeToken) })
if (!meRes.ok) {
localStorage.removeItem(TOKEN_KEY)
setToken('')
setMe(null)
setContext(null)
throw new Error(meRes.status === 401 ? 'Session abgelaufen' : `HTTP ${meRes.status}`)
}
setMe(await meRes.json())
const ctxRes = await fetch('/api/me/context', { headers: apiHeaders(activeToken) })
if (ctxRes.ok) setContext(await ctxRes.json())
}, [])
useEffect(() => {
if (!token) return
loadSession(token).catch((err) => setAuthError(err.message))
}, [token, loadSession])
function applyLogin(data) {
localStorage.setItem(TOKEN_KEY, data.token)
setToken(data.token)
setPassword('')
setPasswordConfirm('')
setSetup({ registration_open: false, has_users: true, user_count: 1 })
setAuthMode('login')
}
async function handleLogin(event) {
event.preventDefault()
setAuthBusy(true)
setAuthError(null)
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ email, password }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(parseError(body, `Login fehlgeschlagen (${res.status})`))
applyLogin(body)
} catch (err) {
setAuthError(err.message)
} finally {
setAuthBusy(false)
}
}
async function handleRegister(event) {
event.preventDefault()
if (password !== passwordConfirm) {
setAuthError('Passwörter stimmen nicht überein')
return
}
setAuthBusy(true)
setAuthError(null)
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({
email,
password,
display_name: displayName,
organization_name: organizationName || undefined,
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(parseError(body, `Registrierung fehlgeschlagen (${res.status})`))
applyLogin(body)
} catch (err) {
setAuthError(err.message)
} finally {
setAuthBusy(false)
}
}
async function handleLogout() {
setAuthBusy(true)
setAuthError(null)
try {
if (token) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: apiHeaders(token),
})
}
} catch {
/* lokal entfernen */
} finally {
localStorage.removeItem(TOKEN_KEY)
setToken('')
setMe(null)
setContext(null)
setAuthBusy(false)
}
}
const registrationOpen = setup?.registration_open === true
const registrationClosedBecauseUsersExist = setup?.has_users === true && !registrationOpen
return ( return (
<main className="shell"> <main className="shell">
<h1>Jinkendo Kairo</h1> <header className="hero">
<p>Operativer Program Director Sprint 0 / AP0.1</p> <h1>Jinkendo Kairo</h1>
<p>Operativer Program Director Sprint 0 / AP0.2</p>
<p className="muted">Auth, Tenant &amp; Actor Foundation</p>
</header>
<section className="card auth-card">
<h2>{token ? 'Session' : registrationOpen ? 'Ersteinrichtung' : 'Anmeldung'}</h2>
{!token && registrationOpen && (
<p className="setup-hint">
Noch kein Systemadministrator. Der erste Account wird automatisch{' '}
<strong>Portal-Admin</strong> und erhält einen Default-Tenant.
</p>
)}
{!token && setupLoading && (
<p className="muted">Prüfe Ersteinrichtung </p>
)}
{!token && setupError && (
<p className="error">{setupError}</p>
)}
{!token && registrationClosedBecauseUsersExist && (
<p className="setup-hint setup-hint-muted">
Es existiert bereits mindestens ein Benutzer ({setup.user_count ?? '≥1'}).
Registrierung ist nur für die Ersteinrichtung vorgesehen bitte anmelden.
Falls der Admin per <code>KAIRO_BOOTSTRAP_*</code> in der <code>.env</code> angelegt
wurde, nutzen Sie diese Zugangsdaten.
</p>
)}
{!token && !setupLoading && !setupError && (
<>
<div className="tabs" role="tablist">
<button
type="button"
role="tab"
className={authMode === 'login' ? 'tab active' : 'tab'}
aria-selected={authMode === 'login'}
onClick={() => { setAuthMode('login'); setAuthError(null) }}
>
Anmelden
</button>
<button
type="button"
role="tab"
className={authMode === 'register' ? 'tab active' : 'tab'}
aria-selected={authMode === 'register'}
disabled={!registrationOpen}
onClick={() => { setAuthMode('register'); setAuthError(null) }}
>
Registrieren
</button>
</div>
{authMode === 'login' && (
<form className="form" onSubmit={handleLogin}>
<label>
E-Mail
<input
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Passwort
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</label>
<button type="submit" disabled={authBusy}>
{authBusy ? 'Anmelden …' : 'Anmelden'}
</button>
</form>
)}
{authMode === 'register' && registrationOpen && (
<form className="form" onSubmit={handleRegister}>
<label>
Anzeigename
<input
type="text"
autoComplete="name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
/>
</label>
<label>
Organisation / Tenant (optional)
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="z. B. Mein Programm-Team"
/>
</label>
<label>
E-Mail
<input
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Passwort
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</label>
<label>
Passwort bestätigen
<input
type="password"
autoComplete="new-password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
minLength={8}
required
/>
</label>
<button type="submit" disabled={authBusy}>
{authBusy ? 'Registrieren …' : 'Systemadmin anlegen'}
</button>
</form>
)}
{authMode === 'register' && !registrationOpen && (
<p className="muted">
Registrierung ist geschlossen. Bitte melden Sie sich an oder wenden Sie sich an
den Administrator.
</p>
)}
</>
)}
{token && me && (
<div className="session">
<p>
Eingeloggt als <strong>{me.display_name}</strong> ({me.email})
</p>
<p className="muted">
{me.portal_role === 'admin' ? 'Portal-Systemadmin' : `Portal-Rolle: ${me.portal_role}`}
{context?.tenant?.name && ` · ${context.tenant.name}`}
</p>
<button type="button" className="secondary" onClick={handleLogout} disabled={authBusy}>
Abmelden
</button>
</div>
)}
{authError && <p className="error">{authError}</p>}
</section>
<section className="card"> <section className="card">
<h2>API Health</h2> <h2>Ersteinrichtung</h2>
{error && <p className="error">Fehler: {error}</p>} {setupLoading && <p className="muted">Lade </p>}
{!error && !health && <p>Lade </p>} {setupError && <p className="error">{setupError}</p>}
{health && ( {setup && <pre>{JSON.stringify(setup, null, 2)}</pre>}
<pre>{JSON.stringify(health, null, 2)}</pre> {!setupLoading && !setup && !setupError && (
<p className="muted">Kein Setup-Status verfügbar.</p>
)} )}
</section> </section>
<section className="card">
<h2>API Health</h2>
{healthError && <p className="error">Fehler: {healthError}</p>}
{!healthError && !health && <p>Lade </p>}
{health && <pre>{JSON.stringify(health, null, 2)}</pre>}
</section>
{token && me && (
<>
<section className="card">
<h2>/api/me</h2>
<pre>{JSON.stringify(me, null, 2)}</pre>
</section>
{context && (
<section className="card">
<h2>/api/me/context</h2>
<pre>{JSON.stringify(context, null, 2)}</pre>
</section>
)}
</>
)}
</main> </main>
) )
} }

View File

@ -11,7 +11,16 @@ body {
.shell { .shell {
max-width: 720px; max-width: 720px;
margin: 2rem auto; margin: 2rem auto;
padding: 0 1rem; padding: 0 1rem 3rem;
}
.hero h1 {
margin-bottom: 0.25rem;
}
.muted {
color: #555;
font-size: 0.95rem;
} }
.card { .card {
@ -19,6 +28,54 @@ body {
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
margin-top: 1rem;
}
.card h2 {
margin-top: 0;
font-size: 1.1rem;
}
.auth-card {
margin-top: 0;
}
.setup-hint {
background: #eef4fc;
border: 1px solid #c5d9f0;
border-radius: 6px;
padding: 0.75rem;
font-size: 0.92rem;
margin: 0 0 1rem;
}
.setup-hint-muted {
background: #f3f3f3;
border-color: #ddd;
}
.tabs {
display: flex;
gap: 0.35rem;
margin-bottom: 1rem;
}
.tab {
background: #eee;
color: #333;
border: 1px solid #ccc;
padding: 0.45rem 0.9rem;
}
.tab.active {
background: #1a4d8f;
color: #fff;
border-color: #1a4d8f;
}
.tab:disabled {
opacity: 0.45;
cursor: not-allowed;
} }
.error { .error {
@ -27,4 +84,49 @@ body {
pre { pre {
overflow: auto; overflow: auto;
font-size: 0.85rem;
}
.form {
display: grid;
gap: 0.75rem;
}
.form label {
display: grid;
gap: 0.25rem;
font-size: 0.9rem;
}
.form input {
padding: 0.5rem 0.6rem;
border: 1px solid #ccc;
border-radius: 6px;
font: inherit;
}
button {
padding: 0.55rem 1rem;
border: none;
border-radius: 6px;
background: #1a4d8f;
color: #fff;
font: inherit;
cursor: pointer;
width: fit-content;
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
button.secondary {
background: #eee;
color: #222;
border: 1px solid #ccc;
}
.session p {
margin: 0.35rem 0;
} }