master user anlegen #2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
45
README.md
45
README.md
|
|
@ -99,6 +99,49 @@ docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests
|
|||
|
||||
UI: http://localhost:3097 · API: http://localhost:8097
|
||||
|
||||
Interaktive API-Doku (Dev): http://localhost:8097/api/docs
|
||||
|
||||
### Auth (AP0.2)
|
||||
|
||||
**Ersteinrichtung Dev** (automatisch per Seed nach jedem Backend-Start):
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| E-Mail | `lars@stommer.com` |
|
||||
| Passwort | `12345678` |
|
||||
|
||||
Alternativ: UI-Registrierung (nur wenn DB leer und Dev-Seed deaktiviert) oder Bootstrap per `.env`:
|
||||
|
||||
```env
|
||||
KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
|
||||
KAIRO_BOOTSTRAP_ADMIN_PASSWORD=…
|
||||
KAIRO_BOOTSTRAP_TENANT_SLUG=default
|
||||
```
|
||||
|
||||
Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md)
|
||||
|
||||
| Endpoint | Methode | Auth | Beschreibung |
|
||||
|----------|---------|------|--------------|
|
||||
| `/api/auth/setup-status` | GET | — | Ob Erstregistrierung offen ist |
|
||||
| `/api/auth/register` | POST | — | Erster User → Portal-Admin + Tenant (nur wenn noch kein User) |
|
||||
| `/api/auth/login` | POST | — | E-Mail + Passwort → Session-Token |
|
||||
| `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen |
|
||||
| `/api/me` | GET | `X-Auth-Token` | Aktueller User + Tenant-Liste |
|
||||
| `/api/me/context` | GET | `X-Auth-Token` | TenantContext (Tenant + Human Actor) |
|
||||
| `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) |
|
||||
|
||||
```bash
|
||||
# Login
|
||||
curl -s -X POST http://localhost:8097/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@kairo.local","password":"…"}'
|
||||
|
||||
# Geschützter Endpoint
|
||||
curl -s http://localhost:8097/api/me -H "X-Auth-Token: TOKEN"
|
||||
```
|
||||
|
||||
Regeln: `user_id` kommt aus der Session, nicht aus Client-Headern. Portalrolle (`portal_role`) und Tenantrolle (`tenant_role`) sind getrennt.
|
||||
|
||||
## AP0.1 – Stand Projektgrundlage
|
||||
|
||||
| Bereich | Status |
|
||||
|
|
@ -107,5 +150,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 |
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
243
backend/auth.py
Normal file
243
backend/auth.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""Authentication: bcrypt passwords, server-side sessions, FastAPI dependencies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
|
||||
AUTH_HEADER = "X-Auth-Token"
|
||||
SESSION_DAYS = int(os.getenv("SESSION_DAYS", "30"))
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, stored_hash: str) -> bool:
|
||||
if not stored_hash:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8"))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def make_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _session_expiry() -> datetime:
|
||||
return datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
|
||||
|
||||
|
||||
def get_user_by_email(email: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, email, password_hash, display_name, portal_role, is_active
|
||||
FROM users
|
||||
WHERE LOWER(email) = LOWER(%s)
|
||||
""",
|
||||
(email.strip(),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_by_id(user_id: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, email, display_name, portal_role, is_active, created_at
|
||||
FROM users
|
||||
WHERE id = %s
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_session(token: str) -> Optional[dict[str, Any]]:
|
||||
if not token:
|
||||
return None
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
s.id AS session_id,
|
||||
s.token,
|
||||
s.user_id,
|
||||
s.active_tenant_id,
|
||||
s.expires_at,
|
||||
u.email,
|
||||
u.display_name,
|
||||
u.portal_role,
|
||||
u.is_active AS user_is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = %s AND s.expires_at > NOW()
|
||||
""",
|
||||
(token,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
session = dict(row)
|
||||
if not session.get("user_is_active"):
|
||||
return None
|
||||
return session
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _default_active_tenant(user_id: str) -> Optional[str]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tm.tenant_id
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = %s
|
||||
AND tm.is_active = TRUE
|
||||
AND t.is_active = TRUE
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return str(row[0]) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_session(user_id: str, active_tenant_id: Optional[str] = None) -> dict[str, Any]:
|
||||
token = make_token()
|
||||
tenant_id = active_tenant_id or _default_active_tenant(user_id)
|
||||
expires_at = _session_expiry()
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sessions (token, user_id, active_tenant_id, expires_at)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING token, expires_at, active_tenant_id
|
||||
""",
|
||||
(token, user_id, tenant_id, expires_at),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
conn.commit()
|
||||
row["user_id"] = user_id
|
||||
return row
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_session(token: str) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM sessions WHERE token = %s", (token,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_session_active_tenant(token: str, tenant_id: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE sessions s
|
||||
SET active_tenant_id = %s
|
||||
FROM users u
|
||||
WHERE s.token = %s
|
||||
AND s.user_id = u.id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = s.user_id
|
||||
AND tm.tenant_id = %s
|
||||
AND tm.is_active = TRUE
|
||||
AND t.is_active = TRUE
|
||||
)
|
||||
""",
|
||||
(tenant_id, token, tenant_id),
|
||||
)
|
||||
updated = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return updated
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def login(email: str, password: str) -> dict[str, Any]:
|
||||
user = get_user_by_email(email)
|
||||
if not user or not user.get("is_active"):
|
||||
log_audit("auth.login_failed", details={"email": email.strip().lower()})
|
||||
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
|
||||
|
||||
if not verify_password(password, user["password_hash"]):
|
||||
log_audit("auth.login_failed", user_id=str(user["id"]), details={"email": user["email"]})
|
||||
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
|
||||
|
||||
session = create_session(str(user["id"]))
|
||||
log_audit("auth.login", user_id=str(user["id"]), details={"email": user["email"]})
|
||||
return {
|
||||
"token": session["token"],
|
||||
"expires_at": session["expires_at"].isoformat(),
|
||||
"user": {
|
||||
"id": str(user["id"]),
|
||||
"email": user["email"],
|
||||
"display_name": user["display_name"],
|
||||
"portal_role": user["portal_role"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def logout(token: str) -> None:
|
||||
session = get_session(token)
|
||||
if session:
|
||||
log_audit(
|
||||
"auth.logout",
|
||||
user_id=str(session["user_id"]),
|
||||
tenant_id=str(session["active_tenant_id"]) if session.get("active_tenant_id") else None,
|
||||
)
|
||||
delete_session(token)
|
||||
|
||||
|
||||
def require_auth(x_auth_token: Optional[str] = Header(default=None, alias=AUTH_HEADER)) -> dict[str, Any]:
|
||||
session = get_session(x_auth_token or "")
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Nicht eingeloggt")
|
||||
return session
|
||||
|
||||
|
||||
def require_portal_admin(session: dict[str, Any] = Depends(require_auth)) -> dict[str, Any]:
|
||||
if session.get("portal_role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Nur für Portal-Admins")
|
||||
return session
|
||||
38
backend/bootstrap.py
Normal file
38
backend/bootstrap.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""Bootstrap first admin user from environment (optional alternative to UI registration)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from services.registration import provision_system_admin, user_count
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
return os.getenv(name, "").strip()
|
||||
|
||||
|
||||
def bootstrap_admin_if_needed() -> None:
|
||||
if _env("SKIP_BOOTSTRAP").lower() in ("1", "true", "yes"):
|
||||
return
|
||||
|
||||
email = _env("KAIRO_BOOTSTRAP_ADMIN_EMAIL")
|
||||
password = _env("KAIRO_BOOTSTRAP_ADMIN_PASSWORD")
|
||||
if not email or not password:
|
||||
return
|
||||
|
||||
if user_count() > 0:
|
||||
return
|
||||
|
||||
tenant_slug = _env("KAIRO_BOOTSTRAP_TENANT_SLUG") or "default"
|
||||
tenant_name = _env("KAIRO_BOOTSTRAP_TENANT_NAME") or "Default Tenant"
|
||||
display_name = _env("KAIRO_BOOTSTRAP_ADMIN_NAME") or "Kairo Admin"
|
||||
|
||||
provision_system_admin(
|
||||
email=email,
|
||||
password=password,
|
||||
display_name=display_name,
|
||||
tenant_slug=tenant_slug,
|
||||
tenant_name=tenant_name,
|
||||
source="env_bootstrap",
|
||||
)
|
||||
print(f"[bootstrap] Systemadmin und Tenant '{tenant_slug}' angelegt")
|
||||
20
backend/entrypoint.sh
Normal file
20
backend/entrypoint.sh
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "=== Kairo Backend Startup ==="
|
||||
|
||||
if [ "${SKIP_DB_MIGRATE}" != "1" ] && [ "${SKIP_DB_MIGRATE}" != "true" ] && [ "${SKIP_DB_MIGRATE}" != "yes" ]; then
|
||||
python run_migrations.py
|
||||
else
|
||||
echo "[SKIP_DB_MIGRATE] Migrationen übersprungen"
|
||||
fi
|
||||
|
||||
if [ "${SKIP_SEEDS}" != "1" ] && [ "${SKIP_SEEDS}" != "true" ] && [ "${SKIP_SEEDS}" != "yes" ]; then
|
||||
python run_seeds.py
|
||||
else
|
||||
echo "[SKIP_SEEDS] Data-Seeds übersprungen"
|
||||
fi
|
||||
|
||||
export KAIRO_DB_READY=1
|
||||
echo "=== Starte Anwendung: $* ==="
|
||||
exec "$@"
|
||||
|
|
@ -11,15 +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
|
||||
|
||||
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()
|
||||
|
|
@ -42,6 +53,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():
|
||||
|
|
|
|||
81
backend/migrations/002_auth_identity_tenant_actor.sql
Normal file
81
backend/migrations/002_auth_identity_tenant_actor.sql
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
-- AP0.2: Auth, Identity, Tenant & Actor Foundation
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
portal_role VARCHAR(32) NOT NULL DEFAULT 'user'
|
||||
CHECK (portal_role IN ('user', 'admin')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug VARCHAR(64) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE tenant_memberships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_role VARCHAR(32) NOT NULL DEFAULT 'member'
|
||||
CHECK (tenant_role IN ('owner', 'admin', 'member')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenant_memberships_user ON tenant_memberships(user_id);
|
||||
|
||||
CREATE TABLE actors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
actor_type VARCHAR(32) NOT NULL
|
||||
CHECK (actor_type IN ('human', 'agent', 'working_group', 'external_system')),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK (
|
||||
(actor_type = 'human' AND user_id IS NOT NULL)
|
||||
OR (actor_type <> 'human' AND user_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_actors_human_user_tenant
|
||||
ON actors(tenant_id, user_id)
|
||||
WHERE actor_type = 'human';
|
||||
|
||||
CREATE INDEX idx_actors_tenant ON actors(tenant_id);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token VARCHAR(128) NOT NULL UNIQUE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_tenant_id UUID NULL REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
action VARCHAR(64) NOT NULL,
|
||||
user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
tenant_id UUID NULL REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
details JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
|
||||
8
backend/migrations/003_data_seeds_tracking.sql
Normal file
8
backend/migrations/003_data_seeds_tracking.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- Tracking für idempotente Data-Seeds (Checksum-basiert, bei Änderung erneut ausführbar)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_seeds (
|
||||
seed_name VARCHAR(255) PRIMARY KEY,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
51
backend/routers/auth.py
Normal file
51
backend/routers/auth.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Auth endpoints: login, logout, registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from auth import login, logout, require_auth
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from services.registration import register_system_admin, setup_status
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=255)
|
||||
password: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=255)
|
||||
password: str = Field(min_length=8, max_length=256)
|
||||
display_name: str = Field(min_length=1, max_length=255)
|
||||
organization_name: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
@router.get("/setup-status")
|
||||
def auth_setup_status():
|
||||
return setup_status()
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def auth_register(body: RegisterRequest):
|
||||
"""Erster User wird Portal-Systemadmin inkl. Default-Tenant."""
|
||||
return register_system_admin(
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
display_name=body.display_name,
|
||||
organization_name=body.organization_name,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def auth_login(body: LoginRequest):
|
||||
return login(body.email, body.password)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def auth_logout(session: dict = Depends(require_auth)):
|
||||
token = session.get("token")
|
||||
if token:
|
||||
logout(token)
|
||||
return {"ok": True}
|
||||
49
backend/routers/me.py
Normal file
49
backend/routers/me.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Current user and tenant context endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from auth import get_session, require_auth, set_session_active_tenant
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from tenant_context import TenantContext, get_tenant_context, list_user_tenants, require_tenant_context, resolve_tenant_context
|
||||
|
||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||
|
||||
|
||||
class SwitchTenantRequest(BaseModel):
|
||||
tenant_id: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_me(session: dict = Depends(require_auth)):
|
||||
tenants = list_user_tenants(str(session["user_id"]))
|
||||
return {
|
||||
"id": str(session["user_id"]),
|
||||
"email": session["email"],
|
||||
"display_name": session["display_name"],
|
||||
"portal_role": session["portal_role"],
|
||||
"active_tenant_id": str(session["active_tenant_id"]) if session.get("active_tenant_id") else None,
|
||||
"tenants": tenants,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/context")
|
||||
def get_my_context(ctx: TenantContext = Depends(get_tenant_context)):
|
||||
return ctx.to_dict()
|
||||
|
||||
|
||||
@router.get("/context/required")
|
||||
def get_my_context_with_tenant(ctx: TenantContext = Depends(require_tenant_context)):
|
||||
"""Example endpoint requiring an active tenant — for tests and AP0.3 prep."""
|
||||
return ctx.to_dict()
|
||||
|
||||
|
||||
@router.post("/tenant")
|
||||
def switch_tenant(body: SwitchTenantRequest, session: dict = Depends(require_auth)):
|
||||
ok = set_session_active_tenant(session["token"], body.tenant_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=403, detail="Tenant nicht erlaubt")
|
||||
fresh = get_session(session["token"])
|
||||
if not fresh:
|
||||
raise HTTPException(status_code=401, detail="Session ungültig")
|
||||
return resolve_tenant_context(fresh).to_dict()
|
||||
275
backend/run_seeds.py
Normal file
275
backend/run_seeds.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apply idempotent data seeds with checksum-based tracking and re-run on change."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, List, Optional, Tuple
|
||||
|
||||
import psycopg2
|
||||
import sqlparse
|
||||
|
||||
from db import db_params, get_connection
|
||||
|
||||
_SEED_PREFIX = re.compile(r"^seed_(\d+)_(.+)$")
|
||||
_DEV_MARKER = ".dev."
|
||||
|
||||
|
||||
def is_production() -> bool:
|
||||
return os.getenv("ENVIRONMENT", "development").strip().lower() == "production"
|
||||
|
||||
|
||||
def seeds_directory() -> str:
|
||||
docker_path = "/app/seeds"
|
||||
if os.path.isdir(docker_path):
|
||||
return docker_path
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "seeds")
|
||||
|
||||
|
||||
def init_data_seeds_table(conn) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS data_seeds (
|
||||
seed_name VARCHAR(255) PRIMARY KEY,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _seed_sort_key(filename: str) -> Tuple[int, str]:
|
||||
stem = filename
|
||||
for suffix in (".sql", ".py"):
|
||||
if stem.endswith(suffix):
|
||||
stem = stem[: -len(suffix)]
|
||||
break
|
||||
match = _SEED_PREFIX.match(stem)
|
||||
if match:
|
||||
return (int(match.group(1)), stem)
|
||||
return (0, stem)
|
||||
|
||||
|
||||
def _seed_stem(filename: str) -> str:
|
||||
if filename.endswith(".dev.sql"):
|
||||
return filename[: -len(".dev.sql")]
|
||||
if filename.endswith(".sql"):
|
||||
return filename[: -len(".sql")]
|
||||
if filename.endswith(".py"):
|
||||
return filename[: -len(".py")]
|
||||
return filename
|
||||
|
||||
|
||||
def seed_files(seeds_dir: str) -> List[Tuple[str, str, str]]:
|
||||
"""Return (seed_name, filepath, kind) sorted by numeric prefix."""
|
||||
rows: List[Tuple[str, str, str]] = []
|
||||
if not os.path.isdir(seeds_dir):
|
||||
return rows
|
||||
|
||||
for filename in os.listdir(seeds_dir):
|
||||
if filename.startswith("_") or not filename.startswith("seed_"):
|
||||
continue
|
||||
if filename.endswith(".sql"):
|
||||
kind = "sql"
|
||||
elif filename.endswith(".py"):
|
||||
kind = "py"
|
||||
else:
|
||||
continue
|
||||
stem = _seed_stem(filename)
|
||||
rows.append((stem, os.path.join(seeds_dir, filename), kind))
|
||||
|
||||
rows.sort(key=lambda item: _seed_sort_key(item[0]))
|
||||
return rows
|
||||
|
||||
|
||||
def seed_applies_in_environment(filename: str) -> bool:
|
||||
if _DEV_MARKER in filename and is_production():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def file_checksum(filepath: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(filepath, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(65536), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def applied_seed_checksum(conn, seed_name: str) -> Optional[str]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT checksum FROM data_seeds WHERE seed_name = %s", (seed_name,))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def record_seed(conn, seed_name: str, checksum: str) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO data_seeds (seed_name, checksum, executed_at, last_run_at)
|
||||
VALUES (%s, %s, NOW(), NOW())
|
||||
ON CONFLICT (seed_name) DO UPDATE SET
|
||||
checksum = EXCLUDED.checksum,
|
||||
last_run_at = NOW()
|
||||
""",
|
||||
(seed_name, checksum),
|
||||
)
|
||||
|
||||
|
||||
def _split_statements(sql_text: str) -> List[str]:
|
||||
parts = sqlparse.split(sql_text.strip())
|
||||
return [part.strip() for part in parts if part and part.strip()]
|
||||
|
||||
|
||||
def run_sql_seed(conn, filepath: str) -> None:
|
||||
with open(filepath, "r", encoding="utf-8") as handle:
|
||||
body = handle.read()
|
||||
statements = _split_statements(body)
|
||||
with conn.cursor() as cur:
|
||||
for stmt in statements:
|
||||
cur.execute(stmt)
|
||||
|
||||
|
||||
def run_python_seed(filepath: str) -> None:
|
||||
module_name = f"kairo_seed_{hashlib.md5(filepath.encode()).hexdigest()[:12]}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Seed-Modul nicht ladbar: {filepath}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
run_fn: Callable[[], None] | None = getattr(module, "run", None)
|
||||
if run_fn is None:
|
||||
raise RuntimeError(f"Seed {filepath} hat keine run()-Funktion")
|
||||
run_fn()
|
||||
|
||||
|
||||
def connect_with_retry(max_retries: int = 30):
|
||||
p = db_params()
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
conn = get_connection()
|
||||
conn.autocommit = False
|
||||
print(f"[OK] Connected to database: {p['dbname']}")
|
||||
return conn
|
||||
except psycopg2.OperationalError:
|
||||
if attempt >= max_retries - 1:
|
||||
raise
|
||||
print(f"Waiting for database... ({attempt + 1}/{max_retries})")
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
def run_seed(conn, seed_name: str, filepath: str, kind: str) -> tuple[bool, object]:
|
||||
print(f"Running seed: {seed_name}")
|
||||
try:
|
||||
if kind == "sql":
|
||||
run_sql_seed(conn, filepath)
|
||||
else:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
run_python_seed(filepath)
|
||||
conn = connect_with_retry(max_retries=5)
|
||||
|
||||
checksum = file_checksum(filepath)
|
||||
record_seed(conn, seed_name, checksum)
|
||||
conn.commit()
|
||||
print(f" [OK] {seed_name}")
|
||||
return True, conn
|
||||
except Exception as exc:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
print(f" [FAIL] {seed_name}: {exc}")
|
||||
return False, conn
|
||||
|
||||
|
||||
def pending_seeds(
|
||||
conn,
|
||||
seeds_dir: str,
|
||||
*,
|
||||
only: Optional[List[str]] = None,
|
||||
force: bool = False,
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
selected: List[Tuple[str, str, str]] = []
|
||||
for seed_name, filepath, kind in seed_files(seeds_dir):
|
||||
filename = os.path.basename(filepath)
|
||||
if not seed_applies_in_environment(filename):
|
||||
print(f" [SKIP] {seed_name} (nur Nicht-Prod)")
|
||||
continue
|
||||
if only and seed_name not in only:
|
||||
continue
|
||||
checksum = file_checksum(filepath)
|
||||
is_dev_seed = _DEV_MARKER in filename
|
||||
always_run = is_dev_seed and not is_production()
|
||||
if force or always_run or applied_seed_checksum(conn, seed_name) != checksum:
|
||||
selected.append((seed_name, filepath, kind))
|
||||
return selected
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
argv = argv if argv is not None else sys.argv[1:]
|
||||
only: Optional[List[str]] = None
|
||||
force = False
|
||||
|
||||
idx = 0
|
||||
while idx < len(argv):
|
||||
arg = argv[idx]
|
||||
if arg == "--only" and idx + 1 < len(argv):
|
||||
only = [part.strip() for part in argv[idx + 1].split(",") if part.strip()]
|
||||
idx += 2
|
||||
continue
|
||||
if arg == "--force":
|
||||
force = True
|
||||
idx += 1
|
||||
continue
|
||||
print(f"[FAIL] Unbekanntes Argument: {arg}")
|
||||
return 1
|
||||
|
||||
print("=" * 60)
|
||||
print("Jinkendo Kairo — Data Seeds")
|
||||
print("=" * 60)
|
||||
|
||||
seeds_dir = seeds_directory()
|
||||
if not os.path.isdir(seeds_dir):
|
||||
print(f"[OK] Kein seeds-Verzeichnis ({seeds_dir}) — nichts zu tun.")
|
||||
return 0
|
||||
|
||||
try:
|
||||
conn = connect_with_retry()
|
||||
init_data_seeds_table(conn)
|
||||
to_run = pending_seeds(conn, seeds_dir, only=only, force=force)
|
||||
|
||||
if not to_run:
|
||||
print("[OK] Alle Seeds aktuell — nichts auszuführen.")
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
print(f"{len(to_run)} Seed(s) ausstehend:")
|
||||
for seed_name, _, _ in to_run:
|
||||
print(f" - {seed_name}")
|
||||
|
||||
for seed_name, filepath, kind in to_run:
|
||||
ok, conn = run_seed(conn, seed_name, filepath, kind)
|
||||
if not ok:
|
||||
conn.close()
|
||||
return 1
|
||||
|
||||
conn.close()
|
||||
print("[OK] Seeds abgeschlossen.")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[FAIL] {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
35
backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql
Normal file
35
backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
-- Dev/CI: pytest-Artefakte entfernen (idempotent, sicher mehrfach ausführbar).
|
||||
-- Nur in Nicht-Prod-Umgebungen (Dateiname *.dev.sql).
|
||||
|
||||
-- Reparatur: ungültige Human-Actors (user_id NULL verletzt CHECK constraint)
|
||||
DELETE FROM actors WHERE actor_type = 'human' AND user_id IS NULL;
|
||||
|
||||
-- Abhängigkeiten vor User-Löschung (human actors: user_id NOT NULL constraint)
|
||||
DELETE FROM actors
|
||||
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
|
||||
|
||||
DELETE FROM tenant_memberships
|
||||
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
|
||||
|
||||
DELETE FROM sessions
|
||||
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
|
||||
|
||||
DELETE FROM audit_log
|
||||
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
|
||||
|
||||
DELETE FROM users WHERE email ~* '@example\.com$';
|
||||
|
||||
-- Verwaiste pytest-Tenants (Factory-Slug t-{10 hex}), ohne Memberships
|
||||
DELETE FROM actors a
|
||||
USING tenants t
|
||||
WHERE a.tenant_id = t.id
|
||||
AND t.slug ~ '^t-[a-f0-9]{10}$'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tenant_memberships tm WHERE tm.tenant_id = t.id
|
||||
);
|
||||
|
||||
DELETE FROM tenants t
|
||||
WHERE t.slug ~ '^t-[a-f0-9]{10}$'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tenant_memberships tm WHERE tm.tenant_id = t.id
|
||||
);
|
||||
9
backend/seeds/seed_002_bootstrap_admin.py
Normal file
9
backend/seeds/seed_002_bootstrap_admin.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""Bootstrap portal admin from KAIRO_BOOTSTRAP_* when no users exist."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def run() -> None:
|
||||
from bootstrap import bootstrap_admin_if_needed
|
||||
|
||||
bootstrap_admin_if_needed()
|
||||
9
backend/seeds/seed_003_ensure_dev_admin.dev.py
Normal file
9
backend/seeds/seed_003_ensure_dev_admin.dev.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""Dev: Portal-Admin lars@stommer.com sicherstellen (läuft bei jedem Start in Dev)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def run() -> None:
|
||||
from services.dev_admin import ensure_dev_admin
|
||||
|
||||
ensure_dev_admin()
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
68
backend/services/actors.py
Normal file
68
backend/services/actors.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Actor creation helpers — User and Actor remain separate concepts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
|
||||
ActorType = Literal["human", "agent", "working_group", "external_system"]
|
||||
|
||||
|
||||
def create_actor(
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_type: ActorType,
|
||||
name: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
if actor_type == "human" and not user_id:
|
||||
raise ValueError("Human actors require user_id")
|
||||
if actor_type != "human" and user_id:
|
||||
raise ValueError("Non-human actors must not have user_id")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO actors (tenant_id, actor_type, name, user_id)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, tenant_id, actor_type, name, user_id, is_active, created_at
|
||||
""",
|
||||
(tenant_id, actor_type, name, user_id),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
conn.commit()
|
||||
for key in ("id", "tenant_id", "user_id"):
|
||||
if row.get(key):
|
||||
row[key] = str(row[key])
|
||||
return row
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_human_actor(tenant_id: str, user_id: str) -> Optional[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, tenant_id, actor_type, name, user_id, is_active, created_at
|
||||
FROM actors
|
||||
WHERE tenant_id = %s AND user_id = %s AND actor_type = 'human'
|
||||
""",
|
||||
(tenant_id, user_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
result = dict(row)
|
||||
for key in ("id", "tenant_id", "user_id"):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
30
backend/services/audit.py
Normal file
30
backend/services/audit.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Minimal audit logging for critical auth actions (AP0.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from db import get_connection
|
||||
|
||||
|
||||
def log_audit(
|
||||
action: str,
|
||||
*,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
details: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_log (action, user_id, tenant_id, details)
|
||||
VALUES (%s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(action, user_id, tenant_id, json.dumps(details or {})),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
187
backend/services/dev_admin.py
Normal file
187
backend/services/dev_admin.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Dev-only admin seed — ensures a known portal admin exists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
from auth import hash_password
|
||||
from db import get_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from services.actors import create_actor, get_human_actor
|
||||
from services.audit import log_audit
|
||||
from services.registration import provision_system_admin, user_count
|
||||
|
||||
DEV_ADMIN_EMAIL = "lars@stommer.com"
|
||||
DEV_ADMIN_PASSWORD = "12345678"
|
||||
DEV_ADMIN_NAME = "Lars Stommer"
|
||||
DEV_TENANT_SLUG = "stommer"
|
||||
DEV_TENANT_NAME = "Stommer"
|
||||
|
||||
|
||||
def _normalize_emails(emails: Iterable[str]) -> tuple[str, ...]:
|
||||
return tuple(sorted({email.strip().lower() for email in emails if email.strip()}))
|
||||
|
||||
|
||||
def delete_users_except(keep_emails: Iterable[str]) -> int:
|
||||
"""Remove all users not in keep_emails (with FK-safe order). Returns deleted count."""
|
||||
keep = _normalize_emails(keep_emails)
|
||||
if not keep:
|
||||
raise ValueError("keep_emails must not be empty")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM actors WHERE actor_type = 'human' AND user_id IS NULL")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM actors
|
||||
WHERE user_id IN (
|
||||
SELECT id FROM users WHERE lower(email) <> ALL(%s)
|
||||
)
|
||||
""",
|
||||
(list(keep),),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM tenant_memberships
|
||||
WHERE user_id IN (
|
||||
SELECT id FROM users WHERE lower(email) <> ALL(%s)
|
||||
)
|
||||
""",
|
||||
(list(keep),),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM sessions
|
||||
WHERE user_id IN (
|
||||
SELECT id FROM users WHERE lower(email) <> ALL(%s)
|
||||
)
|
||||
""",
|
||||
(list(keep),),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM audit_log
|
||||
WHERE user_id IN (
|
||||
SELECT id FROM users WHERE lower(email) <> ALL(%s)
|
||||
)
|
||||
""",
|
||||
(list(keep),),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM users WHERE lower(email) <> ALL(%s) RETURNING email",
|
||||
(list(keep),),
|
||||
)
|
||||
removed = cur.fetchall()
|
||||
conn.commit()
|
||||
return len(removed)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _ensure_tenant(conn, *, slug: str, name: str) -> dict:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenants (slug, name)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id, slug, name
|
||||
""",
|
||||
(slug, name),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
|
||||
def ensure_dev_admin(
|
||||
*,
|
||||
email: str = DEV_ADMIN_EMAIL,
|
||||
password: str = DEV_ADMIN_PASSWORD,
|
||||
display_name: str = DEV_ADMIN_NAME,
|
||||
tenant_slug: str = DEV_TENANT_SLUG,
|
||||
tenant_name: str = DEV_TENANT_NAME,
|
||||
) -> dict:
|
||||
"""Dev seed: remove other users, ensure portal admin + tenant + human actor."""
|
||||
if os.getenv("ENVIRONMENT", "development").strip().lower() == "production":
|
||||
raise RuntimeError("ensure_dev_admin darf nicht in Production laufen")
|
||||
|
||||
normalized_email = email.strip().lower()
|
||||
removed = delete_users_except([normalized_email])
|
||||
if removed:
|
||||
print(f"[dev_admin] {removed} fremde User entfernt")
|
||||
|
||||
if user_count() == 0:
|
||||
result = provision_system_admin(
|
||||
email=normalized_email,
|
||||
password=password,
|
||||
display_name=display_name,
|
||||
tenant_slug=tenant_slug,
|
||||
tenant_name=tenant_name,
|
||||
source="dev_seed",
|
||||
)
|
||||
print(f"[dev_admin] Portal-Admin angelegt: {normalized_email}")
|
||||
return result
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
tenant = _ensure_tenant(conn, slug=tenant_slug, name=tenant_name)
|
||||
tenant_id = str(tenant["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET password_hash = %s,
|
||||
display_name = %s,
|
||||
portal_role = 'admin',
|
||||
updated_at = NOW()
|
||||
WHERE lower(email) = %s
|
||||
RETURNING id, email, display_name, portal_role
|
||||
""",
|
||||
(hash_password(password), display_name.strip(), normalized_email),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError(f"Dev-Admin {normalized_email} fehlt nach Bereinigung")
|
||||
user = dict(row)
|
||||
user_id = str(user["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
|
||||
VALUES (%s, %s, 'owner')
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET tenant_role = 'owner'
|
||||
""",
|
||||
(tenant_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not get_human_actor(tenant_id, user_id):
|
||||
create_actor(
|
||||
tenant_id=tenant_id,
|
||||
actor_type="human",
|
||||
name=display_name.strip(),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
log_audit(
|
||||
"auth.dev_admin_ensure",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"email": normalized_email, "removed_users": removed},
|
||||
)
|
||||
print(f"[dev_admin] Portal-Admin bestätigt: {normalized_email}")
|
||||
return {
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"email": user["email"],
|
||||
"display_name": user["display_name"],
|
||||
"portal_role": user["portal_role"],
|
||||
},
|
||||
"tenant": {"id": tenant_id, "slug": tenant["slug"], "name": tenant["name"]},
|
||||
}
|
||||
152
backend/services/registration.py
Normal file
152
backend/services/registration.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""First-user / system-admin provisioning (bootstrap + registration)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from auth import create_session, get_user_by_email, hash_password
|
||||
from db import get_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from fastapi import HTTPException
|
||||
from services.actors import create_actor
|
||||
from services.audit import log_audit
|
||||
|
||||
|
||||
def user_count() -> int:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) FROM users")
|
||||
return int(cur.fetchone()[0])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def registration_open() -> bool:
|
||||
return user_count() == 0
|
||||
|
||||
|
||||
def setup_status() -> dict[str, Any]:
|
||||
count = user_count()
|
||||
return {
|
||||
"has_users": count > 0,
|
||||
"registration_open": count == 0,
|
||||
"user_count": count,
|
||||
}
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return (slug[:48] or "default")
|
||||
|
||||
|
||||
def provision_system_admin(
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
tenant_slug: str | None = None,
|
||||
tenant_name: str | None = None,
|
||||
source: str = "bootstrap",
|
||||
) -> dict[str, Any]:
|
||||
"""Create portal admin, default tenant, owner membership and human actor."""
|
||||
if user_count() > 0:
|
||||
raise ValueError("users_already_exist")
|
||||
|
||||
normalized_email = email.strip().lower()
|
||||
slug = tenant_slug or os.getenv("KAIRO_BOOTSTRAP_TENANT_SLUG") or "default"
|
||||
name = tenant_name or os.getenv("KAIRO_BOOTSTRAP_TENANT_NAME") or "Default Tenant"
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenants (slug, name)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id, slug, name
|
||||
""",
|
||||
(slug, name),
|
||||
)
|
||||
tenant = dict(cur.fetchone())
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (email, password_hash, display_name, portal_role)
|
||||
VALUES (%s, %s, %s, 'admin')
|
||||
RETURNING id, email, display_name, portal_role
|
||||
""",
|
||||
(normalized_email, hash_password(password), display_name.strip()),
|
||||
)
|
||||
user = dict(cur.fetchone())
|
||||
user_id = str(user["id"])
|
||||
tenant_id = str(tenant["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
|
||||
VALUES (%s, %s, 'owner')
|
||||
""",
|
||||
(tenant_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
create_actor(
|
||||
tenant_id=tenant_id,
|
||||
actor_type="human",
|
||||
name=display_name.strip(),
|
||||
user_id=user_id,
|
||||
)
|
||||
log_audit(
|
||||
"auth.register_system_admin",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"email": normalized_email, "source": source, "tenant_slug": slug},
|
||||
)
|
||||
|
||||
session = create_session(user_id, tenant_id)
|
||||
return {
|
||||
"token": session["token"],
|
||||
"expires_at": session["expires_at"].isoformat(),
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"email": user["email"],
|
||||
"display_name": user["display_name"],
|
||||
"portal_role": user["portal_role"],
|
||||
},
|
||||
"tenant": {"id": tenant_id, "slug": tenant["slug"], "name": tenant["name"]},
|
||||
}
|
||||
|
||||
|
||||
def register_system_admin(
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
organization_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if get_user_by_email(email):
|
||||
raise HTTPException(status_code=409, detail="E-Mail bereits registriert")
|
||||
|
||||
if not registration_open():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Registrierung geschlossen — Systemadministrator ist bereits eingerichtet",
|
||||
)
|
||||
|
||||
org = (organization_name or display_name or "Default Tenant").strip()
|
||||
slug = _slugify(org)
|
||||
|
||||
return provision_system_admin(
|
||||
email=email,
|
||||
password=password,
|
||||
display_name=display_name.strip(),
|
||||
tenant_slug=slug,
|
||||
tenant_name=org,
|
||||
source="registration",
|
||||
)
|
||||
151
backend/tenant_context.py
Normal file
151
backend/tenant_context.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""TenantContext resolution — single layer, not scattered header logic in routers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from auth import require_auth
|
||||
from db import get_connection
|
||||
from services.actors import get_human_actor
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantContext:
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
portal_role: str
|
||||
tenant_id: Optional[str]
|
||||
tenant_slug: Optional[str]
|
||||
tenant_name: Optional[str]
|
||||
tenant_role: Optional[str]
|
||||
actor_id: Optional[str]
|
||||
actor_type: Optional[str]
|
||||
session_token: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"email": self.email,
|
||||
"display_name": self.display_name,
|
||||
"portal_role": self.portal_role,
|
||||
"tenant": (
|
||||
{
|
||||
"id": self.tenant_id,
|
||||
"slug": self.tenant_slug,
|
||||
"name": self.tenant_name,
|
||||
"role": self.tenant_role,
|
||||
}
|
||||
if self.tenant_id
|
||||
else None
|
||||
),
|
||||
"actor": (
|
||||
{
|
||||
"id": self.actor_id,
|
||||
"type": self.actor_type,
|
||||
}
|
||||
if self.actor_id
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def list_user_tenants(user_id: str) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.slug,
|
||||
t.name,
|
||||
tm.tenant_role,
|
||||
tm.is_active AS membership_active
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = %s AND tm.is_active = TRUE AND t.is_active = TRUE
|
||||
ORDER BY t.name
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = []
|
||||
for row in cur.fetchall():
|
||||
item = dict(row)
|
||||
item["id"] = str(item["id"])
|
||||
rows.append(item)
|
||||
return rows
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def resolve_tenant_context(session: dict[str, Any]) -> TenantContext:
|
||||
user_id = str(session["user_id"])
|
||||
tenant_id = session.get("active_tenant_id")
|
||||
|
||||
if not tenant_id:
|
||||
return TenantContext(
|
||||
user_id=user_id,
|
||||
email=session["email"],
|
||||
display_name=session["display_name"],
|
||||
portal_role=session["portal_role"],
|
||||
tenant_id=None,
|
||||
tenant_slug=None,
|
||||
tenant_name=None,
|
||||
tenant_role=None,
|
||||
actor_id=None,
|
||||
actor_type=None,
|
||||
session_token=session["token"],
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.id, t.slug, t.name, tm.tenant_role
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = %s
|
||||
AND tm.tenant_id = %s
|
||||
AND tm.is_active = TRUE
|
||||
AND t.is_active = TRUE
|
||||
""",
|
||||
(user_id, tenant_id),
|
||||
)
|
||||
membership = cur.fetchone()
|
||||
if not membership:
|
||||
raise HTTPException(status_code=403, detail="Keine gültige Tenant-Mitgliedschaft")
|
||||
|
||||
tenant_id_str = str(membership["id"])
|
||||
human = get_human_actor(tenant_id_str, user_id)
|
||||
return TenantContext(
|
||||
user_id=user_id,
|
||||
email=session["email"],
|
||||
display_name=session["display_name"],
|
||||
portal_role=session["portal_role"],
|
||||
tenant_id=tenant_id_str,
|
||||
tenant_slug=membership["slug"],
|
||||
tenant_name=membership["name"],
|
||||
tenant_role=membership["tenant_role"],
|
||||
actor_id=human["id"] if human else None,
|
||||
actor_type=human["actor_type"] if human else None,
|
||||
session_token=session["token"],
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_tenant_context(session: dict[str, Any] = Depends(require_auth)) -> TenantContext:
|
||||
return resolve_tenant_context(session)
|
||||
|
||||
|
||||
def require_tenant_context(session: dict[str, Any] = Depends(require_auth)) -> TenantContext:
|
||||
ctx = resolve_tenant_context(session)
|
||||
if not ctx.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Kein aktiver Tenant — Tenant wählen")
|
||||
return ctx
|
||||
60
backend/tests/conftest.py
Normal file
60
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Shared pytest fixtures for AP0.2 auth tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
os.environ.setdefault("SKIP_SEEDS", "1")
|
||||
os.environ.setdefault("SKIP_BOOTSTRAP", "1")
|
||||
|
||||
|
||||
def _db_available() -> bool:
|
||||
try:
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.getenv("DB_HOST", "localhost"),
|
||||
port=os.getenv("DB_PORT", "5432"),
|
||||
dbname=os.getenv("DB_NAME", "kairo_dev"),
|
||||
user=os.getenv("DB_USER", "kairo_dev"),
|
||||
password=os.getenv("DB_PASSWORD", "dev_password"),
|
||||
)
|
||||
conn.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(not _db_available(), reason="PostgreSQL nicht erreichbar")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _run_migrations():
|
||||
import run_migrations
|
||||
|
||||
assert run_migrations.main() == 0
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _seed_hygiene():
|
||||
"""Vor/nach Tests pytest-Artefakte aus der geteilten Dev-DB entfernen."""
|
||||
import run_seeds
|
||||
|
||||
run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"])
|
||||
yield
|
||||
run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
import importlib
|
||||
|
||||
import main as main_module
|
||||
|
||||
importlib.reload(main_module)
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
return TestClient(main_module.app)
|
||||
91
backend/tests/factories.py
Normal file
91
backend/tests/factories.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Test data factories (PostgreSQL required)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from auth import hash_password
|
||||
from db import get_connection
|
||||
from services.actors import create_actor
|
||||
|
||||
|
||||
def create_tenant(*, slug: str | None = None, name: str = "Test Tenant") -> str:
|
||||
slug = slug or f"t-{uuid.uuid4().hex[:10]}"
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO tenants (slug, name) VALUES (%s, %s) RETURNING id",
|
||||
(slug, name),
|
||||
)
|
||||
tenant_id = str(cur.fetchone()[0])
|
||||
conn.commit()
|
||||
return tenant_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_user(
|
||||
*,
|
||||
email: str | None = None,
|
||||
password: str = "test-password-123",
|
||||
display_name: str = "Test User",
|
||||
portal_role: str = "user",
|
||||
) -> dict:
|
||||
email = email or f"user-{uuid.uuid4().hex[:8]}@example.com"
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (email, password_hash, display_name, portal_role)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, email, display_name, portal_role
|
||||
""",
|
||||
(email, hash_password(password), display_name, portal_role),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return {
|
||||
"id": str(row[0]),
|
||||
"email": row[1],
|
||||
"display_name": row[2],
|
||||
"portal_role": row[3],
|
||||
"password": password,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_membership(*, tenant_id: str, user_id: str, tenant_role: str = "member") -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET tenant_role = EXCLUDED.tenant_role
|
||||
""",
|
||||
(tenant_id, user_id, tenant_role),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def provision_user_in_tenant(
|
||||
*,
|
||||
tenant_role: str = "member",
|
||||
portal_role: str = "user",
|
||||
) -> dict:
|
||||
tenant_id = create_tenant()
|
||||
user = create_user(portal_role=portal_role)
|
||||
add_membership(tenant_id=tenant_id, user_id=user["id"], tenant_role=tenant_role)
|
||||
actor = create_actor(
|
||||
tenant_id=tenant_id,
|
||||
actor_type="human",
|
||||
name=user["display_name"],
|
||||
user_id=user["id"],
|
||||
)
|
||||
return {**user, "tenant_id": tenant_id, "actor_id": actor["id"]}
|
||||
80
backend/tests/test_auth.py
Normal file
80
backend/tests/test_auth.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Auth endpoint and session tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from auth import AUTH_HEADER, get_session
|
||||
from tests.factories import provision_user_in_tenant
|
||||
|
||||
|
||||
def test_login_logout_and_session(client):
|
||||
user = provision_user_in_tenant()
|
||||
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": user["email"], "password": user["password"]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
payload = login.json()
|
||||
assert "token" in payload
|
||||
assert payload["user"]["email"] == user["email"]
|
||||
token = payload["token"]
|
||||
|
||||
me = client.get("/api/me", headers={AUTH_HEADER: token})
|
||||
assert me.status_code == 200
|
||||
assert me.json()["email"] == user["email"]
|
||||
assert me.json()["active_tenant_id"] == user["tenant_id"]
|
||||
|
||||
logout = client.post("/api/auth/logout", headers={AUTH_HEADER: token})
|
||||
assert logout.status_code == 200
|
||||
assert get_session(token) is None
|
||||
|
||||
me_after = client.get("/api/me", headers={AUTH_HEADER: token})
|
||||
assert me_after.status_code == 401
|
||||
|
||||
|
||||
def test_login_invalid_password(client):
|
||||
user = provision_user_in_tenant()
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": user["email"], "password": "wrong-password"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_me_requires_auth(client):
|
||||
assert client.get("/api/me").status_code == 401
|
||||
|
||||
|
||||
def test_me_context_returns_tenant_and_actor(client):
|
||||
user = provision_user_in_tenant(tenant_role="admin")
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": user["email"], "password": user["password"]},
|
||||
)
|
||||
token = login.json()["token"]
|
||||
|
||||
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token})
|
||||
assert ctx.status_code == 200
|
||||
body = ctx.json()
|
||||
assert body["tenant"]["id"] == user["tenant_id"]
|
||||
assert body["tenant"]["role"] == "admin"
|
||||
assert body["actor"]["id"] == user["actor_id"]
|
||||
assert body["actor"]["type"] == "human"
|
||||
|
||||
|
||||
def test_tenant_switch_rejects_invalid_membership(client):
|
||||
user = provision_user_in_tenant()
|
||||
other = provision_user_in_tenant()
|
||||
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": user["email"], "password": user["password"]},
|
||||
)
|
||||
token = login.json()["token"]
|
||||
|
||||
switch = client.post(
|
||||
"/api/me/tenant",
|
||||
headers={AUTH_HEADER: token},
|
||||
json={"tenant_id": other["tenant_id"]},
|
||||
)
|
||||
assert switch.status_code == 403
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ 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
|
||||
assert "003_data_seeds_tracking" in names
|
||||
|
||||
|
||||
def test_migration_runner_is_idempotent():
|
||||
|
|
@ -42,6 +44,8 @@ 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
|
||||
assert "003_data_seeds_tracking" in executed
|
||||
|
||||
|
||||
def test_core_table_exists():
|
||||
|
|
|
|||
69
backend/tests/test_registration.py
Normal file
69
backend/tests/test_registration.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Registration and first system-admin tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from auth import AUTH_HEADER, get_session
|
||||
from db import get_connection
|
||||
from services.registration import user_count
|
||||
|
||||
|
||||
def test_setup_status_open_without_users(client):
|
||||
if user_count() > 0:
|
||||
return
|
||||
res = client.get("/api/auth/setup-status")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["registration_open"] is True
|
||||
assert body["has_users"] is False
|
||||
|
||||
|
||||
def test_register_first_user_becomes_portal_admin(client):
|
||||
if user_count() > 0:
|
||||
return
|
||||
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
email = f"admin-{suffix}@example.com"
|
||||
res = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"password": "secure-pass-123",
|
||||
"display_name": "System Admin",
|
||||
"organization_name": "Kairo Org",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert body["user"]["portal_role"] == "admin"
|
||||
assert body["token"]
|
||||
assert get_session(body["token"]) is not None
|
||||
|
||||
me = client.get("/api/me", headers={AUTH_HEADER: body["token"]})
|
||||
assert me.status_code == 200
|
||||
assert me.json()["portal_role"] == "admin"
|
||||
|
||||
ctx = client.get("/api/me/context", headers={AUTH_HEADER: body["token"]})
|
||||
assert ctx.status_code == 200
|
||||
assert ctx.json()["tenant"]["role"] == "owner"
|
||||
assert ctx.json()["actor"]["type"] == "human"
|
||||
|
||||
# cleanup: allow re-run in isolated env — skip delete in shared CI DB
|
||||
closed = client.get("/api/auth/setup-status")
|
||||
assert closed.json()["registration_open"] is False
|
||||
|
||||
|
||||
def test_register_closed_when_users_exist(client):
|
||||
if user_count() == 0:
|
||||
return
|
||||
|
||||
res = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": f"other-{uuid.uuid4().hex[:8]}@example.com",
|
||||
"password": "secure-pass-123",
|
||||
"display_name": "Other User",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
54
backend/tests/test_seeds.py
Normal file
54
backend/tests/test_seeds.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Data seed runner tests (require PostgreSQL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import run_seeds
|
||||
from tests.factories import create_user
|
||||
|
||||
|
||||
def test_seed_runner_finds_seeds():
|
||||
seeds_dir = run_seeds.seeds_directory()
|
||||
names = [name for name, _, _ in run_seeds.seed_files(seeds_dir)]
|
||||
assert "seed_001_cleanup_pytest_artifacts" in names
|
||||
assert "seed_002_bootstrap_admin" in names
|
||||
|
||||
|
||||
def test_cleanup_seed_removes_example_com_users():
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
user = create_user(email=f"seed-test-{suffix}@example.com")
|
||||
|
||||
assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts", "--force"]) == 0
|
||||
|
||||
from db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) FROM users WHERE id = %s", (user["id"],))
|
||||
assert cur.fetchone()[0] == 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_cleanup_seed_is_idempotent():
|
||||
assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) == 0
|
||||
assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) == 0
|
||||
|
||||
|
||||
def test_dev_admin_seed_ensures_lars():
|
||||
import os
|
||||
|
||||
if os.getenv("ENVIRONMENT", "development").strip().lower() == "production":
|
||||
return
|
||||
|
||||
from auth import get_user_by_email, verify_password
|
||||
from services.dev_admin import DEV_ADMIN_EMAIL, DEV_ADMIN_PASSWORD
|
||||
|
||||
assert run_seeds.main(["--only", "seed_003_ensure_dev_admin"]) == 0
|
||||
|
||||
user = get_user_by_email(DEV_ADMIN_EMAIL)
|
||||
assert user is not None
|
||||
assert user["portal_role"] == "admin"
|
||||
assert verify_password(DEV_ADMIN_PASSWORD, user["password_hash"])
|
||||
46
backend/tests/test_tenant_actor.py
Normal file
46
backend/tests/test_tenant_actor.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Actor and TenantContext tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from auth import AUTH_HEADER
|
||||
from services.actors import create_actor
|
||||
from tests.factories import create_tenant, provision_user_in_tenant
|
||||
|
||||
|
||||
def test_human_actor_linked_to_user(client):
|
||||
user = provision_user_in_tenant()
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": user["email"], "password": user["password"]},
|
||||
)
|
||||
token = login.json()["token"]
|
||||
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token}).json()
|
||||
assert ctx["actor"]["type"] == "human"
|
||||
assert ctx["actor"]["id"] == user["actor_id"]
|
||||
|
||||
|
||||
def test_agent_actor_without_user(client):
|
||||
tenant_id = create_tenant()
|
||||
agent = create_actor(tenant_id=tenant_id, actor_type="agent", name="Planner Agent")
|
||||
assert agent["user_id"] is None
|
||||
assert agent["actor_type"] == "agent"
|
||||
|
||||
|
||||
def test_working_group_and_external_system_actors(client):
|
||||
tenant_id = create_tenant()
|
||||
wg = create_actor(tenant_id=tenant_id, actor_type="working_group", name="Steering Group")
|
||||
ext = create_actor(tenant_id=tenant_id, actor_type="external_system", name="Jira")
|
||||
assert wg["actor_type"] == "working_group"
|
||||
assert ext["actor_type"] == "external_system"
|
||||
|
||||
|
||||
def test_portal_role_separate_from_tenant_role(client):
|
||||
user = provision_user_in_tenant(tenant_role="member", portal_role="admin")
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": user["email"], "password": user["password"]},
|
||||
)
|
||||
token = login.json()["token"]
|
||||
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token}).json()
|
||||
assert ctx["portal_role"] == "admin"
|
||||
assert ctx["tenant"]["role"] == "member"
|
||||
|
|
@ -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 = "003"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -49,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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
54
docs/MIGRATIONS.md
Normal file
54
docs/MIGRATIONS.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Migrationen & Data-Seeds
|
||||
|
||||
Kairo trennt **Schema-Migrationen** (einmalig) von **Data-Seeds** (idempotent, bei Änderung erneut ausführbar).
|
||||
|
||||
## Schema-Migrationen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Pfad** | `backend/migrations/NNN_beschreibung.sql` |
|
||||
| **Tracking** | Tabelle `schema_migrations` |
|
||||
| **Ausführung** | Beim Backend-Start (`run_migrations.py`), überspringbar via `SKIP_DB_MIGRATE=1` |
|
||||
| **Regel** | Jede Datei wird **genau einmal** angewendet. Änderungen → neue nummerierte Datei. |
|
||||
|
||||
## Data-Seeds
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Pfad** | `backend/seeds/seed_NNN_beschreibung.sql` oder `.py` |
|
||||
| **Tracking** | Tabelle `data_seeds` (Name + SHA256-Checksum) |
|
||||
| **Ausführung** | Nach Schema-Migrationen (`run_seeds.py`), überspringbar via `SKIP_SEEDS=1` |
|
||||
| **Regel** | Seed läuft erneut, wenn die Datei **geändert** wurde (Checksum abweicht) oder `--force` gesetzt ist. |
|
||||
|
||||
### Umgebungsfilter
|
||||
|
||||
Dateien mit **`.dev.`** im Namen (z. B. `seed_001_….dev.sql`) laufen **nicht in Production** (`ENVIRONMENT=production`).
|
||||
|
||||
### Seeds manuell ausführen
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev-env.yml exec backend python run_seeds.py
|
||||
docker compose -f docker-compose.dev-env.yml exec backend python run_seeds.py --only seed_001_cleanup_pytest_artifacts --force
|
||||
```
|
||||
|
||||
## Aktuelle Seeds
|
||||
|
||||
| Seed | Typ | Zweck |
|
||||
|------|-----|--------|
|
||||
| `seed_001_cleanup_pytest_artifacts` | SQL (dev) | Entfernt pytest/CI-User (`*@example.com`) und verwaiste Test-Tenants |
|
||||
| `seed_002_bootstrap_admin` | Python | Legt Systemadmin aus `KAIRO_BOOTSTRAP_*` an, wenn noch kein User existiert |
|
||||
| `seed_003_ensure_dev_admin` | Python (dev) | Entfernt fremde Dev-User, stellt `lars@stommer.com` als Portal-Admin sicher (jeder Start) |
|
||||
|
||||
Dev-Seeds (`.dev.*`) werden in Nicht-Prod-Umgebungen **bei jedem Backend-Start** erneut ausgeführt.
|
||||
|
||||
## Neuen Seed anlegen
|
||||
|
||||
1. Datei `backend/seeds/seed_NNN_kurzname.sql` oder `.py` anlegen (Python: `def run() -> None:`).
|
||||
2. SQL idempotent halten (`DELETE … WHERE …`, `INSERT … ON CONFLICT`, etc.).
|
||||
3. Nur Dev/CI: `.dev.sql` / `.dev.py` Suffix verwenden.
|
||||
4. Nach Deploy prüfen: `python run_seeds.py` — bei geänderter Datei wird der Seed automatisch erneut ausgeführt.
|
||||
|
||||
## Tests & CI
|
||||
|
||||
- pytest setzt `SKIP_SEEDS=1` beim App-Import; Cleanup läuft über Fixture + nach CI-pytest.
|
||||
- Geteilte Dev-DB wird nach jedem Test-Lauf bereinigt (`run_seeds.py --only seed_001_cleanup_pytest_artifacts --force`).
|
||||
121
docs/sprints/Sprint0_AP0_1_Completion_Report_v0.1.md
Normal file
121
docs/sprints/Sprint0_AP0_1_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# AP0.1 – Abschlussbericht Projektgrundlage
|
||||
|
||||
**Status:** abgeschlossen
|
||||
**Stand:** 2026-07-04
|
||||
**Branch:** `develop` (`b5efdbb`) · Prod-Stand identisch (`main` `7b96bab`)
|
||||
|
||||
---
|
||||
|
||||
## Umgesetzt
|
||||
|
||||
| Anforderung (AP0.1) | Umsetzung |
|
||||
|---------------------|-----------|
|
||||
| Backend-Skeleton + FastAPI | `backend/main.py`, `backend/Dockerfile` |
|
||||
| Health Endpoint `GET /api/health` | Liefert `status`, `app`, `db` (+ `version`, `schema`) |
|
||||
| PostgreSQL-Anbindung | `backend/db.py`, Compose-Services |
|
||||
| Docker Compose lokal | `docker-compose.dev-env.yml`, `docker-compose.yml` |
|
||||
| Migrationsverzeichnis | `backend/migrations/` |
|
||||
| Migration Runner + `schema_migrations` | `backend/run_migrations.py` |
|
||||
| Erste Migration `001_init_core.sql` | `kairo_app_meta` (Schema-Phase AP0.1) |
|
||||
| Testbasis | `backend/tests/` (pytest) |
|
||||
| Frontend optional minimal | React/Vite-SPA + nginx-Proxy (`frontend/`) |
|
||||
| Verzeichnisse `infra/`, `scripts/`, `tests/` | vorhanden |
|
||||
| README „Local Development“ | `README.md` § Local Development |
|
||||
| Kein Sprint-1-Fachscope | Keine Tenant/User/Actor-Logik, nur Meta-Tabelle |
|
||||
|
||||
**Zusätzlich (über AP0.1-Minimum, bewusst Sprint-0-Infrastruktur):**
|
||||
|
||||
- Gitea Actions: `deploy-dev.yml`, `deploy-prod.yml`, `test.yml`
|
||||
- CI: pytest im Container, compose-smoke (PR), k6-Baseline, Playwright smoke
|
||||
- Deployment-Doku: `docs/DEPLOYMENT.md`
|
||||
- Dev/Prod auf Raspberry Pi (Ports 8097/3097 bzw. 8004/3004)
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit / Integration (pytest)
|
||||
|
||||
| Test | Datei | Status |
|
||||
|------|-------|--------|
|
||||
| App importierbar | `test_health.py::test_app_importable` | ✓ |
|
||||
| Health Endpoint | `test_health.py::test_health_endpoint` | ✓ |
|
||||
| Runner findet Migrationen | `test_migrations.py::test_migration_runner_finds_migrations` | ✓ |
|
||||
| Runner trägt Migration ein + idempotent | `test_migrations.py::test_migration_runner_is_idempotent` | ✓ |
|
||||
| Core-Tabelle existiert | `test_migrations.py::test_core_table_exists` | ✓ |
|
||||
|
||||
### CI (Self-hosted Runner, Dev)
|
||||
|
||||
| Job | Status (2026-07-04) |
|
||||
|-----|---------------------|
|
||||
| pytest-backend | ✓ |
|
||||
| k6 /api/health Baseline | ✓ (nach K6_CI-Anpassung) |
|
||||
| playwright-smoke | ✓ |
|
||||
| lint-backend | ✓ |
|
||||
| compose-smoke | ✓ (PR, isolierte CI-Ports) |
|
||||
|
||||
### Prod
|
||||
|
||||
| Schritt | Status |
|
||||
|---------|--------|
|
||||
| Deploy Production (manueller Merge `develop` → `main`) | ✓ |
|
||||
| Test Suite Prod-Trigger | Fix auf `develop` (`b5efdbb`): Push auf `main` startet Tests |
|
||||
|
||||
---
|
||||
|
||||
## Lokaler Start
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
docker compose -f docker-compose.dev-env.yml up --build
|
||||
|
||||
curl http://localhost:8097/api/health
|
||||
curl http://localhost:3097/api/health
|
||||
|
||||
docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt
|
||||
docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv
|
||||
```
|
||||
|
||||
Migrationen laufen beim Backend-Start automatisch (`run_migrations.main()` in `main.py`, überspringbar via `SKIP_DB_MIGRATE=1` für Tests).
|
||||
|
||||
---
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
1. **Reverse Proxy** — Domains `kairo.jinkendo.de` / `dev.kairo.jinkendo.de` noch nicht eingerichtet; CI und Deploy nutzen localhost (bewusst ausreichend für AP0.1).
|
||||
2. **Prod `.env`** — Muss auf dem Pi unter `/home/lars/docker/kairo/.env` mit sicherem `DB_PASSWORD` liegen (Deploy klont Repo, erzeugt `.env` nicht).
|
||||
3. **README „Startzustand“** — Abschnitt oben in `README.md` beschreibt noch „kein Anwendungscode“; AP0.1-Tabelle unten ist aktuell — Kosmetik-Update empfohlen.
|
||||
4. **`workflow_run`-Trigger** — In Gitea/act unzuverlässig; Prod-Tests hängen an Push `main` (Fix `b5efdbb`). Merge/Deploy bleibt manuell durch Product Owner.
|
||||
|
||||
---
|
||||
|
||||
## Abweichungen von der Spezifikation
|
||||
|
||||
| Spezifikation | Ist | Bewertung |
|
||||
|---------------|-----|-----------|
|
||||
| Health nur `status`, `app`, `db` | zusätzlich `version`, `schema` | Erweiterung, abwärtskompatibel |
|
||||
| `db: "ok"` | auch `"error"` bei DB-Ausfall, dann `status: "degraded"` | sinnvoller Betriebszustand |
|
||||
| Frontend optional | minimal umgesetzt (Health-Anzeige + nginx `/api`-Proxy) | im Scope |
|
||||
| Offene Entscheidungen dokumentiert | in diesem Bericht + `docs/DEPLOYMENT.md`, kein separates ADR für AP0.1 | ausreichend für Abschluss |
|
||||
|
||||
Keine fachliche Sprint-1-Logik vorgezogen.
|
||||
|
||||
---
|
||||
|
||||
## Empfehlung für AP0.2
|
||||
|
||||
**Nächster Auftrag:** AP0.2 – Tenant, User, Actor (`Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.2)
|
||||
|
||||
Vorgehen:
|
||||
|
||||
1. Migration `002_tenant_user_actor.sql` — Tabellen laut Foundation, weiterhin ohne Vorhaben/Projekt-Domäne.
|
||||
2. Seed für lokalen Admin-User (Dev only).
|
||||
3. pytest für Migration + minimale Repository/Model-Schicht.
|
||||
4. Health/version bei Schema-Bump (`DB_SCHEMA_VERSION` → `002`).
|
||||
5. README-Startzustand bereinigen; optional Reverse Proxy parallel (nicht blockierend).
|
||||
|
||||
AP0.1 nicht erneut anfassen, außer README-Kosmetik oder Proxy-Wunsch.
|
||||
|
||||
---
|
||||
|
||||
*Erstellt im Rahmen Sprint 0 – AP0.1 Akzeptanz.*
|
||||
154
docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md
Normal file
154
docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# AP0.2 – Abschlussbericht Auth, Identity, Tenant & Actor Foundation
|
||||
|
||||
**Status:** abgeschlossen (Implementierung)
|
||||
**Stand:** 2026-07-04
|
||||
**Branch:** `develop` (noch nicht auf `main` gemergt)
|
||||
|
||||
---
|
||||
|
||||
## 1. Umgesetzte Dateien
|
||||
|
||||
| Datei | Zweck |
|
||||
|-------|--------|
|
||||
| `backend/migrations/002_auth_identity_tenant_actor.sql` | Schema User, Session, Tenant, Membership, Actor, Audit |
|
||||
| `backend/auth.py` | bcrypt, Sessions, login/logout, `require_auth`, `require_portal_admin` |
|
||||
| `backend/tenant_context.py` | `TenantContext`, `get_tenant_context`, `require_tenant_context` |
|
||||
| `backend/bootstrap.py` | Env-basierter Admin/Tenant/ Actor-Seed |
|
||||
| `backend/services/audit.py` | Audit-Log für Auth-Aktionen |
|
||||
| `backend/services/actors.py` | Actor-Erzeugung + Human-Lookup |
|
||||
| `backend/routers/auth.py` | Login/Logout |
|
||||
| `backend/routers/me.py` | `/api/me`, `/api/me/context`, Tenant-Wechsel |
|
||||
| `backend/main.py` | Router, Bootstrap nach Migrationen |
|
||||
| `backend/tests/conftest.py` | DB-Fixtures |
|
||||
| `backend/tests/factories.py` | Testdaten |
|
||||
| `backend/tests/test_auth.py` | Login, Logout, Session, Context |
|
||||
| `backend/tests/test_tenant_actor.py` | Actor-Typen, Rollen-Trennung |
|
||||
| `backend/version.py` | `0.2.0-ap0.2`, Schema `002` |
|
||||
| `docker-compose.dev-env.yml`, `docker-compose.yml` | Bootstrap/Session-Env |
|
||||
| `.env.example`, `README.md` | Doku |
|
||||
|
||||
---
|
||||
|
||||
## 2. Neue Migrationen
|
||||
|
||||
- **`002_auth_identity_tenant_actor.sql`**
|
||||
- `users` — E-Mail, bcrypt-Hash, `portal_role` (`user` \| `admin`)
|
||||
- `sessions` — Token, `user_id`, `active_tenant_id`, Ablauf
|
||||
- `tenants` — Slug, Name, aktiv
|
||||
- `tenant_memberships` — `tenant_role` (`owner` \| `admin` \| `member`)
|
||||
- `actors` — `human`, `agent`, `working_group`, `external_system`
|
||||
- `audit_log` — Auth-Ereignisse
|
||||
|
||||
---
|
||||
|
||||
## 3. Neue Endpoints
|
||||
|
||||
| Endpoint | Methode | Auth |
|
||||
|----------|---------|------|
|
||||
| `/api/auth/login` | POST | — |
|
||||
| `/api/auth/logout` | POST | `X-Auth-Token` |
|
||||
| `/api/me` | GET | `X-Auth-Token` |
|
||||
| `/api/me/context` | GET | `X-Auth-Token` |
|
||||
| `/api/me/context/required` | GET | `X-Auth-Token` + aktiver Tenant |
|
||||
| `/api/me/tenant` | POST | `X-Auth-Token` |
|
||||
|
||||
OpenAPI: `/api/docs` (Dev)
|
||||
|
||||
---
|
||||
|
||||
## 4. Auth-Fluss
|
||||
|
||||
```
|
||||
POST /api/auth/login { email, password }
|
||||
→ bcrypt verify
|
||||
→ INSERT sessions (opaque token, expires_at, active_tenant_id = erste Membership)
|
||||
→ audit: auth.login
|
||||
→ Response: { token, expires_at, user }
|
||||
|
||||
Request mit Header X-Auth-Token
|
||||
→ get_session(token) JOIN users
|
||||
→ require_auth → session dict (user_id aus DB, nie aus Client-Header)
|
||||
|
||||
POST /api/auth/logout
|
||||
→ DELETE session
|
||||
→ audit: auth.logout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. TenantContext-Auflösung
|
||||
|
||||
```
|
||||
require_auth → session (user_id, active_tenant_id, portal_role, …)
|
||||
→ tenant_memberships + tenants prüfen (nur aktive)
|
||||
→ human actor: actors WHERE tenant_id + user_id + type=human
|
||||
→ TenantContext(
|
||||
portal_role, # Plattform
|
||||
tenant_role, # Mandant
|
||||
tenant_id/slug/name,
|
||||
actor_id/type
|
||||
)
|
||||
```
|
||||
|
||||
Tenant-Wechsel: `POST /api/me/tenant` aktualisiert `sessions.active_tenant_id` nur bei gültiger Membership.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests und Testergebnis
|
||||
|
||||
| Testdatei | Abdeckung |
|
||||
|-----------|-----------|
|
||||
| `test_auth.py` | Login, Logout, Session ungültig, `/api/me`, Context, Tenant-Wechsel verweigert |
|
||||
| `test_tenant_actor.py` | Human↔User, Agent/WG/External ohne User, Portal- vs. Tenant-Rolle |
|
||||
| `test_migrations.py` | Migration 002 erkannt + idempotent |
|
||||
|
||||
Lokal ausführen (Backend-Container):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt
|
||||
docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv
|
||||
```
|
||||
|
||||
*(In dieser Session kein Docker lokal — Verifikation über CI nach Push auf `develop`.)*
|
||||
|
||||
---
|
||||
|
||||
## 7. Abweichungen von den Designprinzipien
|
||||
|
||||
| Prinzip | Abweichung | Begründung |
|
||||
|---------|------------|------------|
|
||||
| Mitai `profiles` | Kairo nutzt `users` | Klarere Trennung User ≠ Actor; kein Multi-Profil-Legacy |
|
||||
| Legacy SHA256-Upgrade | nur bcrypt | Grüne Wiese AP0.2 |
|
||||
| `require_auth_flexible` (Query-Token) | nicht implementiert | Nicht AP0.2-Scope; SSE/Download später |
|
||||
| Account-Lifecycle-Gates | fehlen | AP0.3+ |
|
||||
| Capabilities in TenantContext | leer / nicht modelliert | Bewusst Nicht-Scope |
|
||||
|
||||
Eingehalten: Server-Sessions, `X-Auth-Token`, `Depends(require_auth)` separat, user_id aus Session, Portal- vs. Tenant-Rolle getrennt, TenantContext als eigene Schicht.
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Entscheidungen
|
||||
|
||||
1. **Passwort-Policy / Rate-Limiting** für Login — noch nicht implementiert.
|
||||
2. **Session-Invalidierung** bei Passwort-Reset (Feature kommt später).
|
||||
3. **Tenant-Erstellung via API** — aktuell nur Bootstrap/DB; Admin-API in späterem AP.
|
||||
4. **Frontend Auth-UI** — AP0.2 backend-only; SPA-Anbindung folgt.
|
||||
5. **Prod-Bootstrap** — `KAIRO_BOOTSTRAP_*` in Prod `.env` setzen oder einmalig manuell seeden.
|
||||
|
||||
---
|
||||
|
||||
## 9. Empfehlung für AP0.3
|
||||
|
||||
Laut Foundation-Dokument ursprünglich „Capabilities & Rights Registry“ — der User-Auftrag kombinierte Auth+Tenant bereits in AP0.2.
|
||||
|
||||
**AP0.3 Vorschlag:**
|
||||
|
||||
- Capability/Rights Registry (DB + Sync)
|
||||
- `require_capability()` Dependency
|
||||
- TenantContext um `capabilities: list[str]` erweitern
|
||||
- Keine Feature-Limits / Billing
|
||||
- Optional: Frontend Login-Form + Token-Speicherung
|
||||
|
||||
---
|
||||
|
||||
*Erstellt im Rahmen Sprint 0 – AP0.2.*
|
||||
|
|
@ -1,8 +1,39 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
const TOKEN_KEY = 'kairo_auth_token'
|
||||
|
||||
function apiHeaders(token) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
return headers
|
||||
}
|
||||
|
||||
function parseError(body, fallback) {
|
||||
if (!body) return fallback
|
||||
if (typeof body.detail === 'string') return body.detail
|
||||
if (Array.isArray(body.detail)) {
|
||||
return body.detail.map((d) => d.msg || JSON.stringify(d)).join(', ')
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [health, setHealth] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [healthError, setHealthError] = useState(null)
|
||||
const [setup, setSetup] = useState(null)
|
||||
const [setupLoading, setSetupLoading] = useState(true)
|
||||
const [setupError, setSetupError] = useState(null)
|
||||
const [authMode, setAuthMode] = useState('login')
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [organizationName, setOrganizationName] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
const [authError, setAuthError] = useState(null)
|
||||
const [authBusy, setAuthBusy] = useState(false)
|
||||
const [me, setMe] = useState(null)
|
||||
const [context, setContext] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/health')
|
||||
|
|
@ -11,21 +42,348 @@ export default function App() {
|
|||
return res.json()
|
||||
})
|
||||
.then(setHealth)
|
||||
.catch((err) => setError(err.message))
|
||||
.catch((err) => setHealthError(err.message))
|
||||
|
||||
async function loadSetupStatus() {
|
||||
setSetupLoading(true)
|
||||
setSetupError(null)
|
||||
try {
|
||||
const res = await fetch('/api/auth/setup-status')
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 404
|
||||
? 'Endpoint /api/auth/setup-status nicht gefunden — Backend neu deployen?'
|
||||
: parseError(body, `Setup-Status fehlgeschlagen (HTTP ${res.status})`),
|
||||
)
|
||||
}
|
||||
if (typeof body.registration_open !== 'boolean') {
|
||||
throw new Error('Ungültige Antwort vom Setup-Status — Backend-Version prüfen')
|
||||
}
|
||||
setSetup(body)
|
||||
if (body.registration_open) setAuthMode('register')
|
||||
} catch (err) {
|
||||
setSetup(null)
|
||||
setSetupError(err.message)
|
||||
} finally {
|
||||
setSetupLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadSetupStatus()
|
||||
}, [])
|
||||
|
||||
const loadSession = useCallback(async (activeToken) => {
|
||||
if (!activeToken) {
|
||||
setMe(null)
|
||||
setContext(null)
|
||||
return
|
||||
}
|
||||
const meRes = await fetch('/api/me', { headers: apiHeaders(activeToken) })
|
||||
if (!meRes.ok) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
setToken('')
|
||||
setMe(null)
|
||||
setContext(null)
|
||||
throw new Error(meRes.status === 401 ? 'Session abgelaufen' : `HTTP ${meRes.status}`)
|
||||
}
|
||||
setMe(await meRes.json())
|
||||
const ctxRes = await fetch('/api/me/context', { headers: apiHeaders(activeToken) })
|
||||
if (ctxRes.ok) setContext(await ctxRes.json())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
loadSession(token).catch((err) => setAuthError(err.message))
|
||||
}, [token, loadSession])
|
||||
|
||||
function applyLogin(data) {
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
setToken(data.token)
|
||||
setPassword('')
|
||||
setPasswordConfirm('')
|
||||
setSetup({ registration_open: false, has_users: true, user_count: 1 })
|
||||
setAuthMode('login')
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault()
|
||||
setAuthBusy(true)
|
||||
setAuthError(null)
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(parseError(body, `Login fehlgeschlagen (${res.status})`))
|
||||
applyLogin(body)
|
||||
} catch (err) {
|
||||
setAuthError(err.message)
|
||||
} finally {
|
||||
setAuthBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister(event) {
|
||||
event.preventDefault()
|
||||
if (password !== passwordConfirm) {
|
||||
setAuthError('Passwörter stimmen nicht überein')
|
||||
return
|
||||
}
|
||||
setAuthBusy(true)
|
||||
setAuthError(null)
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
display_name: displayName,
|
||||
organization_name: organizationName || undefined,
|
||||
}),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(parseError(body, `Registrierung fehlgeschlagen (${res.status})`))
|
||||
applyLogin(body)
|
||||
} catch (err) {
|
||||
setAuthError(err.message)
|
||||
} finally {
|
||||
setAuthBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
setAuthBusy(true)
|
||||
setAuthError(null)
|
||||
try {
|
||||
if (token) {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: apiHeaders(token),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* lokal entfernen */
|
||||
} finally {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
setToken('')
|
||||
setMe(null)
|
||||
setContext(null)
|
||||
setAuthBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const registrationOpen = setup?.registration_open === true
|
||||
const registrationClosedBecauseUsersExist = setup?.has_users === true && !registrationOpen
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<h1>Jinkendo Kairo</h1>
|
||||
<p>Operativer Program Director — Sprint 0 / AP0.1</p>
|
||||
<header className="hero">
|
||||
<h1>Jinkendo Kairo</h1>
|
||||
<p>Operativer Program Director — Sprint 0 / AP0.2</p>
|
||||
<p className="muted">Auth, Tenant & Actor Foundation</p>
|
||||
</header>
|
||||
|
||||
<section className="card auth-card">
|
||||
<h2>{token ? 'Session' : registrationOpen ? 'Ersteinrichtung' : 'Anmeldung'}</h2>
|
||||
|
||||
{!token && registrationOpen && (
|
||||
<p className="setup-hint">
|
||||
Noch kein Systemadministrator. Der erste Account wird automatisch{' '}
|
||||
<strong>Portal-Admin</strong> und erhält einen Default-Tenant.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!token && setupLoading && (
|
||||
<p className="muted">Prüfe Ersteinrichtung …</p>
|
||||
)}
|
||||
|
||||
{!token && setupError && (
|
||||
<p className="error">{setupError}</p>
|
||||
)}
|
||||
|
||||
{!token && registrationClosedBecauseUsersExist && (
|
||||
<p className="setup-hint setup-hint-muted">
|
||||
Es existiert bereits mindestens ein Benutzer ({setup.user_count ?? '≥1'}).
|
||||
Registrierung ist nur für die Ersteinrichtung vorgesehen — bitte anmelden.
|
||||
Falls der Admin per <code>KAIRO_BOOTSTRAP_*</code> in der <code>.env</code> angelegt
|
||||
wurde, nutzen Sie diese Zugangsdaten.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!token && !setupLoading && !setupError && (
|
||||
<>
|
||||
<div className="tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={authMode === 'login' ? 'tab active' : 'tab'}
|
||||
aria-selected={authMode === 'login'}
|
||||
onClick={() => { setAuthMode('login'); setAuthError(null) }}
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={authMode === 'register' ? 'tab active' : 'tab'}
|
||||
aria-selected={authMode === 'register'}
|
||||
disabled={!registrationOpen}
|
||||
onClick={() => { setAuthMode('register'); setAuthError(null) }}
|
||||
>
|
||||
Registrieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{authMode === 'login' && (
|
||||
<form className="form" onSubmit={handleLogin}>
|
||||
<label>
|
||||
E-Mail
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={authBusy}>
|
||||
{authBusy ? 'Anmelden …' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{authMode === 'register' && registrationOpen && (
|
||||
<form className="form" onSubmit={handleRegister}>
|
||||
<label>
|
||||
Anzeigename
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Organisation / Tenant (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder="z. B. Mein Programm-Team"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
E-Mail
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Passwort bestätigen
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={authBusy}>
|
||||
{authBusy ? 'Registrieren …' : 'Systemadmin anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{authMode === 'register' && !registrationOpen && (
|
||||
<p className="muted">
|
||||
Registrierung ist geschlossen. Bitte melden Sie sich an oder wenden Sie sich an
|
||||
den Administrator.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{token && me && (
|
||||
<div className="session">
|
||||
<p>
|
||||
Eingeloggt als <strong>{me.display_name}</strong> ({me.email})
|
||||
</p>
|
||||
<p className="muted">
|
||||
{me.portal_role === 'admin' ? 'Portal-Systemadmin' : `Portal-Rolle: ${me.portal_role}`}
|
||||
{context?.tenant?.name && ` · ${context.tenant.name}`}
|
||||
</p>
|
||||
<button type="button" className="secondary" onClick={handleLogout} disabled={authBusy}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authError && <p className="error">{authError}</p>}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>API Health</h2>
|
||||
{error && <p className="error">Fehler: {error}</p>}
|
||||
{!error && !health && <p>Lade …</p>}
|
||||
{health && (
|
||||
<pre>{JSON.stringify(health, null, 2)}</pre>
|
||||
<h2>Ersteinrichtung</h2>
|
||||
{setupLoading && <p className="muted">Lade …</p>}
|
||||
{setupError && <p className="error">{setupError}</p>}
|
||||
{setup && <pre>{JSON.stringify(setup, null, 2)}</pre>}
|
||||
{!setupLoading && !setup && !setupError && (
|
||||
<p className="muted">Kein Setup-Status verfügbar.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>API Health</h2>
|
||||
{healthError && <p className="error">Fehler: {healthError}</p>}
|
||||
{!healthError && !health && <p>Lade …</p>}
|
||||
{health && <pre>{JSON.stringify(health, null, 2)}</pre>}
|
||||
</section>
|
||||
|
||||
{token && me && (
|
||||
<>
|
||||
<section className="card">
|
||||
<h2>/api/me</h2>
|
||||
<pre>{JSON.stringify(me, null, 2)}</pre>
|
||||
</section>
|
||||
{context && (
|
||||
<section className="card">
|
||||
<h2>/api/me/context</h2>
|
||||
<pre>{JSON.stringify(context, null, 2)}</pre>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,54 @@ body {
|
|||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
background: #eef4fc;
|
||||
border: 1px solid #c5d9f0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.92rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.setup-hint-muted {
|
||||
background: #f3f3f3;
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: #eee;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0.45rem 0.9rem;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #1a4d8f;
|
||||
color: #fff;
|
||||
border-color: #1a4d8f;
|
||||
}
|
||||
|
||||
.tab:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
|
|
@ -27,4 +84,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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user