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