diff --git a/README.md b/README.md index 109dc59..6f9cd06 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,14 @@ Interaktive API-Doku (Dev): http://localhost:8097/api/docs ### Auth (AP0.2) -**Ersteinrichtung** (nur wenn noch kein User existiert): +**Ersteinrichtung Dev** (automatisch per Seed nach jedem Backend-Start): -- UI-Registrierung unter http://localhost:3097 (Tab „Registrieren“), oder -- Bootstrap per `.env` (wird als Data-Seed beim Start ausgeführt): +| Feld | Wert | +|------|------| +| E-Mail | `lars@stommer.com` | +| Passwort | `12345678` | + +Alternativ: UI-Registrierung (nur wenn DB leer und Dev-Seed deaktiviert) oder Bootstrap per `.env`: ```env KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local diff --git a/backend/run_seeds.py b/backend/run_seeds.py index 767c77a..04ae38f 100644 --- a/backend/run_seeds.py +++ b/backend/run_seeds.py @@ -208,7 +208,9 @@ def pending_seeds( if only and seed_name not in only: continue checksum = file_checksum(filepath) - if force or applied_seed_checksum(conn, seed_name) != checksum: + is_dev_seed = _DEV_MARKER in filename + always_run = is_dev_seed and not is_production() + if force or always_run or applied_seed_checksum(conn, seed_name) != checksum: selected.append((seed_name, filepath, kind)) return selected diff --git a/backend/seeds/seed_003_ensure_dev_admin.dev.py b/backend/seeds/seed_003_ensure_dev_admin.dev.py new file mode 100644 index 0000000..03e8dbe --- /dev/null +++ b/backend/seeds/seed_003_ensure_dev_admin.dev.py @@ -0,0 +1,9 @@ +"""Dev: Portal-Admin lars@stommer.com sicherstellen (läuft bei jedem Start in Dev).""" + +from __future__ import annotations + + +def run() -> None: + from services.dev_admin import ensure_dev_admin + + ensure_dev_admin() diff --git a/backend/services/dev_admin.py b/backend/services/dev_admin.py new file mode 100644 index 0000000..a26e16c --- /dev/null +++ b/backend/services/dev_admin.py @@ -0,0 +1,187 @@ +"""Dev-only admin seed — ensures a known portal admin exists.""" + +from __future__ import annotations + +import os +from typing import Iterable + +from auth import hash_password +from db import get_connection +from psycopg2.extras import RealDictCursor + +from services.actors import create_actor, get_human_actor +from services.audit import log_audit +from services.registration import provision_system_admin, user_count + +DEV_ADMIN_EMAIL = "lars@stommer.com" +DEV_ADMIN_PASSWORD = "12345678" +DEV_ADMIN_NAME = "Lars Stommer" +DEV_TENANT_SLUG = "stommer" +DEV_TENANT_NAME = "Stommer" + + +def _normalize_emails(emails: Iterable[str]) -> tuple[str, ...]: + return tuple(sorted({email.strip().lower() for email in emails if email.strip()})) + + +def delete_users_except(keep_emails: Iterable[str]) -> int: + """Remove all users not in keep_emails (with FK-safe order). Returns deleted count.""" + keep = _normalize_emails(keep_emails) + if not keep: + raise ValueError("keep_emails must not be empty") + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("DELETE FROM actors WHERE actor_type = 'human' AND user_id IS NULL") + + cur.execute( + """ + DELETE FROM actors + WHERE user_id IN ( + SELECT id FROM users WHERE lower(email) <> ALL(%s) + ) + """, + (list(keep),), + ) + cur.execute( + """ + DELETE FROM tenant_memberships + WHERE user_id IN ( + SELECT id FROM users WHERE lower(email) <> ALL(%s) + ) + """, + (list(keep),), + ) + cur.execute( + """ + DELETE FROM sessions + WHERE user_id IN ( + SELECT id FROM users WHERE lower(email) <> ALL(%s) + ) + """, + (list(keep),), + ) + cur.execute( + """ + DELETE FROM audit_log + WHERE user_id IN ( + SELECT id FROM users WHERE lower(email) <> ALL(%s) + ) + """, + (list(keep),), + ) + cur.execute( + "DELETE FROM users WHERE lower(email) <> ALL(%s) RETURNING email", + (list(keep),), + ) + removed = cur.fetchall() + conn.commit() + return len(removed) + finally: + conn.close() + + +def _ensure_tenant(conn, *, slug: str, name: str) -> dict: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO tenants (slug, name) + VALUES (%s, %s) + ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name + RETURNING id, slug, name + """, + (slug, name), + ) + return dict(cur.fetchone()) + + +def ensure_dev_admin( + *, + email: str = DEV_ADMIN_EMAIL, + password: str = DEV_ADMIN_PASSWORD, + display_name: str = DEV_ADMIN_NAME, + tenant_slug: str = DEV_TENANT_SLUG, + tenant_name: str = DEV_TENANT_NAME, +) -> dict: + """Dev seed: remove other users, ensure portal admin + tenant + human actor.""" + if os.getenv("ENVIRONMENT", "development").strip().lower() == "production": + raise RuntimeError("ensure_dev_admin darf nicht in Production laufen") + + normalized_email = email.strip().lower() + removed = delete_users_except([normalized_email]) + if removed: + print(f"[dev_admin] {removed} fremde User entfernt") + + if user_count() == 0: + result = provision_system_admin( + email=normalized_email, + password=password, + display_name=display_name, + tenant_slug=tenant_slug, + tenant_name=tenant_name, + source="dev_seed", + ) + print(f"[dev_admin] Portal-Admin angelegt: {normalized_email}") + return result + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + tenant = _ensure_tenant(conn, slug=tenant_slug, name=tenant_name) + tenant_id = str(tenant["id"]) + + cur.execute( + """ + UPDATE users + SET password_hash = %s, + display_name = %s, + portal_role = 'admin', + updated_at = NOW() + WHERE lower(email) = %s + RETURNING id, email, display_name, portal_role + """, + (hash_password(password), display_name.strip(), normalized_email), + ) + row = cur.fetchone() + if not row: + raise RuntimeError(f"Dev-Admin {normalized_email} fehlt nach Bereinigung") + user = dict(row) + user_id = str(user["id"]) + + cur.execute( + """ + INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role) + VALUES (%s, %s, 'owner') + ON CONFLICT (tenant_id, user_id) DO UPDATE SET tenant_role = 'owner' + """, + (tenant_id, user_id), + ) + conn.commit() + finally: + conn.close() + + if not get_human_actor(tenant_id, user_id): + create_actor( + tenant_id=tenant_id, + actor_type="human", + name=display_name.strip(), + user_id=user_id, + ) + + log_audit( + "auth.dev_admin_ensure", + user_id=user_id, + tenant_id=tenant_id, + details={"email": normalized_email, "removed_users": removed}, + ) + print(f"[dev_admin] Portal-Admin bestätigt: {normalized_email}") + return { + "user": { + "id": user_id, + "email": user["email"], + "display_name": user["display_name"], + "portal_role": user["portal_role"], + }, + "tenant": {"id": tenant_id, "slug": tenant["slug"], "name": tenant["name"]}, + } diff --git a/backend/tests/test_seeds.py b/backend/tests/test_seeds.py index cb46f93..5bd98c1 100644 --- a/backend/tests/test_seeds.py +++ b/backend/tests/test_seeds.py @@ -35,3 +35,20 @@ def test_cleanup_seed_removes_example_com_users(): def test_cleanup_seed_is_idempotent(): assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) == 0 assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) == 0 + + +def test_dev_admin_seed_ensures_lars(): + import os + + if os.getenv("ENVIRONMENT", "development").strip().lower() == "production": + return + + from auth import get_user_by_email, verify_password + from services.dev_admin import DEV_ADMIN_EMAIL, DEV_ADMIN_PASSWORD + + assert run_seeds.main(["--only", "seed_003_ensure_dev_admin"]) == 0 + + user = get_user_by_email(DEV_ADMIN_EMAIL) + assert user is not None + assert user["portal_role"] == "admin" + assert verify_password(DEV_ADMIN_PASSWORD, user["password_hash"]) diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index 2bcbf72..414c588 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -37,6 +37,9 @@ docker compose -f docker-compose.dev-env.yml exec backend python run_seeds.py -- |------|-----|--------| | `seed_001_cleanup_pytest_artifacts` | SQL (dev) | Entfernt pytest/CI-User (`*@example.com`) und verwaiste Test-Tenants | | `seed_002_bootstrap_admin` | Python | Legt Systemadmin aus `KAIRO_BOOTSTRAP_*` an, wenn noch kein User existiert | +| `seed_003_ensure_dev_admin` | Python (dev) | Entfernt fremde Dev-User, stellt `lars@stommer.com` als Portal-Admin sicher (jeder Start) | + +Dev-Seeds (`.dev.*`) werden in Nicht-Prod-Umgebungen **bei jedem Backend-Start** erneut ausgeführt. ## Neuen Seed anlegen