master user anlegen #2

Merged
Lars merged 8 commits from develop into main 2026-07-04 22:35:08 +02:00
6 changed files with 226 additions and 4 deletions
Showing only changes of commit 88792eeb47 - Show all commits

View File

@ -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

View File

@ -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

View 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()

View 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"]},
}

View File

@ -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"])

View File

@ -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