Erstregistrierung als Systemadmin plus Login/Register-Dialog im Frontend.
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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-04 20:19:02 +02:00
parent 45a108b4a0
commit 101376540a
7 changed files with 502 additions and 112 deletions

View File

@ -113,6 +113,8 @@ KAIRO_BOOTSTRAP_TENANT_SLUG=default
| Endpoint | Methode | Auth | Beschreibung | | 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/login` | POST | — | E-Mail + Passwort → Session-Token |
| `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen | | `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen |
| `/api/me` | GET | `X-Auth-Token` | Aktueller User + Tenant-Liste | | `/api/me` | GET | `X-Auth-Token` | Aktueller User + Tenant-Liste |

View File

@ -1,15 +1,10 @@
"""Bootstrap first admin user, tenant, membership and human actor from environment.""" """Bootstrap first admin user from environment (optional alternative to UI registration)."""
from __future__ import annotations from __future__ import annotations
import os import os
from psycopg2.extras import RealDictCursor from services.registration import provision_system_admin, user_count
from auth import create_session, hash_password
from db import get_connection
from services.actors import create_actor
from services.audit import log_audit
def _env(name: str) -> str: def _env(name: str) -> str:
@ -25,64 +20,19 @@ def bootstrap_admin_if_needed() -> None:
if not email or not password: if not email or not password:
return return
if user_count() > 0:
return
tenant_slug = _env("KAIRO_BOOTSTRAP_TENANT_SLUG") or "default" tenant_slug = _env("KAIRO_BOOTSTRAP_TENANT_SLUG") or "default"
tenant_name = _env("KAIRO_BOOTSTRAP_TENANT_NAME") or "Default Tenant" tenant_name = _env("KAIRO_BOOTSTRAP_TENANT_NAME") or "Default Tenant"
display_name = _env("KAIRO_BOOTSTRAP_ADMIN_NAME") or "Kairo Admin" display_name = _env("KAIRO_BOOTSTRAP_ADMIN_NAME") or "Kairo Admin"
conn = get_connection() provision_system_admin(
try: email=email,
with conn.cursor(cursor_factory=RealDictCursor) as cur: password=password,
cur.execute("SELECT COUNT(*) AS n FROM users") display_name=display_name,
if cur.fetchone()["n"] > 0: tenant_slug=tenant_slug,
return tenant_name=tenant_name,
source="env_bootstrap",
cur.execute(
"""
INSERT INTO tenants (slug, name)
VALUES (%s, %s)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id
""",
(tenant_slug, tenant_name),
) )
tenant_row = cur.fetchone() print(f"[bootstrap] Systemadmin und Tenant '{tenant_slug}' angelegt")
if not tenant_row:
cur.execute("SELECT id FROM tenants WHERE slug = %s", (tenant_slug,))
tenant_row = cur.fetchone()
tenant_id = str(tenant_row["id"])
cur.execute(
"""
INSERT INTO users (email, password_hash, display_name, portal_role)
VALUES (%s, %s, %s, 'admin')
RETURNING id
""",
(email.lower(), hash_password(password), display_name),
)
user_id = str(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
VALUES (%s, %s, 'owner')
ON CONFLICT (tenant_id, user_id) DO NOTHING
""",
(tenant_id, user_id),
)
conn.commit()
finally:
conn.close()
create_actor(
tenant_id=tenant_id,
actor_type="human",
name=display_name,
user_id=user_id,
)
log_audit(
"bootstrap.admin_created",
user_id=user_id,
tenant_id=tenant_id,
details={"email": email.lower(), "tenant_slug": tenant_slug},
)
print(f"[bootstrap] Admin user and tenant '{tenant_slug}' created")

View File

@ -1,27 +1,50 @@
"""Auth endpoints: login, logout.""" """Auth endpoints: login, logout, registration."""
from __future__ import annotations from __future__ import annotations
from auth import login, logout, require_auth from auth import login, logout, require_auth
from fastapi import APIRouter, Depends, Response from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from services.registration import register_system_admin, setup_status
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
# str statt EmailStr: Self-Hosted/Dev nutzt oft .local-Domains (admin@kairo.local)
email: str = Field(min_length=3, max_length=255) email: str = Field(min_length=3, max_length=255)
password: str = Field(min_length=8, max_length=256) 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") @router.post("/login")
def auth_login(body: LoginRequest): def auth_login(body: LoginRequest):
return login(body.email, body.password) return login(body.email, body.password)
@router.post("/logout") @router.post("/logout")
def auth_logout(response: Response, session: dict = Depends(require_auth)): def auth_logout(session: dict = Depends(require_auth)):
token = session.get("token") token = session.get("token")
if token: if token:
logout(token) logout(token)

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

View 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

View File

@ -8,12 +8,26 @@ function apiHeaders(token) {
return headers 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() { export default function App() {
const [health, setHealth] = useState(null) const [health, setHealth] = useState(null)
const [healthError, setHealthError] = useState(null) const [healthError, setHealthError] = useState(null)
const [setup, setSetup] = useState(null)
const [authMode, setAuthMode] = useState('login')
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '') const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [organizationName, setOrganizationName] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [authError, setAuthError] = useState(null) const [authError, setAuthError] = useState(null)
const [authBusy, setAuthBusy] = useState(false) const [authBusy, setAuthBusy] = useState(false)
const [me, setMe] = useState(null) const [me, setMe] = useState(null)
@ -27,6 +41,14 @@ export default function App() {
}) })
.then(setHealth) .then(setHealth)
.catch((err) => setHealthError(err.message)) .catch((err) => setHealthError(err.message))
fetch('/api/auth/setup-status')
.then((res) => res.json())
.then((data) => {
setSetup(data)
if (data.registration_open) setAuthMode('register')
})
.catch(() => setSetup({ registration_open: false, has_users: true }))
}, []) }, [])
const loadSession = useCallback(async (activeToken) => { const loadSession = useCallback(async (activeToken) => {
@ -53,6 +75,15 @@ export default function App() {
loadSession(token).catch((err) => setAuthError(err.message)) loadSession(token).catch((err) => setAuthError(err.message))
}, [token, loadSession]) }, [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) { async function handleLogin(event) {
event.preventDefault() event.preventDefault()
setAuthBusy(true) setAuthBusy(true)
@ -63,14 +94,38 @@ export default function App() {
headers: apiHeaders(), headers: apiHeaders(),
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}) })
if (!res.ok) {
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
throw new Error(body.detail || `Login fehlgeschlagen (${res.status})`) if (!res.ok) throw new Error(parseError(body, `Login fehlgeschlagen (${res.status})`))
applyLogin(body)
} catch (err) {
setAuthError(err.message)
} finally {
setAuthBusy(false)
} }
const data = await res.json() }
localStorage.setItem(TOKEN_KEY, data.token)
setToken(data.token) async function handleRegister(event) {
setPassword('') 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) { } catch (err) {
setAuthError(err.message) setAuthError(err.message)
} finally { } finally {
@ -89,7 +144,7 @@ export default function App() {
}) })
} }
} catch { } catch {
/* Session trotzdem lokal entfernen */ /* lokal entfernen */
} finally { } finally {
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY)
setToken('') setToken('')
@ -99,6 +154,8 @@ export default function App() {
} }
} }
const registrationOpen = setup?.registration_open === true
return ( return (
<main className="shell"> <main className="shell">
<header className="hero"> <header className="hero">
@ -107,16 +164,41 @@ export default function App() {
<p className="muted">Auth, Tenant &amp; Actor Foundation</p> <p className="muted">Auth, Tenant &amp; Actor Foundation</p>
</header> </header>
<section className="card"> <section className="card auth-card">
<h2>API Health</h2> <h2>{token ? 'Session' : registrationOpen ? 'Ersteinrichtung' : 'Anmeldung'}</h2>
{healthError && <p className="error">Fehler: {healthError}</p>}
{!healthError && !health && <p>Lade </p>} {!token && registrationOpen && (
{health && <pre>{JSON.stringify(health, null, 2)}</pre>} <p className="setup-hint">
</section> Noch kein Systemadministrator. Der erste Account wird automatisch{' '}
<strong>Portal-Admin</strong> und erhält einen Default-Tenant.
</p>
)}
<section className="card">
<h2>Session</h2>
{!token && ( {!token && (
<>
<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}> <form className="form" onSubmit={handleLogin}>
<label> <label>
E-Mail E-Mail
@ -145,14 +227,82 @@ export default function App() {
</form> </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 && ( {token && me && (
<div className="session"> <div className="session">
<p> <p>
Eingeloggt als <strong>{me.display_name}</strong> ({me.email}) Eingeloggt als <strong>{me.display_name}</strong> ({me.email})
</p> </p>
<p className="muted"> <p className="muted">
Portal-Rolle: {me.portal_role} {me.portal_role === 'admin' ? 'Portal-Systemadmin' : `Portal-Rolle: ${me.portal_role}`}
{me.active_tenant_id && ` · Tenant: ${me.active_tenant_id}`} {context?.tenant?.name && ` · ${context.tenant.name}`}
</p> </p>
<button type="button" className="secondary" onClick={handleLogout} disabled={authBusy}> <button type="button" className="secondary" onClick={handleLogout} disabled={authBusy}>
Abmelden Abmelden
@ -163,6 +313,13 @@ export default function App() {
{authError && <p className="error">{authError}</p>} {authError && <p className="error">{authError}</p>}
</section> </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 && ( {token && me && (
<> <>
<section className="card"> <section className="card">

View File

@ -36,6 +36,43 @@ body {
font-size: 1.1rem; 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;
}
.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 { .error {
color: #b00020; color: #b00020;
} }