AP0.2: Auth, Tenant, Actor Foundation mit Sessions, TenantContext und Tests.
Some checks failed
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Failing after 7s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Failing after 7s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b5efdbbc2e
commit
564008be13
|
|
@ -8,6 +8,11 @@
|
||||||
# APP_URL=https://dev.kairo.jinkendo.de
|
# APP_URL=https://dev.kairo.jinkendo.de
|
||||||
# ALLOWED_ORIGINS=https://dev.kairo.jinkendo.de,http://192.168.2.49:3097
|
# ALLOWED_ORIGINS=https://dev.kairo.jinkendo.de,http://192.168.2.49:3097
|
||||||
# ENVIRONMENT=development
|
# ENVIRONMENT=development
|
||||||
|
# SESSION_DAYS=30
|
||||||
|
# KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
|
||||||
|
# KAIRO_BOOTSTRAP_ADMIN_PASSWORD=change-me-in-dev-env
|
||||||
|
# KAIRO_BOOTSTRAP_TENANT_SLUG=default
|
||||||
|
# KAIRO_BOOTSTRAP_TENANT_NAME=Default Tenant
|
||||||
|
|
||||||
# ─── PROD (docker-compose.yml) ───────────────────────────────────────────────
|
# ─── PROD (docker-compose.yml) ───────────────────────────────────────────────
|
||||||
DB_NAME=kairo
|
DB_NAME=kairo
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -99,6 +99,38 @@ docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests
|
||||||
|
|
||||||
UI: http://localhost:3097 · API: http://localhost:8097
|
UI: http://localhost:3097 · API: http://localhost:8097
|
||||||
|
|
||||||
|
Interaktive API-Doku (Dev): http://localhost:8097/api/docs
|
||||||
|
|
||||||
|
### Auth (AP0.2)
|
||||||
|
|
||||||
|
Bootstrap (nur wenn noch kein User existiert — `.env` setzen):
|
||||||
|
|
||||||
|
```env
|
||||||
|
KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
|
||||||
|
KAIRO_BOOTSTRAP_ADMIN_PASSWORD=…
|
||||||
|
KAIRO_BOOTSTRAP_TENANT_SLUG=default
|
||||||
|
```
|
||||||
|
|
||||||
|
| Endpoint | Methode | Auth | Beschreibung |
|
||||||
|
|----------|---------|------|--------------|
|
||||||
|
| `/api/auth/login` | POST | — | E-Mail + Passwort → Session-Token |
|
||||||
|
| `/api/auth/logout` | POST | `X-Auth-Token` | Session löschen |
|
||||||
|
| `/api/me` | GET | `X-Auth-Token` | Aktueller User + Tenant-Liste |
|
||||||
|
| `/api/me/context` | GET | `X-Auth-Token` | TenantContext (Tenant + Human Actor) |
|
||||||
|
| `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login
|
||||||
|
curl -s -X POST http://localhost:8097/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"admin@kairo.local","password":"…"}'
|
||||||
|
|
||||||
|
# Geschützter Endpoint
|
||||||
|
curl -s http://localhost:8097/api/me -H "X-Auth-Token: TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Regeln: `user_id` kommt aus der Session, nicht aus Client-Headern. Portalrolle (`portal_role`) und Tenantrolle (`tenant_role`) sind getrennt.
|
||||||
|
|
||||||
## AP0.1 – Stand Projektgrundlage
|
## AP0.1 – Stand Projektgrundlage
|
||||||
|
|
||||||
| Bereich | Status |
|
| Bereich | Status |
|
||||||
|
|
@ -107,5 +139,5 @@ UI: http://localhost:3097 · API: http://localhost:8097
|
||||||
| Docker Compose (Prod + Dev) | erledigt |
|
| Docker Compose (Prod + Dev) | erledigt |
|
||||||
| Backend (FastAPI, Migrationen, `/api/health`) | erledigt |
|
| Backend (FastAPI, Migrationen, `/api/health`) | erledigt |
|
||||||
| Frontend minimal (React + nginx Proxy) | erledigt |
|
| Frontend minimal (React + nginx Proxy) | erledigt |
|
||||||
| pytest (Health + Migrationen) | erledigt |
|
| pytest (Health + Migrationen + Auth/Tenant/Actor) | erledigt (AP0.2) |
|
||||||
| Gitea Actions (Deploy + Test) | erledigt |
|
| Gitea Actions (Deploy + Test) | erledigt |
|
||||||
|
|
|
||||||
243
backend/auth.py
Normal file
243
backend/auth.py
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
"""Authentication: bcrypt passwords, server-side sessions, FastAPI dependencies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from fastapi import Depends, Header, HTTPException
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.audit import log_audit
|
||||||
|
|
||||||
|
AUTH_HEADER = "X-Auth-Token"
|
||||||
|
SESSION_DAYS = int(os.getenv("SESSION_DAYS", "30"))
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored_hash: str) -> bool:
|
||||||
|
if not stored_hash:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def make_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_expiry() -> datetime:
|
||||||
|
return datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email: str) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, email, password_hash, display_name, portal_role, is_active
|
||||||
|
FROM users
|
||||||
|
WHERE LOWER(email) = LOWER(%s)
|
||||||
|
""",
|
||||||
|
(email.strip(),),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id: str) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, email, display_name, portal_role, is_active, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(token: str) -> Optional[dict[str, Any]]:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id AS session_id,
|
||||||
|
s.token,
|
||||||
|
s.user_id,
|
||||||
|
s.active_tenant_id,
|
||||||
|
s.expires_at,
|
||||||
|
u.email,
|
||||||
|
u.display_name,
|
||||||
|
u.portal_role,
|
||||||
|
u.is_active AS user_is_active
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.token = %s AND s.expires_at > NOW()
|
||||||
|
""",
|
||||||
|
(token,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
session = dict(row)
|
||||||
|
if not session.get("user_is_active"):
|
||||||
|
return None
|
||||||
|
return session
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_active_tenant(user_id: str) -> Optional[str]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT tm.tenant_id
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = %s
|
||||||
|
AND tm.is_active = TRUE
|
||||||
|
AND t.is_active = TRUE
|
||||||
|
ORDER BY tm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return str(row[0]) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(user_id: str, active_tenant_id: Optional[str] = None) -> dict[str, Any]:
|
||||||
|
token = make_token()
|
||||||
|
tenant_id = active_tenant_id or _default_active_tenant(user_id)
|
||||||
|
expires_at = _session_expiry()
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions (token, user_id, active_tenant_id, expires_at)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING token, expires_at, active_tenant_id
|
||||||
|
""",
|
||||||
|
(token, user_id, tenant_id, expires_at),
|
||||||
|
)
|
||||||
|
row = dict(cur.fetchone())
|
||||||
|
conn.commit()
|
||||||
|
row["user_id"] = user_id
|
||||||
|
return row
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_session(token: str) -> None:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM sessions WHERE token = %s", (token,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_active_tenant(token: str, tenant_id: str) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sessions s
|
||||||
|
SET active_tenant_id = %s
|
||||||
|
FROM users u
|
||||||
|
WHERE s.token = %s
|
||||||
|
AND s.user_id = u.id
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM tenant_memberships tm
|
||||||
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = s.user_id
|
||||||
|
AND tm.tenant_id = %s
|
||||||
|
AND tm.is_active = TRUE
|
||||||
|
AND t.is_active = TRUE
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(tenant_id, token, tenant_id),
|
||||||
|
)
|
||||||
|
updated = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
return updated
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def login(email: str, password: str) -> dict[str, Any]:
|
||||||
|
user = get_user_by_email(email)
|
||||||
|
if not user or not user.get("is_active"):
|
||||||
|
log_audit("auth.login_failed", details={"email": email.strip().lower()})
|
||||||
|
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
|
||||||
|
|
||||||
|
if not verify_password(password, user["password_hash"]):
|
||||||
|
log_audit("auth.login_failed", user_id=str(user["id"]), details={"email": user["email"]})
|
||||||
|
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
|
||||||
|
|
||||||
|
session = create_session(str(user["id"]))
|
||||||
|
log_audit("auth.login", user_id=str(user["id"]), details={"email": user["email"]})
|
||||||
|
return {
|
||||||
|
"token": session["token"],
|
||||||
|
"expires_at": session["expires_at"].isoformat(),
|
||||||
|
"user": {
|
||||||
|
"id": str(user["id"]),
|
||||||
|
"email": user["email"],
|
||||||
|
"display_name": user["display_name"],
|
||||||
|
"portal_role": user["portal_role"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def logout(token: str) -> None:
|
||||||
|
session = get_session(token)
|
||||||
|
if session:
|
||||||
|
log_audit(
|
||||||
|
"auth.logout",
|
||||||
|
user_id=str(session["user_id"]),
|
||||||
|
tenant_id=str(session["active_tenant_id"]) if session.get("active_tenant_id") else None,
|
||||||
|
)
|
||||||
|
delete_session(token)
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(x_auth_token: Optional[str] = Header(default=None, alias=AUTH_HEADER)) -> dict[str, Any]:
|
||||||
|
session = get_session(x_auth_token or "")
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=401, detail="Nicht eingeloggt")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def require_portal_admin(session: dict[str, Any] = Depends(require_auth)) -> dict[str, Any]:
|
||||||
|
if session.get("portal_role") != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für Portal-Admins")
|
||||||
|
return session
|
||||||
88
backend/bootstrap.py
Normal file
88
backend/bootstrap.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Bootstrap first admin user, tenant, membership and human actor from environment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
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:
|
||||||
|
return os.getenv(name, "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_admin_if_needed() -> None:
|
||||||
|
if _env("SKIP_BOOTSTRAP").lower() in ("1", "true", "yes"):
|
||||||
|
return
|
||||||
|
|
||||||
|
email = _env("KAIRO_BOOTSTRAP_ADMIN_EMAIL")
|
||||||
|
password = _env("KAIRO_BOOTSTRAP_ADMIN_PASSWORD")
|
||||||
|
if not email or not password:
|
||||||
|
return
|
||||||
|
|
||||||
|
tenant_slug = _env("KAIRO_BOOTSTRAP_TENANT_SLUG") or "default"
|
||||||
|
tenant_name = _env("KAIRO_BOOTSTRAP_TENANT_NAME") or "Default Tenant"
|
||||||
|
display_name = _env("KAIRO_BOOTSTRAP_ADMIN_NAME") or "Kairo Admin"
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT COUNT(*) AS n FROM users")
|
||||||
|
if cur.fetchone()["n"] > 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
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()
|
||||||
|
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")
|
||||||
|
|
@ -21,6 +21,10 @@ else:
|
||||||
print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})")
|
print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})")
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
import bootstrap
|
||||||
|
|
||||||
|
bootstrap.bootstrap_admin_if_needed()
|
||||||
|
|
||||||
allowed_origins = [
|
allowed_origins = [
|
||||||
origin.strip()
|
origin.strip()
|
||||||
for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:3097").split(",")
|
for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:3097").split(",")
|
||||||
|
|
@ -42,6 +46,11 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from routers import auth, me # noqa: E402
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(me.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def api_health():
|
def api_health():
|
||||||
|
|
|
||||||
81
backend/migrations/002_auth_identity_tenant_actor.sql
Normal file
81
backend/migrations/002_auth_identity_tenant_actor.sql
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
-- AP0.2: Auth, Identity, Tenant & Actor Foundation
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(255) NOT NULL,
|
||||||
|
portal_role VARCHAR(32) NOT NULL DEFAULT 'user'
|
||||||
|
CHECK (portal_role IN ('user', 'admin')),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tenant_memberships (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tenant_role VARCHAR(32) NOT NULL DEFAULT 'member'
|
||||||
|
CHECK (tenant_role IN ('owner', 'admin', 'member')),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (tenant_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tenant_memberships_user ON tenant_memberships(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE actors (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
actor_type VARCHAR(32) NOT NULL
|
||||||
|
CHECK (actor_type IN ('human', 'agent', 'working_group', 'external_system')),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (
|
||||||
|
(actor_type = 'human' AND user_id IS NOT NULL)
|
||||||
|
OR (actor_type <> 'human' AND user_id IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_actors_human_user_tenant
|
||||||
|
ON actors(tenant_id, user_id)
|
||||||
|
WHERE actor_type = 'human';
|
||||||
|
|
||||||
|
CREATE INDEX idx_actors_tenant ON actors(tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
active_tenant_id UUID NULL REFERENCES tenants(id) ON DELETE SET NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sessions_token ON sessions(token);
|
||||||
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
tenant_id UUID NULL REFERENCES tenants(id) ON DELETE SET NULL,
|
||||||
|
details JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
|
||||||
|
|
@ -3,3 +3,5 @@ uvicorn[standard]==0.29.0
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
sqlparse>=0.5.0
|
sqlparse>=0.5.0
|
||||||
pydantic==2.7.1
|
pydantic==2.7.1
|
||||||
|
email-validator>=2.1.0
|
||||||
|
bcrypt>=4.1.0
|
||||||
|
|
|
||||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
27
backend/routers/auth.py
Normal file
27
backend/routers/auth.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Auth endpoints: login, logout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auth import login, logout, require_auth
|
||||||
|
from fastapi import APIRouter, Depends, Response
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def auth_login(body: LoginRequest):
|
||||||
|
return login(body.email, body.password)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def auth_logout(response: Response, session: dict = Depends(require_auth)):
|
||||||
|
token = session.get("token")
|
||||||
|
if token:
|
||||||
|
logout(token)
|
||||||
|
return {"ok": True}
|
||||||
49
backend/routers/me.py
Normal file
49
backend/routers/me.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Current user and tenant context endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auth import get_session, require_auth, set_session_active_tenant
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from tenant_context import TenantContext, get_tenant_context, list_user_tenants, require_tenant_context, resolve_tenant_context
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchTenantRequest(BaseModel):
|
||||||
|
tenant_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_me(session: dict = Depends(require_auth)):
|
||||||
|
tenants = list_user_tenants(str(session["user_id"]))
|
||||||
|
return {
|
||||||
|
"id": str(session["user_id"]),
|
||||||
|
"email": session["email"],
|
||||||
|
"display_name": session["display_name"],
|
||||||
|
"portal_role": session["portal_role"],
|
||||||
|
"active_tenant_id": str(session["active_tenant_id"]) if session.get("active_tenant_id") else None,
|
||||||
|
"tenants": tenants,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/context")
|
||||||
|
def get_my_context(ctx: TenantContext = Depends(get_tenant_context)):
|
||||||
|
return ctx.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/context/required")
|
||||||
|
def get_my_context_with_tenant(ctx: TenantContext = Depends(require_tenant_context)):
|
||||||
|
"""Example endpoint requiring an active tenant — for tests and AP0.3 prep."""
|
||||||
|
return ctx.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tenant")
|
||||||
|
def switch_tenant(body: SwitchTenantRequest, session: dict = Depends(require_auth)):
|
||||||
|
ok = set_session_active_tenant(session["token"], body.tenant_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=403, detail="Tenant nicht erlaubt")
|
||||||
|
fresh = get_session(session["token"])
|
||||||
|
if not fresh:
|
||||||
|
raise HTTPException(status_code=401, detail="Session ungültig")
|
||||||
|
return resolve_tenant_context(fresh).to_dict()
|
||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
68
backend/services/actors.py
Normal file
68
backend/services/actors.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Actor creation helpers — User and Actor remain separate concepts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
|
||||||
|
ActorType = Literal["human", "agent", "working_group", "external_system"]
|
||||||
|
|
||||||
|
|
||||||
|
def create_actor(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
actor_type: ActorType,
|
||||||
|
name: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if actor_type == "human" and not user_id:
|
||||||
|
raise ValueError("Human actors require user_id")
|
||||||
|
if actor_type != "human" and user_id:
|
||||||
|
raise ValueError("Non-human actors must not have user_id")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO actors (tenant_id, actor_type, name, user_id)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id, tenant_id, actor_type, name, user_id, is_active, created_at
|
||||||
|
""",
|
||||||
|
(tenant_id, actor_type, name, user_id),
|
||||||
|
)
|
||||||
|
row = dict(cur.fetchone())
|
||||||
|
conn.commit()
|
||||||
|
for key in ("id", "tenant_id", "user_id"):
|
||||||
|
if row.get(key):
|
||||||
|
row[key] = str(row[key])
|
||||||
|
return row
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_human_actor(tenant_id: str, user_id: str) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, tenant_id, actor_type, name, user_id, is_active, created_at
|
||||||
|
FROM actors
|
||||||
|
WHERE tenant_id = %s AND user_id = %s AND actor_type = 'human'
|
||||||
|
""",
|
||||||
|
(tenant_id, user_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
result = dict(row)
|
||||||
|
for key in ("id", "tenant_id", "user_id"):
|
||||||
|
if result.get(key):
|
||||||
|
result[key] = str(result[key])
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
30
backend/services/audit.py
Normal file
30
backend/services/audit.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""Minimal audit logging for critical auth actions (AP0.2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
def log_audit(
|
||||||
|
action: str,
|
||||||
|
*,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
tenant_id: Optional[str] = None,
|
||||||
|
details: Optional[dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_log (action, user_id, tenant_id, details)
|
||||||
|
VALUES (%s, %s, %s, %s::jsonb)
|
||||||
|
""",
|
||||||
|
(action, user_id, tenant_id, json.dumps(details or {})),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
151
backend/tenant_context.py
Normal file
151
backend/tenant_context.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""TenantContext resolution — single layer, not scattered header logic in routers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from db import get_connection
|
||||||
|
from services.actors import get_human_actor
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TenantContext:
|
||||||
|
user_id: str
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
portal_role: str
|
||||||
|
tenant_id: Optional[str]
|
||||||
|
tenant_slug: Optional[str]
|
||||||
|
tenant_name: Optional[str]
|
||||||
|
tenant_role: Optional[str]
|
||||||
|
actor_id: Optional[str]
|
||||||
|
actor_type: Optional[str]
|
||||||
|
session_token: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"email": self.email,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"portal_role": self.portal_role,
|
||||||
|
"tenant": (
|
||||||
|
{
|
||||||
|
"id": self.tenant_id,
|
||||||
|
"slug": self.tenant_slug,
|
||||||
|
"name": self.tenant_name,
|
||||||
|
"role": self.tenant_role,
|
||||||
|
}
|
||||||
|
if self.tenant_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"actor": (
|
||||||
|
{
|
||||||
|
"id": self.actor_id,
|
||||||
|
"type": self.actor_type,
|
||||||
|
}
|
||||||
|
if self.actor_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_user_tenants(user_id: str) -> list[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.slug,
|
||||||
|
t.name,
|
||||||
|
tm.tenant_role,
|
||||||
|
tm.is_active AS membership_active
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = %s AND tm.is_active = TRUE AND t.is_active = TRUE
|
||||||
|
ORDER BY t.name
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
item["id"] = str(item["id"])
|
||||||
|
rows.append(item)
|
||||||
|
return rows
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tenant_context(session: dict[str, Any]) -> TenantContext:
|
||||||
|
user_id = str(session["user_id"])
|
||||||
|
tenant_id = session.get("active_tenant_id")
|
||||||
|
|
||||||
|
if not tenant_id:
|
||||||
|
return TenantContext(
|
||||||
|
user_id=user_id,
|
||||||
|
email=session["email"],
|
||||||
|
display_name=session["display_name"],
|
||||||
|
portal_role=session["portal_role"],
|
||||||
|
tenant_id=None,
|
||||||
|
tenant_slug=None,
|
||||||
|
tenant_name=None,
|
||||||
|
tenant_role=None,
|
||||||
|
actor_id=None,
|
||||||
|
actor_type=None,
|
||||||
|
session_token=session["token"],
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.id, t.slug, t.name, tm.tenant_role
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = %s
|
||||||
|
AND tm.tenant_id = %s
|
||||||
|
AND tm.is_active = TRUE
|
||||||
|
AND t.is_active = TRUE
|
||||||
|
""",
|
||||||
|
(user_id, tenant_id),
|
||||||
|
)
|
||||||
|
membership = cur.fetchone()
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine gültige Tenant-Mitgliedschaft")
|
||||||
|
|
||||||
|
tenant_id_str = str(membership["id"])
|
||||||
|
human = get_human_actor(tenant_id_str, user_id)
|
||||||
|
return TenantContext(
|
||||||
|
user_id=user_id,
|
||||||
|
email=session["email"],
|
||||||
|
display_name=session["display_name"],
|
||||||
|
portal_role=session["portal_role"],
|
||||||
|
tenant_id=tenant_id_str,
|
||||||
|
tenant_slug=membership["slug"],
|
||||||
|
tenant_name=membership["name"],
|
||||||
|
tenant_role=membership["tenant_role"],
|
||||||
|
actor_id=human["id"] if human else None,
|
||||||
|
actor_type=human["actor_type"] if human else None,
|
||||||
|
session_token=session["token"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_context(session: dict[str, Any] = Depends(require_auth)) -> TenantContext:
|
||||||
|
return resolve_tenant_context(session)
|
||||||
|
|
||||||
|
|
||||||
|
def require_tenant_context(session: dict[str, Any] = Depends(require_auth)) -> TenantContext:
|
||||||
|
ctx = resolve_tenant_context(session)
|
||||||
|
if not ctx.tenant_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Kein aktiver Tenant — Tenant wählen")
|
||||||
|
return ctx
|
||||||
49
backend/tests/conftest.py
Normal file
49
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Shared pytest fixtures for AP0.2 auth tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||||
|
os.environ.setdefault("SKIP_BOOTSTRAP", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _db_available() -> bool:
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=os.getenv("DB_HOST", "localhost"),
|
||||||
|
port=os.getenv("DB_PORT", "5432"),
|
||||||
|
dbname=os.getenv("DB_NAME", "kairo_dev"),
|
||||||
|
user=os.getenv("DB_USER", "kairo_dev"),
|
||||||
|
password=os.getenv("DB_PASSWORD", "dev_password"),
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(not _db_available(), reason="PostgreSQL nicht erreichbar")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _run_migrations():
|
||||||
|
import run_migrations
|
||||||
|
|
||||||
|
assert run_migrations.main() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import main as main_module
|
||||||
|
|
||||||
|
importlib.reload(main_module)
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
return TestClient(main_module.app)
|
||||||
91
backend/tests/factories.py
Normal file
91
backend/tests/factories.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Test data factories (PostgreSQL required)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from auth import hash_password
|
||||||
|
from db import get_connection
|
||||||
|
from services.actors import create_actor
|
||||||
|
|
||||||
|
|
||||||
|
def create_tenant(*, slug: str | None = None, name: str = "Test Tenant") -> str:
|
||||||
|
slug = slug or f"t-{uuid.uuid4().hex[:10]}"
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO tenants (slug, name) VALUES (%s, %s) RETURNING id",
|
||||||
|
(slug, name),
|
||||||
|
)
|
||||||
|
tenant_id = str(cur.fetchone()[0])
|
||||||
|
conn.commit()
|
||||||
|
return tenant_id
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
*,
|
||||||
|
email: str | None = None,
|
||||||
|
password: str = "test-password-123",
|
||||||
|
display_name: str = "Test User",
|
||||||
|
portal_role: str = "user",
|
||||||
|
) -> dict:
|
||||||
|
email = email or f"user-{uuid.uuid4().hex[:8]}@test.local"
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (email, password_hash, display_name, portal_role)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id, email, display_name, portal_role
|
||||||
|
""",
|
||||||
|
(email, hash_password(password), display_name, portal_role),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"id": str(row[0]),
|
||||||
|
"email": row[1],
|
||||||
|
"display_name": row[2],
|
||||||
|
"portal_role": row[3],
|
||||||
|
"password": password,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def add_membership(*, tenant_id: str, user_id: str, tenant_role: str = "member") -> None:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (tenant_id, user_id) DO UPDATE SET tenant_role = EXCLUDED.tenant_role
|
||||||
|
""",
|
||||||
|
(tenant_id, user_id, tenant_role),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def provision_user_in_tenant(
|
||||||
|
*,
|
||||||
|
tenant_role: str = "member",
|
||||||
|
portal_role: str = "user",
|
||||||
|
) -> dict:
|
||||||
|
tenant_id = create_tenant()
|
||||||
|
user = create_user(portal_role=portal_role)
|
||||||
|
add_membership(tenant_id=tenant_id, user_id=user["id"], tenant_role=tenant_role)
|
||||||
|
actor = create_actor(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
actor_type="human",
|
||||||
|
name=user["display_name"],
|
||||||
|
user_id=user["id"],
|
||||||
|
)
|
||||||
|
return {**user, "tenant_id": tenant_id, "actor_id": actor["id"]}
|
||||||
80
backend/tests/test_auth.py
Normal file
80
backend/tests/test_auth.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Auth endpoint and session tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auth import AUTH_HEADER, get_session
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_logout_and_session(client):
|
||||||
|
user = provision_user_in_tenant()
|
||||||
|
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": user["email"], "password": user["password"]},
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
payload = login.json()
|
||||||
|
assert "token" in payload
|
||||||
|
assert payload["user"]["email"] == user["email"]
|
||||||
|
token = payload["token"]
|
||||||
|
|
||||||
|
me = client.get("/api/me", headers={AUTH_HEADER: token})
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.json()["email"] == user["email"]
|
||||||
|
assert me.json()["active_tenant_id"] == user["tenant_id"]
|
||||||
|
|
||||||
|
logout = client.post("/api/auth/logout", headers={AUTH_HEADER: token})
|
||||||
|
assert logout.status_code == 200
|
||||||
|
assert get_session(token) is None
|
||||||
|
|
||||||
|
me_after = client.get("/api/me", headers={AUTH_HEADER: token})
|
||||||
|
assert me_after.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_invalid_password(client):
|
||||||
|
user = provision_user_in_tenant()
|
||||||
|
response = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": user["email"], "password": "wrong-password"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_requires_auth(client):
|
||||||
|
assert client.get("/api/me").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_context_returns_tenant_and_actor(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": user["email"], "password": user["password"]},
|
||||||
|
)
|
||||||
|
token = login.json()["token"]
|
||||||
|
|
||||||
|
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token})
|
||||||
|
assert ctx.status_code == 200
|
||||||
|
body = ctx.json()
|
||||||
|
assert body["tenant"]["id"] == user["tenant_id"]
|
||||||
|
assert body["tenant"]["role"] == "admin"
|
||||||
|
assert body["actor"]["id"] == user["actor_id"]
|
||||||
|
assert body["actor"]["type"] == "human"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_switch_rejects_invalid_membership(client):
|
||||||
|
user = provision_user_in_tenant()
|
||||||
|
other = provision_user_in_tenant()
|
||||||
|
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": user["email"], "password": user["password"]},
|
||||||
|
)
|
||||||
|
token = login.json()["token"]
|
||||||
|
|
||||||
|
switch = client.post(
|
||||||
|
"/api/me/tenant",
|
||||||
|
headers={AUTH_HEADER: token},
|
||||||
|
json={"tenant_id": other["tenant_id"]},
|
||||||
|
)
|
||||||
|
assert switch.status_code == 403
|
||||||
|
|
@ -7,6 +7,7 @@ from fastapi.testclient import TestClient
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client(monkeypatch):
|
def client(monkeypatch):
|
||||||
monkeypatch.setenv("SKIP_DB_MIGRATE", "1")
|
monkeypatch.setenv("SKIP_DB_MIGRATE", "1")
|
||||||
|
monkeypatch.setenv("SKIP_BOOTSTRAP", "1")
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import main as main_module
|
import main as main_module
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ def test_migration_runner_finds_migrations():
|
||||||
files = run_migrations.migration_files(migrations_dir)
|
files = run_migrations.migration_files(migrations_dir)
|
||||||
names = [name for name, _ in files]
|
names = [name for name, _ in files]
|
||||||
assert "001_init_core" in names
|
assert "001_init_core" in names
|
||||||
|
assert "002_auth_identity_tenant_actor" in names
|
||||||
|
|
||||||
|
|
||||||
def test_migration_runner_is_idempotent():
|
def test_migration_runner_is_idempotent():
|
||||||
|
|
@ -42,6 +43,7 @@ def test_migration_runner_is_idempotent():
|
||||||
executed = run_migrations.executed_migrations(conn)
|
executed = run_migrations.executed_migrations(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
assert "001_init_core" in executed
|
assert "001_init_core" in executed
|
||||||
|
assert "002_auth_identity_tenant_actor" in executed
|
||||||
|
|
||||||
|
|
||||||
def test_core_table_exists():
|
def test_core_table_exists():
|
||||||
|
|
|
||||||
46
backend/tests/test_tenant_actor.py
Normal file
46
backend/tests/test_tenant_actor.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Actor and TenantContext tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auth import AUTH_HEADER
|
||||||
|
from services.actors import create_actor
|
||||||
|
from tests.factories import create_tenant, provision_user_in_tenant
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_actor_linked_to_user(client):
|
||||||
|
user = provision_user_in_tenant()
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": user["email"], "password": user["password"]},
|
||||||
|
)
|
||||||
|
token = login.json()["token"]
|
||||||
|
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token}).json()
|
||||||
|
assert ctx["actor"]["type"] == "human"
|
||||||
|
assert ctx["actor"]["id"] == user["actor_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_actor_without_user(client):
|
||||||
|
tenant_id = create_tenant()
|
||||||
|
agent = create_actor(tenant_id=tenant_id, actor_type="agent", name="Planner Agent")
|
||||||
|
assert agent["user_id"] is None
|
||||||
|
assert agent["actor_type"] == "agent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_working_group_and_external_system_actors(client):
|
||||||
|
tenant_id = create_tenant()
|
||||||
|
wg = create_actor(tenant_id=tenant_id, actor_type="working_group", name="Steering Group")
|
||||||
|
ext = create_actor(tenant_id=tenant_id, actor_type="external_system", name="Jira")
|
||||||
|
assert wg["actor_type"] == "working_group"
|
||||||
|
assert ext["actor_type"] == "external_system"
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_role_separate_from_tenant_role(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member", portal_role="admin")
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": user["email"], "password": user["password"]},
|
||||||
|
)
|
||||||
|
token = login.json()["token"]
|
||||||
|
ctx = client.get("/api/me/context", headers={AUTH_HEADER: token}).json()
|
||||||
|
assert ctx["portal_role"] == "admin"
|
||||||
|
assert ctx["tenant"]["role"] == "member"
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.1.0-ap0.1"
|
APP_VERSION = "0.2.0-ap0.2"
|
||||||
DB_SCHEMA_VERSION = "001"
|
DB_SCHEMA_VERSION = "002"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ services:
|
||||||
APP_URL: "${APP_URL:-https://dev.kairo.jinkendo.de}"
|
APP_URL: "${APP_URL:-https://dev.kairo.jinkendo.de}"
|
||||||
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.kairo.jinkendo.de,http://192.168.2.49:3097,http://localhost:3097}"
|
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.kairo.jinkendo.de,http://192.168.2.49:3097,http://localhost:3097}"
|
||||||
ENVIRONMENT: "${ENVIRONMENT:-development}"
|
ENVIRONMENT: "${ENVIRONMENT:-development}"
|
||||||
|
SESSION_DAYS: "${SESSION_DAYS:-30}"
|
||||||
|
KAIRO_BOOTSTRAP_ADMIN_EMAIL: "${KAIRO_BOOTSTRAP_ADMIN_EMAIL:-}"
|
||||||
|
KAIRO_BOOTSTRAP_ADMIN_PASSWORD: "${KAIRO_BOOTSTRAP_ADMIN_PASSWORD:-}"
|
||||||
|
KAIRO_BOOTSTRAP_TENANT_SLUG: "${KAIRO_BOOTSTRAP_TENANT_SLUG:-default}"
|
||||||
|
KAIRO_BOOTSTRAP_TENANT_NAME: "${KAIRO_BOOTSTRAP_TENANT_NAME:-Default Tenant}"
|
||||||
ports:
|
ports:
|
||||||
- "${KAIRO_BACKEND_PORT:-8097}:8000"
|
- "${KAIRO_BACKEND_PORT:-8097}:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ services:
|
||||||
APP_URL: "${APP_URL:-https://kairo.jinkendo.de}"
|
APP_URL: "${APP_URL:-https://kairo.jinkendo.de}"
|
||||||
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kairo.jinkendo.de}"
|
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kairo.jinkendo.de}"
|
||||||
ENVIRONMENT: "${ENVIRONMENT:-production}"
|
ENVIRONMENT: "${ENVIRONMENT:-production}"
|
||||||
|
SESSION_DAYS: "${SESSION_DAYS:-30}"
|
||||||
|
KAIRO_BOOTSTRAP_ADMIN_EMAIL: "${KAIRO_BOOTSTRAP_ADMIN_EMAIL:-}"
|
||||||
|
KAIRO_BOOTSTRAP_ADMIN_PASSWORD: "${KAIRO_BOOTSTRAP_ADMIN_PASSWORD:-}"
|
||||||
ports:
|
ports:
|
||||||
- "${KAIRO_BACKEND_PORT:-8004}:8000"
|
- "${KAIRO_BACKEND_PORT:-8004}:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
121
docs/sprints/Sprint0_AP0_1_Completion_Report_v0.1.md
Normal file
121
docs/sprints/Sprint0_AP0_1_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# AP0.1 – Abschlussbericht Projektgrundlage
|
||||||
|
|
||||||
|
**Status:** abgeschlossen
|
||||||
|
**Stand:** 2026-07-04
|
||||||
|
**Branch:** `develop` (`b5efdbb`) · Prod-Stand identisch (`main` `7b96bab`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umgesetzt
|
||||||
|
|
||||||
|
| Anforderung (AP0.1) | Umsetzung |
|
||||||
|
|---------------------|-----------|
|
||||||
|
| Backend-Skeleton + FastAPI | `backend/main.py`, `backend/Dockerfile` |
|
||||||
|
| Health Endpoint `GET /api/health` | Liefert `status`, `app`, `db` (+ `version`, `schema`) |
|
||||||
|
| PostgreSQL-Anbindung | `backend/db.py`, Compose-Services |
|
||||||
|
| Docker Compose lokal | `docker-compose.dev-env.yml`, `docker-compose.yml` |
|
||||||
|
| Migrationsverzeichnis | `backend/migrations/` |
|
||||||
|
| Migration Runner + `schema_migrations` | `backend/run_migrations.py` |
|
||||||
|
| Erste Migration `001_init_core.sql` | `kairo_app_meta` (Schema-Phase AP0.1) |
|
||||||
|
| Testbasis | `backend/tests/` (pytest) |
|
||||||
|
| Frontend optional minimal | React/Vite-SPA + nginx-Proxy (`frontend/`) |
|
||||||
|
| Verzeichnisse `infra/`, `scripts/`, `tests/` | vorhanden |
|
||||||
|
| README „Local Development“ | `README.md` § Local Development |
|
||||||
|
| Kein Sprint-1-Fachscope | Keine Tenant/User/Actor-Logik, nur Meta-Tabelle |
|
||||||
|
|
||||||
|
**Zusätzlich (über AP0.1-Minimum, bewusst Sprint-0-Infrastruktur):**
|
||||||
|
|
||||||
|
- Gitea Actions: `deploy-dev.yml`, `deploy-prod.yml`, `test.yml`
|
||||||
|
- CI: pytest im Container, compose-smoke (PR), k6-Baseline, Playwright smoke
|
||||||
|
- Deployment-Doku: `docs/DEPLOYMENT.md`
|
||||||
|
- Dev/Prod auf Raspberry Pi (Ports 8097/3097 bzw. 8004/3004)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### Unit / Integration (pytest)
|
||||||
|
|
||||||
|
| Test | Datei | Status |
|
||||||
|
|------|-------|--------|
|
||||||
|
| App importierbar | `test_health.py::test_app_importable` | ✓ |
|
||||||
|
| Health Endpoint | `test_health.py::test_health_endpoint` | ✓ |
|
||||||
|
| Runner findet Migrationen | `test_migrations.py::test_migration_runner_finds_migrations` | ✓ |
|
||||||
|
| Runner trägt Migration ein + idempotent | `test_migrations.py::test_migration_runner_is_idempotent` | ✓ |
|
||||||
|
| Core-Tabelle existiert | `test_migrations.py::test_core_table_exists` | ✓ |
|
||||||
|
|
||||||
|
### CI (Self-hosted Runner, Dev)
|
||||||
|
|
||||||
|
| Job | Status (2026-07-04) |
|
||||||
|
|-----|---------------------|
|
||||||
|
| pytest-backend | ✓ |
|
||||||
|
| k6 /api/health Baseline | ✓ (nach K6_CI-Anpassung) |
|
||||||
|
| playwright-smoke | ✓ |
|
||||||
|
| lint-backend | ✓ |
|
||||||
|
| compose-smoke | ✓ (PR, isolierte CI-Ports) |
|
||||||
|
|
||||||
|
### Prod
|
||||||
|
|
||||||
|
| Schritt | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| Deploy Production (manueller Merge `develop` → `main`) | ✓ |
|
||||||
|
| Test Suite Prod-Trigger | Fix auf `develop` (`b5efdbb`): Push auf `main` startet Tests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lokaler Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
docker compose -f docker-compose.dev-env.yml up --build
|
||||||
|
|
||||||
|
curl http://localhost:8097/api/health
|
||||||
|
curl http://localhost:3097/api/health
|
||||||
|
|
||||||
|
docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt
|
||||||
|
docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrationen laufen beim Backend-Start automatisch (`run_migrations.main()` in `main.py`, überspringbar via `SKIP_DB_MIGRATE=1` für Tests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Fragen
|
||||||
|
|
||||||
|
1. **Reverse Proxy** — Domains `kairo.jinkendo.de` / `dev.kairo.jinkendo.de` noch nicht eingerichtet; CI und Deploy nutzen localhost (bewusst ausreichend für AP0.1).
|
||||||
|
2. **Prod `.env`** — Muss auf dem Pi unter `/home/lars/docker/kairo/.env` mit sicherem `DB_PASSWORD` liegen (Deploy klont Repo, erzeugt `.env` nicht).
|
||||||
|
3. **README „Startzustand“** — Abschnitt oben in `README.md` beschreibt noch „kein Anwendungscode“; AP0.1-Tabelle unten ist aktuell — Kosmetik-Update empfohlen.
|
||||||
|
4. **`workflow_run`-Trigger** — In Gitea/act unzuverlässig; Prod-Tests hängen an Push `main` (Fix `b5efdbb`). Merge/Deploy bleibt manuell durch Product Owner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abweichungen von der Spezifikation
|
||||||
|
|
||||||
|
| Spezifikation | Ist | Bewertung |
|
||||||
|
|---------------|-----|-----------|
|
||||||
|
| Health nur `status`, `app`, `db` | zusätzlich `version`, `schema` | Erweiterung, abwärtskompatibel |
|
||||||
|
| `db: "ok"` | auch `"error"` bei DB-Ausfall, dann `status: "degraded"` | sinnvoller Betriebszustand |
|
||||||
|
| Frontend optional | minimal umgesetzt (Health-Anzeige + nginx `/api`-Proxy) | im Scope |
|
||||||
|
| Offene Entscheidungen dokumentiert | in diesem Bericht + `docs/DEPLOYMENT.md`, kein separates ADR für AP0.1 | ausreichend für Abschluss |
|
||||||
|
|
||||||
|
Keine fachliche Sprint-1-Logik vorgezogen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfehlung für AP0.2
|
||||||
|
|
||||||
|
**Nächster Auftrag:** AP0.2 – Tenant, User, Actor (`Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` § AP0.2)
|
||||||
|
|
||||||
|
Vorgehen:
|
||||||
|
|
||||||
|
1. Migration `002_tenant_user_actor.sql` — Tabellen laut Foundation, weiterhin ohne Vorhaben/Projekt-Domäne.
|
||||||
|
2. Seed für lokalen Admin-User (Dev only).
|
||||||
|
3. pytest für Migration + minimale Repository/Model-Schicht.
|
||||||
|
4. Health/version bei Schema-Bump (`DB_SCHEMA_VERSION` → `002`).
|
||||||
|
5. README-Startzustand bereinigen; optional Reverse Proxy parallel (nicht blockierend).
|
||||||
|
|
||||||
|
AP0.1 nicht erneut anfassen, außer README-Kosmetik oder Proxy-Wunsch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Erstellt im Rahmen Sprint 0 – AP0.1 Akzeptanz.*
|
||||||
154
docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md
Normal file
154
docs/sprints/Sprint0_AP0_2_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# AP0.2 – Abschlussbericht Auth, Identity, Tenant & Actor Foundation
|
||||||
|
|
||||||
|
**Status:** abgeschlossen (Implementierung)
|
||||||
|
**Stand:** 2026-07-04
|
||||||
|
**Branch:** `develop` (noch nicht auf `main` gemergt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Umgesetzte Dateien
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|--------|
|
||||||
|
| `backend/migrations/002_auth_identity_tenant_actor.sql` | Schema User, Session, Tenant, Membership, Actor, Audit |
|
||||||
|
| `backend/auth.py` | bcrypt, Sessions, login/logout, `require_auth`, `require_portal_admin` |
|
||||||
|
| `backend/tenant_context.py` | `TenantContext`, `get_tenant_context`, `require_tenant_context` |
|
||||||
|
| `backend/bootstrap.py` | Env-basierter Admin/Tenant/ Actor-Seed |
|
||||||
|
| `backend/services/audit.py` | Audit-Log für Auth-Aktionen |
|
||||||
|
| `backend/services/actors.py` | Actor-Erzeugung + Human-Lookup |
|
||||||
|
| `backend/routers/auth.py` | Login/Logout |
|
||||||
|
| `backend/routers/me.py` | `/api/me`, `/api/me/context`, Tenant-Wechsel |
|
||||||
|
| `backend/main.py` | Router, Bootstrap nach Migrationen |
|
||||||
|
| `backend/tests/conftest.py` | DB-Fixtures |
|
||||||
|
| `backend/tests/factories.py` | Testdaten |
|
||||||
|
| `backend/tests/test_auth.py` | Login, Logout, Session, Context |
|
||||||
|
| `backend/tests/test_tenant_actor.py` | Actor-Typen, Rollen-Trennung |
|
||||||
|
| `backend/version.py` | `0.2.0-ap0.2`, Schema `002` |
|
||||||
|
| `docker-compose.dev-env.yml`, `docker-compose.yml` | Bootstrap/Session-Env |
|
||||||
|
| `.env.example`, `README.md` | Doku |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Neue Migrationen
|
||||||
|
|
||||||
|
- **`002_auth_identity_tenant_actor.sql`**
|
||||||
|
- `users` — E-Mail, bcrypt-Hash, `portal_role` (`user` \| `admin`)
|
||||||
|
- `sessions` — Token, `user_id`, `active_tenant_id`, Ablauf
|
||||||
|
- `tenants` — Slug, Name, aktiv
|
||||||
|
- `tenant_memberships` — `tenant_role` (`owner` \| `admin` \| `member`)
|
||||||
|
- `actors` — `human`, `agent`, `working_group`, `external_system`
|
||||||
|
- `audit_log` — Auth-Ereignisse
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Neue Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Methode | Auth |
|
||||||
|
|----------|---------|------|
|
||||||
|
| `/api/auth/login` | POST | — |
|
||||||
|
| `/api/auth/logout` | POST | `X-Auth-Token` |
|
||||||
|
| `/api/me` | GET | `X-Auth-Token` |
|
||||||
|
| `/api/me/context` | GET | `X-Auth-Token` |
|
||||||
|
| `/api/me/context/required` | GET | `X-Auth-Token` + aktiver Tenant |
|
||||||
|
| `/api/me/tenant` | POST | `X-Auth-Token` |
|
||||||
|
|
||||||
|
OpenAPI: `/api/docs` (Dev)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Auth-Fluss
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login { email, password }
|
||||||
|
→ bcrypt verify
|
||||||
|
→ INSERT sessions (opaque token, expires_at, active_tenant_id = erste Membership)
|
||||||
|
→ audit: auth.login
|
||||||
|
→ Response: { token, expires_at, user }
|
||||||
|
|
||||||
|
Request mit Header X-Auth-Token
|
||||||
|
→ get_session(token) JOIN users
|
||||||
|
→ require_auth → session dict (user_id aus DB, nie aus Client-Header)
|
||||||
|
|
||||||
|
POST /api/auth/logout
|
||||||
|
→ DELETE session
|
||||||
|
→ audit: auth.logout
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. TenantContext-Auflösung
|
||||||
|
|
||||||
|
```
|
||||||
|
require_auth → session (user_id, active_tenant_id, portal_role, …)
|
||||||
|
→ tenant_memberships + tenants prüfen (nur aktive)
|
||||||
|
→ human actor: actors WHERE tenant_id + user_id + type=human
|
||||||
|
→ TenantContext(
|
||||||
|
portal_role, # Plattform
|
||||||
|
tenant_role, # Mandant
|
||||||
|
tenant_id/slug/name,
|
||||||
|
actor_id/type
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tenant-Wechsel: `POST /api/me/tenant` aktualisiert `sessions.active_tenant_id` nur bei gültiger Membership.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tests und Testergebnis
|
||||||
|
|
||||||
|
| Testdatei | Abdeckung |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `test_auth.py` | Login, Logout, Session ungültig, `/api/me`, Context, Tenant-Wechsel verweigert |
|
||||||
|
| `test_tenant_actor.py` | Human↔User, Agent/WG/External ohne User, Portal- vs. Tenant-Rolle |
|
||||||
|
| `test_migrations.py` | Migration 002 erkannt + idempotent |
|
||||||
|
|
||||||
|
Lokal ausführen (Backend-Container):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt
|
||||||
|
docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv
|
||||||
|
```
|
||||||
|
|
||||||
|
*(In dieser Session kein Docker lokal — Verifikation über CI nach Push auf `develop`.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Abweichungen von den Designprinzipien
|
||||||
|
|
||||||
|
| Prinzip | Abweichung | Begründung |
|
||||||
|
|---------|------------|------------|
|
||||||
|
| Mitai `profiles` | Kairo nutzt `users` | Klarere Trennung User ≠ Actor; kein Multi-Profil-Legacy |
|
||||||
|
| Legacy SHA256-Upgrade | nur bcrypt | Grüne Wiese AP0.2 |
|
||||||
|
| `require_auth_flexible` (Query-Token) | nicht implementiert | Nicht AP0.2-Scope; SSE/Download später |
|
||||||
|
| Account-Lifecycle-Gates | fehlen | AP0.3+ |
|
||||||
|
| Capabilities in TenantContext | leer / nicht modelliert | Bewusst Nicht-Scope |
|
||||||
|
|
||||||
|
Eingehalten: Server-Sessions, `X-Auth-Token`, `Depends(require_auth)` separat, user_id aus Session, Portal- vs. Tenant-Rolle getrennt, TenantContext als eigene Schicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Offene Entscheidungen
|
||||||
|
|
||||||
|
1. **Passwort-Policy / Rate-Limiting** für Login — noch nicht implementiert.
|
||||||
|
2. **Session-Invalidierung** bei Passwort-Reset (Feature kommt später).
|
||||||
|
3. **Tenant-Erstellung via API** — aktuell nur Bootstrap/DB; Admin-API in späterem AP.
|
||||||
|
4. **Frontend Auth-UI** — AP0.2 backend-only; SPA-Anbindung folgt.
|
||||||
|
5. **Prod-Bootstrap** — `KAIRO_BOOTSTRAP_*` in Prod `.env` setzen oder einmalig manuell seeden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Empfehlung für AP0.3
|
||||||
|
|
||||||
|
Laut Foundation-Dokument ursprünglich „Capabilities & Rights Registry“ — der User-Auftrag kombinierte Auth+Tenant bereits in AP0.2.
|
||||||
|
|
||||||
|
**AP0.3 Vorschlag:**
|
||||||
|
|
||||||
|
- Capability/Rights Registry (DB + Sync)
|
||||||
|
- `require_capability()` Dependency
|
||||||
|
- TenantContext um `capabilities: list[str]` erweitern
|
||||||
|
- Keine Feature-Limits / Billing
|
||||||
|
- Optional: Frontend Login-Form + Token-Speicherung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Erstellt im Rahmen Sprint 0 – AP0.2.*
|
||||||
Loading…
Reference in New Issue
Block a user