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 (
@@ -107,42 +164,135 @@ export default function App() {

Auth, Tenant & Actor Foundation

-
-

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}`}

+
+

API Health

+ {healthError &&

Fehler: {healthError}

} + {!healthError && !health &&

Lade …

} + {health &&
{JSON.stringify(health, null, 2)}
} +
+ {token && me && ( <>
diff --git a/frontend/src/app.css b/frontend/src/app.css index 065425b..f8895e8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -36,6 +36,43 @@ body { 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; +} + +.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 { color: #b00020; } From 2f631f419a215088e9d1aabd612ba9eaca140cf9 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 20:22:36 +0200 Subject: [PATCH 5/8] Frontend: Setup-Status-Fehler sichtbar machen, wenn Registrieren deaktiviert ist. Co-authored-by: Cursor --- frontend/src/App.jsx | 66 ++++++++++++++++++++++++++++++++++++++------ frontend/src/app.css | 5 ++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ae4f62a..5d004e4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -21,6 +21,8 @@ export default function App() { const [health, setHealth] = 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('') @@ -42,13 +44,33 @@ 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 })) + 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) => { @@ -155,6 +177,7 @@ export default function App() { } const registrationOpen = setup?.registration_open === true + const registrationClosedBecauseUsersExist = setup?.has_users === true && !registrationOpen return (
@@ -174,7 +197,24 @@ export default function App() {

)} - {!token && ( + {!token && setupLoading && ( +

Prüfe Ersteinrichtung …

+ )} + + {!token && setupError && ( +

{setupError}

+ )} + + {!token && registrationClosedBecauseUsersExist && ( +

+ Es existiert bereits mindestens ein Benutzer ({setup.user_count ?? '≥1'}). + Registrierung ist nur für die Ersteinrichtung vorgesehen — bitte anmelden. + Falls der Admin per KAIRO_BOOTSTRAP_* in der .env angelegt + wurde, nutzen Sie diese Zugangsdaten. +

+ )} + + {!token && !setupLoading && !setupError && ( <>
+
+

Ersteinrichtung

+ {setupLoading &&

Lade …

} + {setupError &&

{setupError}

} + {setup &&
{JSON.stringify(setup, null, 2)}
} + {!setupLoading && !setup && !setupError && ( +

Kein Setup-Status verfügbar.

+ )} +
+

API Health

{healthError &&

Fehler: {healthError}

} diff --git a/frontend/src/app.css b/frontend/src/app.css index f8895e8..5115f0f 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -49,6 +49,11 @@ body { margin: 0 0 1rem; } +.setup-hint-muted { + background: #f3f3f3; + border-color: #ddd; +} + .tabs { display: flex; gap: 0.35rem; From 7a6eb3abe85fc984a37ed6365b8fb530b2727e86 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 20:28:07 +0200 Subject: [PATCH 6/8] Idempotentes Data-Seed-System neben Schema-Migrationen einfuehren. Co-authored-by: Cursor --- .gitea/workflows/test.yml | 3 +- README.md | 7 +- backend/main.py | 10 +- .../migrations/003_data_seeds_tracking.sql | 8 + backend/run_seeds.py | 273 ++++++++++++++++++ .../seed_001_cleanup_pytest_artifacts.dev.sql | 32 ++ backend/seeds/seed_002_bootstrap_admin.py | 9 + backend/tests/conftest.py | 11 + backend/tests/test_migrations.py | 2 + backend/tests/test_seeds.py | 37 +++ backend/version.py | 2 +- docs/MIGRATIONS.md | 51 ++++ 12 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/003_data_seeds_tracking.sql create mode 100644 backend/run_seeds.py create mode 100644 backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql create mode 100644 backend/seeds/seed_002_bootstrap_admin.py create mode 100644 backend/tests/test_seeds.py create mode 100644 docs/MIGRATIONS.md diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index c0a2997..a27a40c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -62,7 +62,8 @@ jobs: docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc " 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" diff --git a/README.md b/README.md index 1fe1550..109dc59 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,10 @@ Interaktive API-Doku (Dev): http://localhost:8097/api/docs ### Auth (AP0.2) -Bootstrap (nur wenn noch kein User existiert — `.env` setzen): +**Ersteinrichtung** (nur wenn noch kein User existiert): + +- UI-Registrierung unter http://localhost:3097 (Tab „Registrieren“), oder +- Bootstrap per `.env` (wird als Data-Seed beim Start ausgeführt): ```env KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local @@ -111,6 +114,8 @@ 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 | diff --git a/backend/main.py b/backend/main.py index 81e8403..3bac90e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -21,9 +21,15 @@ else: print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})") sys.exit(exit_code) - import bootstrap +if os.getenv("SKIP_SEEDS", "").strip().lower() not in ("1", "true", "yes"): + import run_seeds - bootstrap.bootstrap_admin_if_needed() + 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 = [ origin.strip() diff --git a/backend/migrations/003_data_seeds_tracking.sql b/backend/migrations/003_data_seeds_tracking.sql new file mode 100644 index 0000000..51a041f --- /dev/null +++ b/backend/migrations/003_data_seeds_tracking.sql @@ -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() +); diff --git a/backend/run_seeds.py b/backend/run_seeds.py new file mode 100644 index 0000000..767c77a --- /dev/null +++ b/backend/run_seeds.py @@ -0,0 +1,273 @@ +#!/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) + if force 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()) diff --git a/backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql b/backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql new file mode 100644 index 0000000..25d422c --- /dev/null +++ b/backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql @@ -0,0 +1,32 @@ +-- Dev/CI: pytest-Artefakte entfernen (idempotent, sicher mehrfach ausführbar). +-- Nur in Nicht-Prod-Umgebungen (Dateiname *.dev.sql). + +-- 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 + ); diff --git a/backend/seeds/seed_002_bootstrap_admin.py b/backend/seeds/seed_002_bootstrap_admin.py new file mode 100644 index 0000000..9f62c09 --- /dev/null +++ b/backend/seeds/seed_002_bootstrap_admin.py @@ -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() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f62facc..e765d80 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,6 +7,7 @@ import os import pytest os.environ.setdefault("SKIP_DB_MIGRATE", "1") +os.environ.setdefault("SKIP_SEEDS", "1") os.environ.setdefault("SKIP_BOOTSTRAP", "1") @@ -37,6 +38,16 @@ def _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 diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 7e9b5f0..9b011c0 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -33,6 +33,7 @@ def test_migration_runner_finds_migrations(): names = [name for name, _ in files] 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(): @@ -44,6 +45,7 @@ def test_migration_runner_is_idempotent(): conn.close() 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(): diff --git a/backend/tests/test_seeds.py b/backend/tests/test_seeds.py new file mode 100644 index 0000000..cb46f93 --- /dev/null +++ b/backend/tests/test_seeds.py @@ -0,0 +1,37 @@ +"""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 diff --git a/backend/version.py b/backend/version.py index 0220179..721587c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ APP_VERSION = "0.2.0-ap0.2" -DB_SCHEMA_VERSION = "002" +DB_SCHEMA_VERSION = "003" APP_NAME = "jinkendo-kairo" diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md new file mode 100644 index 0000000..2bcbf72 --- /dev/null +++ b/docs/MIGRATIONS.md @@ -0,0 +1,51 @@ +# 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 | + +## 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`). From 598677d3438ed03dea05ff2c5881c5f5d3b0b87e Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 20:38:49 +0200 Subject: [PATCH 7/8] Deploy-Fix: Migrationen/Seeds im Entrypoint, laengere Healthcheck-Phase, Logs bei Fehler. Co-authored-by: Cursor --- .gitea/workflows/deploy-dev.yml | 9 +++-- backend/Dockerfile | 2 ++ backend/entrypoint.sh | 20 +++++++++++ backend/main.py | 33 ++++++++++--------- .../seed_001_cleanup_pytest_artifacts.dev.sql | 3 ++ docker-compose.dev-env.yml | 4 +-- 6 files changed, 51 insertions(+), 20 deletions(-) create mode 100644 backend/entrypoint.sh diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index 770015c..99d4910 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -25,10 +25,15 @@ jobs: fi docker compose -f docker-compose.dev-env.yml build --no-cache backend frontend 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 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 fi echo "✓ DEV API /api/health OK" diff --git a/backend/Dockerfile b/backend/Dockerfile index 1b87504..9039ee0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,7 +10,9 @@ ENV PIP_DEFAULT_TIMEOUT=120 RUN pip install --no-cache-dir -r requirements.txt COPY . . +RUN chmod +x /app/entrypoint.sh EXPOSE 8000 +ENTRYPOINT ["/app/entrypoint.sh"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..b1506e3 --- /dev/null +++ b/backend/entrypoint.sh @@ -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 "$@" diff --git a/backend/main.py b/backend/main.py index 3bac90e..2bd264f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,25 +11,26 @@ from fastapi.middleware.cors import CORSMiddleware from db import check_db from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION -if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): - print("[SKIP_DB_MIGRATE] Migrationen übersprungen") -else: - import run_migrations +if os.getenv("KAIRO_DB_READY") != "1": + if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"): + import run_migrations - exit_code = run_migrations.main() - if exit_code != 0: - print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})") - sys.exit(exit_code) + exit_code = run_migrations.main() + if exit_code != 0: + print(f"[FAIL] Migrationen fehlgeschlagen (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 + 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") + 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 = [ origin.strip() diff --git a/backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql b/backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql index 25d422c..c52b7fa 100644 --- a/backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql +++ b/backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql @@ -1,6 +1,9 @@ -- 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$'); diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index 160c621..b564283 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -54,8 +54,8 @@ services: ] interval: 10s timeout: 5s - retries: 12 - start_period: 30s + retries: 18 + start_period: 90s restart: unless-stopped networks: - dev-kairo-network From 88792eeb47ccc058341fe0d743799aedd85c0b84 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 4 Jul 2026 22:25:11 +0200 Subject: [PATCH 8/8] Dev-Seed: lars@stommer.com als Portal-Admin bei jedem Start sicherstellen. Co-authored-by: Cursor --- README.md | 10 +- backend/run_seeds.py | 4 +- .../seeds/seed_003_ensure_dev_admin.dev.py | 9 + backend/services/dev_admin.py | 187 ++++++++++++++++++ backend/tests/test_seeds.py | 17 ++ docs/MIGRATIONS.md | 3 + 6 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 backend/seeds/seed_003_ensure_dev_admin.dev.py create mode 100644 backend/services/dev_admin.py diff --git a/README.md b/README.md index 109dc59..6f9cd06 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,14 @@ Interaktive API-Doku (Dev): http://localhost:8097/api/docs ### Auth (AP0.2) -**Ersteinrichtung** (nur wenn noch kein User existiert): +**Ersteinrichtung Dev** (automatisch per Seed nach jedem Backend-Start): -- UI-Registrierung unter http://localhost:3097 (Tab „Registrieren“), oder -- Bootstrap per `.env` (wird als Data-Seed beim Start ausgeführt): +| 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 diff --git a/backend/run_seeds.py b/backend/run_seeds.py index 767c77a..04ae38f 100644 --- a/backend/run_seeds.py +++ b/backend/run_seeds.py @@ -208,7 +208,9 @@ def pending_seeds( if only and seed_name not in only: continue checksum = file_checksum(filepath) - if force or applied_seed_checksum(conn, seed_name) != checksum: + 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 diff --git a/backend/seeds/seed_003_ensure_dev_admin.dev.py b/backend/seeds/seed_003_ensure_dev_admin.dev.py new file mode 100644 index 0000000..03e8dbe --- /dev/null +++ b/backend/seeds/seed_003_ensure_dev_admin.dev.py @@ -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() diff --git a/backend/services/dev_admin.py b/backend/services/dev_admin.py new file mode 100644 index 0000000..a26e16c --- /dev/null +++ b/backend/services/dev_admin.py @@ -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"]}, + } diff --git a/backend/tests/test_seeds.py b/backend/tests/test_seeds.py index cb46f93..5bd98c1 100644 --- a/backend/tests/test_seeds.py +++ b/backend/tests/test_seeds.py @@ -35,3 +35,20 @@ def test_cleanup_seed_removes_example_com_users(): 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"]) diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index 2bcbf72..414c588 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -37,6 +37,9 @@ docker compose -f docker-compose.dev-env.yml exec backend python run_seeds.py -- |------|-----|--------| | `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