Dev-Seed: lars@stommer.com als Portal-Admin bei jedem Start sicherstellen.
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 9s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 9s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
598677d343
commit
88792eeb47
10
README.md
10
README.md
|
|
@ -103,10 +103,14 @@ Interaktive API-Doku (Dev): http://localhost:8097/api/docs
|
||||||
|
|
||||||
### Auth (AP0.2)
|
### 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
|
| Feld | Wert |
|
||||||
- Bootstrap per `.env` (wird als Data-Seed beim Start ausgeführt):
|
|------|------|
|
||||||
|
| E-Mail | `lars@stommer.com` |
|
||||||
|
| Passwort | `12345678` |
|
||||||
|
|
||||||
|
Alternativ: UI-Registrierung (nur wenn DB leer und Dev-Seed deaktiviert) oder Bootstrap per `.env`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
|
KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,9 @@ def pending_seeds(
|
||||||
if only and seed_name not in only:
|
if only and seed_name not in only:
|
||||||
continue
|
continue
|
||||||
checksum = file_checksum(filepath)
|
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))
|
selected.append((seed_name, filepath, kind))
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
|
|
||||||
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()
|
||||||
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"]},
|
||||||
|
}
|
||||||
|
|
@ -35,3 +35,20 @@ def test_cleanup_seed_removes_example_com_users():
|
||||||
def test_cleanup_seed_is_idempotent():
|
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
|
||||||
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"])
|
||||||
|
|
|
||||||
|
|
@ -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_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_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
|
## Neuen Seed anlegen
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user