From 564008be131322f4bfbbb7a9ef855378a390b3ed Mon Sep 17 00:00:00 2001
From: Lars
Date: Sat, 4 Jul 2026 20:09:54 +0200
Subject: [PATCH 1/8] AP0.2: Auth, Tenant, Actor Foundation mit Sessions,
TenantContext und Tests.
Co-authored-by: Cursor
---
.env.example | 5 +
README.md | 34 ++-
backend/auth.py | 243 ++++++++++++++++++
backend/bootstrap.py | 88 +++++++
backend/main.py | 9 +
.../002_auth_identity_tenant_actor.sql | 81 ++++++
backend/requirements.txt | 2 +
backend/routers/__init__.py | 0
backend/routers/auth.py | 27 ++
backend/routers/me.py | 49 ++++
backend/services/__init__.py | 0
backend/services/actors.py | 68 +++++
backend/services/audit.py | 30 +++
backend/tenant_context.py | 151 +++++++++++
backend/tests/conftest.py | 49 ++++
backend/tests/factories.py | 91 +++++++
backend/tests/test_auth.py | 80 ++++++
backend/tests/test_health.py | 1 +
backend/tests/test_migrations.py | 2 +
backend/tests/test_tenant_actor.py | 46 ++++
backend/version.py | 4 +-
docker-compose.dev-env.yml | 5 +
docker-compose.yml | 3 +
.../Sprint0_AP0_1_Completion_Report_v0.1.md | 121 +++++++++
.../Sprint0_AP0_2_Completion_Report_v0.1.md | 154 +++++++++++
25 files changed, 1340 insertions(+), 3 deletions(-)
create mode 100644 backend/auth.py
create mode 100644 backend/bootstrap.py
create mode 100644 backend/migrations/002_auth_identity_tenant_actor.sql
create mode 100644 backend/routers/__init__.py
create mode 100644 backend/routers/auth.py
create mode 100644 backend/routers/me.py
create mode 100644 backend/services/__init__.py
create mode 100644 backend/services/actors.py
create mode 100644 backend/services/audit.py
create mode 100644 backend/tenant_context.py
create mode 100644 backend/tests/conftest.py
create mode 100644 backend/tests/factories.py
create mode 100644 backend/tests/test_auth.py
create mode 100644 backend/tests/test_tenant_actor.py
create mode 100644 docs/sprints/Sprint0_AP0_1_Completion_Report_v0.1.md
create mode 100644 docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md
diff --git a/.env.example b/.env.example
index 5405d19..df7c40f 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,11 @@
# APP_URL=https://dev.kairo.jinkendo.de
# ALLOWED_ORIGINS=https://dev.kairo.jinkendo.de,http://192.168.2.49:3097
# 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) ───────────────────────────────────────────────
DB_NAME=kairo
diff --git a/README.md b/README.md
index 60b0c4d..0bfc5e0 100644
--- a/README.md
+++ b/README.md
@@ -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
+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
| Bereich | Status |
@@ -107,5 +139,5 @@ UI: http://localhost:3097 · API: http://localhost:8097
| Docker Compose (Prod + Dev) | erledigt |
| Backend (FastAPI, Migrationen, `/api/health`) | 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 |
diff --git a/backend/auth.py b/backend/auth.py
new file mode 100644
index 0000000..7ababec
--- /dev/null
+++ b/backend/auth.py
@@ -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
diff --git a/backend/bootstrap.py b/backend/bootstrap.py
new file mode 100644
index 0000000..270fbc9
--- /dev/null
+++ b/backend/bootstrap.py
@@ -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")
diff --git a/backend/main.py b/backend/main.py
index 3aabe41..81e8403 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -21,6 +21,10 @@ else:
print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})")
sys.exit(exit_code)
+ import bootstrap
+
+ bootstrap.bootstrap_admin_if_needed()
+
allowed_origins = [
origin.strip()
for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:3097").split(",")
@@ -42,6 +46,11 @@ app.add_middleware(
allow_headers=["*"],
)
+from routers import auth, me # noqa: E402
+
+app.include_router(auth.router)
+app.include_router(me.router)
+
@app.get("/api/health")
def api_health():
diff --git a/backend/migrations/002_auth_identity_tenant_actor.sql b/backend/migrations/002_auth_identity_tenant_actor.sql
new file mode 100644
index 0000000..9436520
--- /dev/null
+++ b/backend/migrations/002_auth_identity_tenant_actor.sql
@@ -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);
diff --git a/backend/requirements.txt b/backend/requirements.txt
index dfd7b31..435f881 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -3,3 +3,5 @@ uvicorn[standard]==0.29.0
psycopg2-binary==2.9.9
sqlparse>=0.5.0
pydantic==2.7.1
+email-validator>=2.1.0
+bcrypt>=4.1.0
diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/routers/auth.py b/backend/routers/auth.py
new file mode 100644
index 0000000..bb661d6
--- /dev/null
+++ b/backend/routers/auth.py
@@ -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}
diff --git a/backend/routers/me.py b/backend/routers/me.py
new file mode 100644
index 0000000..b5834c9
--- /dev/null
+++ b/backend/routers/me.py
@@ -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()
diff --git a/backend/services/__init__.py b/backend/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/services/actors.py b/backend/services/actors.py
new file mode 100644
index 0000000..e2cee3d
--- /dev/null
+++ b/backend/services/actors.py
@@ -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()
diff --git a/backend/services/audit.py b/backend/services/audit.py
new file mode 100644
index 0000000..e131134
--- /dev/null
+++ b/backend/services/audit.py
@@ -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()
diff --git a/backend/tenant_context.py b/backend/tenant_context.py
new file mode 100644
index 0000000..209f1c8
--- /dev/null
+++ b/backend/tenant_context.py
@@ -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
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..f62facc
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -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)
diff --git a/backend/tests/factories.py b/backend/tests/factories.py
new file mode 100644
index 0000000..7cda720
--- /dev/null
+++ b/backend/tests/factories.py
@@ -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"]}
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
new file mode 100644
index 0000000..23dd30a
--- /dev/null
+++ b/backend/tests/test_auth.py
@@ -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
diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py
index 1a0c7f0..eeb1aba 100644
--- a/backend/tests/test_health.py
+++ b/backend/tests/test_health.py
@@ -7,6 +7,7 @@ from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
monkeypatch.setenv("SKIP_DB_MIGRATE", "1")
+ monkeypatch.setenv("SKIP_BOOTSTRAP", "1")
import importlib
import main as main_module
diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py
index 3b6c5c2..7e9b5f0 100644
--- a/backend/tests/test_migrations.py
+++ b/backend/tests/test_migrations.py
@@ -32,6 +32,7 @@ def test_migration_runner_finds_migrations():
files = run_migrations.migration_files(migrations_dir)
names = [name for name, _ in files]
assert "001_init_core" in names
+ assert "002_auth_identity_tenant_actor" in names
def test_migration_runner_is_idempotent():
@@ -42,6 +43,7 @@ def test_migration_runner_is_idempotent():
executed = run_migrations.executed_migrations(conn)
conn.close()
assert "001_init_core" in executed
+ assert "002_auth_identity_tenant_actor" in executed
def test_core_table_exists():
diff --git a/backend/tests/test_tenant_actor.py b/backend/tests/test_tenant_actor.py
new file mode 100644
index 0000000..ff73ac3
--- /dev/null
+++ b/backend/tests/test_tenant_actor.py
@@ -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"
diff --git a/backend/version.py b/backend/version.py
index 033eae6..0220179 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.1.0-ap0.1"
-DB_SCHEMA_VERSION = "001"
+APP_VERSION = "0.2.0-ap0.2"
+DB_SCHEMA_VERSION = "002"
APP_NAME = "jinkendo-kairo"
diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml
index 90f04a9..160c621 100644
--- a/docker-compose.dev-env.yml
+++ b/docker-compose.dev-env.yml
@@ -34,6 +34,11 @@ services:
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}"
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:
- "${KAIRO_BACKEND_PORT:-8097}:8000"
depends_on:
diff --git a/docker-compose.yml b/docker-compose.yml
index c7fdb74..02a1405 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,6 +35,9 @@ services:
APP_URL: "${APP_URL:-https://kairo.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kairo.jinkendo.de}"
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:
- "${KAIRO_BACKEND_PORT:-8004}:8000"
depends_on:
diff --git a/docs/sprints/Sprint0_AP0_1_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_1_Completion_Report_v0.1.md
new file mode 100644
index 0000000..bd91665
--- /dev/null
+++ b/docs/sprints/Sprint0_AP0_1_Completion_Report_v0.1.md
@@ -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.*
diff --git a/docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md
new file mode 100644
index 0000000..abe0a91
--- /dev/null
+++ b/docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md
@@ -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.*
From 25d397622655bd084494f2e7005d6702377a9466 Mon Sep 17 00:00:00 2001
From: Lars
Date: Sat, 4 Jul 2026 20:14:34 +0200
Subject: [PATCH 2/8] =?UTF-8?q?Fix:=20Login-Email=20als=20str=20=E2=80=94?=
=?UTF-8?q?=20EmailStr=20lehnt=20.local-Domains=20ab=20(422=20in=20CI).?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
backend/routers/auth.py | 5 +++--
backend/tests/factories.py | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/backend/routers/auth.py b/backend/routers/auth.py
index bb661d6..da2eacc 100644
--- a/backend/routers/auth.py
+++ b/backend/routers/auth.py
@@ -4,13 +4,14 @@ from __future__ import annotations
from auth import login, logout, require_auth
from fastapi import APIRouter, Depends, Response
-from pydantic import BaseModel, EmailStr, Field
+from pydantic import BaseModel, Field
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel):
- email: EmailStr
+ # str statt EmailStr: Self-Hosted/Dev nutzt oft .local-Domains (admin@kairo.local)
+ email: str = Field(min_length=3, max_length=255)
password: str = Field(min_length=8, max_length=256)
diff --git a/backend/tests/factories.py b/backend/tests/factories.py
index 7cda720..42bbcba 100644
--- a/backend/tests/factories.py
+++ b/backend/tests/factories.py
@@ -32,7 +32,7 @@ def create_user(
display_name: str = "Test User",
portal_role: str = "user",
) -> dict:
- email = email or f"user-{uuid.uuid4().hex[:8]}@test.local"
+ email = email or f"user-{uuid.uuid4().hex[:8]}@example.com"
conn = get_connection()
try:
with conn.cursor() as cur:
From 45a108b4a089d72ec14710c8080255fe649fa033 Mon Sep 17 00:00:00 2001
From: Lars
Date: Sat, 4 Jul 2026 20:17:03 +0200
Subject: [PATCH 3/8] Frontend: AP0.2 Login, /api/me und TenantContext-Anzeige.
Co-authored-by: Cursor
---
frontend/src/App.jsx | 171 ++++++++++++++++++++++++++++++++++++++++---
frontend/src/app.css | 62 +++++++++++++++-
2 files changed, 222 insertions(+), 11 deletions(-)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index b612a68..13dd341 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,8 +1,23 @@
-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
+}
export default function App() {
const [health, setHealth] = useState(null)
- const [error, setError] = useState(null)
+ const [healthError, setHealthError] = useState(null)
+ const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '')
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [authError, setAuthError] = useState(null)
+ const [authBusy, setAuthBusy] = useState(false)
+ const [me, setMe] = useState(null)
+ const [context, setContext] = useState(null)
useEffect(() => {
fetch('/api/health')
@@ -11,21 +26,157 @@ export default function App() {
return res.json()
})
.then(setHealth)
- .catch((err) => setError(err.message))
+ .catch((err) => setHealthError(err.message))
}, [])
+ 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])
+
+ 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 }),
+ })
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}))
+ throw new Error(body.detail || `Login fehlgeschlagen (${res.status})`)
+ }
+ const data = await res.json()
+ localStorage.setItem(TOKEN_KEY, data.token)
+ setToken(data.token)
+ setPassword('')
+ } 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 {
+ /* Session trotzdem lokal entfernen */
+ } finally {
+ localStorage.removeItem(TOKEN_KEY)
+ setToken('')
+ setMe(null)
+ setContext(null)
+ setAuthBusy(false)
+ }
+ }
+
return (
- Jinkendo Kairo
- Operativer Program Director — Sprint 0 / AP0.1
+
+ Jinkendo Kairo
+ Operativer Program Director — Sprint 0 / AP0.2
+ Auth, Tenant & Actor Foundation
+
+
API Health
- {error && Fehler: {error}
}
- {!error && !health && Lade …
}
- {health && (
- {JSON.stringify(health, null, 2)}
- )}
+ {healthError && Fehler: {healthError}
}
+ {!healthError && !health && Lade …
}
+ {health && {JSON.stringify(health, null, 2)}}
+
+
+ Session
+ {!token && (
+
+ )}
+
+ {token && me && (
+
+
+ Eingeloggt als {me.display_name} ({me.email})
+
+
+ Portal-Rolle: {me.portal_role}
+ {me.active_tenant_id && ` · Tenant: ${me.active_tenant_id}`}
+
+
+
+ )}
+
+ {authError && {authError}
}
+
+
+ {token && me && (
+ <>
+
+ /api/me
+ {JSON.stringify(me, null, 2)}
+
+ {context && (
+
+ /api/me/context
+ {JSON.stringify(context, null, 2)}
+
+ )}
+ >
+ )}
)
}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 2a8997c..065425b 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -11,7 +11,16 @@ body {
.shell {
max-width: 720px;
margin: 2rem auto;
- padding: 0 1rem;
+ padding: 0 1rem 3rem;
+}
+
+.hero h1 {
+ margin-bottom: 0.25rem;
+}
+
+.muted {
+ color: #555;
+ font-size: 0.95rem;
}
.card {
@@ -19,6 +28,12 @@ body {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
+ margin-top: 1rem;
+}
+
+.card h2 {
+ margin-top: 0;
+ font-size: 1.1rem;
}
.error {
@@ -27,4 +42,49 @@ body {
pre {
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;
}
From 101376540aec3b6963eeef8c262ce794577deb1a Mon Sep 17 00:00:00 2001
From: Lars
Date: Sat, 4 Jul 2026 20:19:02 +0200
Subject: [PATCH 4/8] Erstregistrierung als Systemadmin plus
Login/Register-Dialog im Frontend.
Co-authored-by: Cursor
---
README.md | 2 +
backend/bootstrap.py | 76 ++-------
backend/routers/auth.py | 31 +++-
backend/services/registration.py | 152 ++++++++++++++++++
backend/tests/test_registration.py | 69 ++++++++
frontend/src/App.jsx | 247 +++++++++++++++++++++++------
frontend/src/app.css | 37 +++++
7 files changed, 502 insertions(+), 112 deletions(-)
create mode 100644 backend/services/registration.py
create mode 100644 backend/tests/test_registration.py
diff --git a/README.md b/README.md
index 0bfc5e0..1fe1550 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,8 @@ KAIRO_BOOTSTRAP_TENANT_SLUG=default
| 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 |
diff --git a/backend/bootstrap.py b/backend/bootstrap.py
index 270fbc9..d8d1c9e 100644
--- a/backend/bootstrap.py
+++ b/backend/bootstrap.py
@@ -1,15 +1,10 @@
-"""Bootstrap first admin user, tenant, membership and human actor from environment."""
+"""Bootstrap first admin user from environment (optional alternative to UI registration)."""
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
+from services.registration import provision_system_admin, user_count
def _env(name: str) -> str:
@@ -25,64 +20,19 @@ def bootstrap_admin_if_needed() -> None:
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"
- 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,
+ provision_system_admin(
+ email=email,
+ password=password,
+ display_name=display_name,
+ tenant_slug=tenant_slug,
+ tenant_name=tenant_name,
+ source="env_bootstrap",
)
- 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")
+ print(f"[bootstrap] Systemadmin und Tenant '{tenant_slug}' angelegt")
diff --git a/backend/routers/auth.py b/backend/routers/auth.py
index da2eacc..920c8f1 100644
--- a/backend/routers/auth.py
+++ b/backend/routers/auth.py
@@ -1,27 +1,50 @@
-"""Auth endpoints: login, logout."""
+"""Auth endpoints: login, logout, registration."""
from __future__ import annotations
from auth import login, logout, require_auth
-from fastapi import APIRouter, Depends, Response
+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):
- # str statt EmailStr: Self-Hosted/Dev nutzt oft .local-Domains (admin@kairo.local)
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(response: Response, session: dict = Depends(require_auth)):
+def auth_logout(session: dict = Depends(require_auth)):
token = session.get("token")
if token:
logout(token)
diff --git a/backend/services/registration.py b/backend/services/registration.py
new file mode 100644
index 0000000..0363dae
--- /dev/null
+++ b/backend/services/registration.py
@@ -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",
+ )
diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py
new file mode 100644
index 0000000..cebd176
--- /dev/null
+++ b/backend/tests/test_registration.py
@@ -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
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 13dd341..ae4f62a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -8,12 +8,26 @@ function apiHeaders(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() {
const [health, setHealth] = useState(null)
const [healthError, setHealthError] = useState(null)
+ const [setup, setSetup] = 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)
@@ -27,6 +41,14 @@ export default function App() {
})
.then(setHealth)
.catch((err) => setHealthError(err.message))
+
+ fetch('/api/auth/setup-status')
+ .then((res) => res.json())
+ .then((data) => {
+ setSetup(data)
+ if (data.registration_open) setAuthMode('register')
+ })
+ .catch(() => setSetup({ registration_open: false, has_users: true }))
}, [])
const loadSession = useCallback(async (activeToken) => {
@@ -53,6 +75,15 @@ export default function App() {
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)
@@ -63,14 +94,38 @@ export default function App() {
headers: apiHeaders(),
body: JSON.stringify({ email, password }),
})
- if (!res.ok) {
- const body = await res.json().catch(() => ({}))
- throw new Error(body.detail || `Login fehlgeschlagen (${res.status})`)
- }
- const data = await res.json()
- localStorage.setItem(TOKEN_KEY, data.token)
- setToken(data.token)
- setPassword('')
+ 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 {
@@ -89,7 +144,7 @@ export default function App() {
})
}
} catch {
- /* Session trotzdem lokal entfernen */
+ /* lokal entfernen */
} finally {
localStorage.removeItem(TOKEN_KEY)
setToken('')
@@ -99,6 +154,8 @@ export default function App() {
}
}
+ const registrationOpen = setup?.registration_open === true
+
return (
-
- API Health
- {healthError && Fehler: {healthError}
}
- {!healthError && !health && Lade …
}
- {health && {JSON.stringify(health, null, 2)}}
-
+
+ {token ? 'Session' : registrationOpen ? 'Ersteinrichtung' : 'Anmeldung'}
+
+ {!token && registrationOpen && (
+
+ Noch kein Systemadministrator. Der erste Account wird automatisch{' '}
+ Portal-Admin und erhält einen Default-Tenant.
+
+ )}
-
- Session
{!token && (
-
+ <>
+
+
+
+
+
+ {authMode === 'login' && (
+
+ )}
+
+ {authMode === 'register' && registrationOpen && (
+
+ )}
+
+ {authMode === 'register' && !registrationOpen && (
+
+ Registrierung ist geschlossen. Bitte melden Sie sich an oder wenden Sie sich an
+ den Administrator.
+
+ )}
+ >
)}
{token && me && (
@@ -151,8 +301,8 @@ export default function App() {
Eingeloggt als {me.display_name} ({me.email})
- Portal-Rolle: {me.portal_role}
- {me.active_tenant_id && ` · Tenant: ${me.active_tenant_id}`}
+ {me.portal_role === 'admin' ? 'Portal-Systemadmin' : `Portal-Rolle: ${me.portal_role}`}
+ {context?.tenant?.name && ` · ${context.tenant.name}`}