Kairo-Jinkendo/backend/services/registration.py
Lars 101376540a
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 2s
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 12s
Erstregistrierung als Systemadmin plus Login/Register-Dialog im Frontend.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 20:19:02 +02:00

153 lines
4.3 KiB
Python

"""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",
)