feat: enhance API and profile management with environment configurations
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s

- Added functions to determine production environment and OpenAPI exposure settings, improving API documentation control.
- Updated FastAPI initialization to conditionally set OpenAPI and documentation URLs based on environment variables.
- Refactored health check response to limit detail exposure in production environments, enhancing security.
- Streamlined profile management by removing legacy ID retrieval and ensuring session-based profile access for security improvements.
This commit is contained in:
Lars 2026-05-07 10:40:10 +02:00
parent 18fa4de055
commit c2d9eac151
12 changed files with 351 additions and 57 deletions

View File

@ -29,6 +29,12 @@ Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Aud
---
### Changelog (Fortführung)
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
---
### Hinweis `GET /training-units`
Kein impliziter Filter nach `effective_club_id` (Multi-Verein-Kalender); bei Bedarf `club_id` Query setzen.

View File

@ -0,0 +1,82 @@
# Produktionsreife: Audit-Ergebnis & Umsetzungsplan (Stand 2026-05-07)
**Zweck:** Einheitliche Referenz gegen **Drift** zwischen Sicherheits-/Betriebsanforderungen und Code.
**Bezug:** Zugriffsschicht `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, Cursor-Regel `.cursor/rules/access-layer.mdc`.
---
## Teil A — Audit-Ergebnis (Kurzfassung)
### A.1 Kritisch / Hoch (behoben oder geplant)
| ID | Befund | Status (2026-05-07) |
|----|--------|----------------------|
| SEC-01 | `GET /api/profile` (Legacy): ohne Header wurde das **erste Profil der DB** geliefert → IDOR / Datenleck | **Behoben:** Profil immer aus Session |
| SEC-02 | OpenAPI `/docs`, `/redoc`, `/openapi.json` in Produktion exponiert | **Behoben:** bei `ENVIRONMENT=production` aus; Override `PUBLIC_OPENAPI=1` |
| SEC-03 | `/api/health/ready` mit Tabellendetails / Migrationszähler öffentlich | **Behoben:** in Prod nur kompakte Antwort; Detail via `HEALTH_READY_PUBLIC_DETAIL=1` |
| SEC-04 | Statische Auslieferung `/media` ohne Auth (Link-Leak) | **Geplant** (Phase 2): geschützter Download oder signierte URLs |
| OPS-01 | PostgreSQL-Hostport in `docker-compose.yml` | **Dokumentiert:** nur intern binden / Firewall |
| OPS-02 | Fehlende Security-Header (CSP, X-Frame-Options, …) | **Teilweise:** nginx-Basis-Header ergänzt |
### A.2 Mittel / Konsistenz
| ID | Befund | Status |
|----|--------|--------|
| CON-01 | Frontend: direktes `fetch('/api/profiles')` ohne zentralen Client | **Behoben** (AdminCatalogsPage → `api.listProfiles`) |
| CON-02 | `ProfileContext.jsx` (unused): falsches Token-Feld / `session` | **Behoben** (`user` + `getCurrentProfile` / `listProfiles`) |
| CON-03 | Sparten (`division`) vs. Zielbild ACCESS_LAYER | **Offen** (Roadmap Stufe D) |
### A.3 Niedrig
| ID | Befund | Status |
|----|--------|--------|
| MISC-01 | `auth.py` Debug-`print` beim Import | **Behoben** (entfernt) |
| MISC-02 | `delete_profile`: DELETE auf Mitai-Tabellen schlägt fehl, wenn Tabelle fehlt | **Behoben:** nur löschen, wenn Tabelle existiert |
---
## Teil B — Priorisierter Umsetzungsplan
### Phase 1 — Sofort (Beta-Blocker) ✅ umgesetzt in Repo-Stand
1. Legacy-Endpunkte `/api/profile` GET/PUT: **nur Session-`profile_id`**, kein „erstes Profil“-Fallback.
2. OpenAPI in Produktion deaktivieren; opt-in für Notfall-Debugging.
3. Readiness-Endpoint in Prod entschärfen; operatives Detail per Env.
4. Automatisierte **Security-Release-Tests** (`tests/test_security_release.py`) + Script `scripts/security_release_checks.py` in CI.
5. Profil-Löschung: optionale Tabellen nur bei Existenz.
6. Frontend: Admin-Kataloge Profilliste über `api.js`.
### Phase 2 — Kurzfristig (vor breitem Go-Live)
1. **Medien:** Zugriff an Übungs-/Governance-Rechte koppeln (kein anonymes `/media/...` für sensible Objekte).
2. **Mitgliederverzeichnis:** E-Mail-Sichtbarkeit rollenbasierend oder Richtlinie/DSE.
3. **DB-Port:** Prod-Compose ohne öffentliche DB-Port-Publikation oder strikte Bindung.
4. `GET /api/maturity-models/{id}`: Leserechte mit Admin/Matrix-Politik abstimmen.
### Phase 3 — Mittelfristig
1. CSP und restliche Header fein abstimmen (PWA, eingebettete Medien).
2. `ACCESS_LAYER_ENDPOINT_AUDIT.md` bei Änderungen an Tenant/Governance aktualisieren (Arbeitsdisziplin).
3. Division-/Sparten-Durchsetzung laut ARCHITEKTUR-Roadmap.
---
## Teil C — CI / Qualitätssicherung
- **ACCESS_LAYER_STRICT:** `scripts/check_access_layer_hints.py` (unverändert Pflicht).
- **Security-Release:** `python scripts/security_release_checks.py` (pytest-Modul `test_security_release`).
- **Regression Profil:** `tests/test_profiles_read_access.py` (Legacy-Route).
---
## Teil D — Umgebungsvariablen (Referenz)
| Variable | Bedeutung |
|----------|-----------|
| `ENVIRONMENT` | `production` / `prod` → OpenAPI aus, Health-Ready kompakt |
| `PUBLIC_OPENAPI` | `1` / `true` → OpenAPI trotz Prod einschalten (nur Debugging) |
| `HEALTH_READY_PUBLIC_DETAIL` | `1` / `true` → volle Ready-JSON inkl. Tabellenliste in Prod |
---
**Pflege:** Nach jeder relevanten Änderung **Status-Spalte** und ggf. **Phase** aktualisieren; bei neuem Audit Datum im Titel anpassen oder Abschnitt „Changelog“ ergänzen.

View File

@ -38,6 +38,7 @@ jobs:
pip install -r /app/requirements-dev.txt &&
cd /app &&
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
python scripts/security_release_checks.py &&
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
"

View File

@ -200,6 +200,10 @@ ALLOWED_ORIGINS=https://shinkan.jinkendo.de
MEDIA_DIR=/app/media
```
## Produktions-/Sicherheitsaudit (Drift vermeiden)
Aktuelle Befunde und Umsetzungsstände: `.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md` (Fortlaufend pflegen.)
## Kritische Regeln für Claude Code
### Must-Do:

View File

@ -13,8 +13,6 @@ import bcrypt
from db import get_db, get_cursor
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
def hash_pin(pin: str) -> str:
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""

View File

@ -18,6 +18,20 @@ from slowapi.errors import RateLimitExceeded
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
def _is_production_environment() -> bool:
return os.getenv("ENVIRONMENT", "development").strip().lower() in ("production", "prod")
def _public_openapi_enabled() -> bool:
return os.getenv("PUBLIC_OPENAPI", "").strip().lower() in ("1", "true", "yes")
def _health_ready_public_detail_enabled() -> bool:
"""In Prod standardmäßig keine Tabellen-/Migrations-Details (Information Disclosure)."""
return os.getenv("HEALTH_READY_PUBLIC_DETAIL", "").strip().lower() in ("1", "true", "yes")
# Run database migrations before API start — halbes Schema ist schlimmer als kein Start
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
@ -39,11 +53,20 @@ else:
from routers.auth import limiter as auth_rate_limiter
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
_expose_docs = (not _is_production_environment()) or _public_openapi_enabled()
_openapi_url = "/openapi.json" if _expose_docs else None
_docs_url = "/docs" if _expose_docs else None
_redoc_url = "/redoc" if _expose_docs else None
# Initialize FastAPI app
app = FastAPI(
title="Shinkan Jinkendo API",
description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung",
version=APP_VERSION
version=APP_VERSION,
openapi_url=_openapi_url,
docs_url=_docs_url,
redoc_url=_redoc_url,
)
# SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py)
@ -132,7 +155,7 @@ def health_ready():
migration_count = 0
complete = bool(err is None and all(tables.get(t) for t in REQUIRED))
return {
body = {
"status": "ready" if complete else "degraded",
"database": err is None,
"detail": err,
@ -140,18 +163,27 @@ def health_ready():
"tables": tables,
"schema_migrations_count": migration_count,
}
if _is_production_environment() and not _health_ready_public_detail_enabled():
return {
"status": body["status"],
"database": body["database"],
"schema_complete": body["schema_complete"],
}
return body
# Root Endpoint
@app.get("/")
def read_root():
"""Root endpoint - API info"""
return {
out = {
"app": "Shinkan Jinkendo API",
"version": APP_VERSION,
"docs": "/docs",
"health": "/health"
"health": "/health",
}
if _expose_docs:
out["docs"] = "/docs"
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin

View File

@ -22,19 +22,6 @@ router = APIRouter(prefix="/api", tags=["profiles"])
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
# ── Helper ────────────────────────────────────────────────────────────────────
def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
"""Get profile_id - from header for legacy endpoints."""
if x_profile_id:
return x_profile_id
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1")
row = cur.fetchone()
if row: return row['id']
raise HTTPException(400, "Kein Profil gefunden")
# ── Current User Profile ──────────────────────────────────────────────────────
@router.get("/profiles/me")
def get_current_profile(
@ -310,26 +297,45 @@ def delete_profile(pid: str, session=Depends(require_auth)):
cur.execute("SELECT COUNT(*) as count FROM profiles")
count = cur.fetchone()['count']
if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']:
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
# Mitai-Überbleibsel: nur löschen, wenn die Tabelle im Schema existiert (Shinkan-DB ohne diese Tabellen).
_optional_mitai_tables = (
"weight_log",
"circumference_log",
"caliper_log",
"nutrition_log",
"activity_log",
"ai_insights",
)
for table in _optional_mitai_tables:
cur.execute(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS t_exists
""",
(table,),
)
ex = cur.fetchone()
if ex and next(iter(ex.values())):
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
return {"ok": True}
# ── Current User Profile ──────────────────────────────────────────────────────
@router.get("/profile")
def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
"""Legacy endpoint returns active profile."""
pid = get_pid(x_profile_id)
def get_active_profile(session: dict = Depends(require_auth)):
"""Legacy-Alias für das eingeloggte Profil — immer Session, kein X-Profile-Id (SECURITY: kein IDOR)."""
pid = str(session["profile_id"])
return profile_document(pid)
@router.put("/profile")
def update_active_profile(
p: ProfileUpdate,
x_profile_id: Optional[str] = Header(default=None),
tenant: TenantContext = Depends(get_tenant_context),
):
"""Update current user's profile."""
pid = get_pid(x_profile_id)
"""Profil des eingeloggten Nutzers aktualisieren — dieselbe Quelle wie GET /profile."""
pid = str(tenant.profile_id)
return _run_profile_update(pid, p, tenant)

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
CI-Sicherheitschecks: schlanke pytest-Sammlung ohne Integrations-DB.
Repo-Root ist egal arbeitet relativ zu diesem Script (backend/).
Usage (aus backend/):
python scripts/security_release_checks.py
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
def main() -> int:
root = Path(__file__).resolve().parents[1]
test_file = root / "tests" / "test_security_release.py"
cmd = [
sys.executable,
"-m",
"pytest",
str(test_file),
"-v",
"--tb=short",
]
return subprocess.run(cmd, cwd=str(root)).returncode
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,116 @@
"""
Sicherheits-Regression für Release-Konfiguration (OpenAPI, Health-Ready, Legacy-Routen).
Läuft ohne PostgreSQL, wo möglich (TestClient + Subprocess mit frischem Import).
CI: scripts/security_release_checks.py
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
BACKEND_ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_auth_override():
yield
app.dependency_overrides.pop(require_auth, None)
def test_legacy_get_profile_uses_session_only(client: TestClient) -> None:
"""SEC-01: /api/profile darf nur das Session-Profil liefern."""
def auth_user() -> dict:
return {"profile_id": 901, "role": "trainer"}
app.dependency_overrides[require_auth] = auth_user
with patch("routers.profiles.profile_document") as pd_mock:
pd_mock.return_value = {"id": 901, "name": "T"}
r = client.get("/api/profile", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 200
pd_mock.assert_called_once_with("901")
def _run_fresh_import_int(code: str, env: dict) -> subprocess.CompletedProcess:
merged = {**os.environ, **env, "SKIP_DB_MIGRATE": "1"}
return subprocess.run(
[sys.executable, "-c", code],
cwd=str(BACKEND_ROOT),
env=merged,
capture_output=True,
text=True,
check=False,
)
@pytest.mark.parametrize(
"extra_env, expect_openapi",
[
({"ENVIRONMENT": "development"}, True),
({"ENVIRONMENT": "production"}, False),
({"ENVIRONMENT": "production", "PUBLIC_OPENAPI": "true"}, True),
],
)
def test_openapi_urls_match_environment(extra_env: dict, expect_openapi: bool) -> None:
snippet = f"""
from main import app
assert (app.openapi_url is not None) == {expect_openapi}
assert (app.docs_url is not None) == {expect_openapi}
"""
proc = _run_fresh_import_int(snippet, extra_env)
assert proc.returncode == 0, proc.stderr + proc.stdout
def test_health_ready_minimal_body_in_production() -> None:
snippet = """
from fastapi.testclient import TestClient
from main import app
c = TestClient(app)
r = c.get("/api/health/ready")
r.raise_for_status()
j = r.json()
assert "tables" not in j
assert "detail" not in j
assert "schema_migrations_count" not in j
assert "status" in j and "database" in j and "schema_complete" in j
"""
proc = _run_fresh_import_int(
snippet,
{"ENVIRONMENT": "production"},
)
assert proc.returncode == 0, proc.stderr + proc.stdout
def test_health_ready_full_detail_when_flag_in_production() -> None:
snippet = """
from fastapi.testclient import TestClient
from main import app
c = TestClient(app)
r = c.get("/api/health/ready")
r.raise_for_status()
j = r.json()
assert "tables" in j
assert "schema_migrations_count" in j
"""
proc = _run_fresh_import_int(
snippet,
{"ENVIRONMENT": "production", "HEALTH_READY_PUBLIC_DETAIL": "1"},
)
assert proc.returncode == 0, proc.stderr + proc.stdout

View File

@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Docker-Embedded DNS: Hostname »backend« bei Container-Neustarts neu auflösen
# verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat.
resolver 127.0.0.11 valid=10s ipv6=off;

View File

@ -1,58 +1,70 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { useAuth } from './AuthContext'
import { getCurrentProfile, listProfiles } from '../utils/api'
const ProfileContext = createContext(null)
export function ProfileProvider({ children }) {
const { session } = useAuth()
const [profiles, setProfiles] = useState([])
const { user, isAuthenticated } = useAuth()
const [profiles, setProfiles] = useState([])
const [activeProfile, setActiveProfileState] = useState(null)
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(true)
const loadProfiles = async () => {
const loadProfiles = async (authUser) => {
try {
const token = localStorage.getItem('bodytrack_token') || ''
const res = await fetch('/api/profiles', {
headers: { 'X-Auth-Token': token }
})
if (!res.ok) return []
return await res.json()
} catch(e) { return [] }
if (!authUser?.id) return []
const admin = authUser.role === 'admin' || authUser.role === 'superadmin'
if (admin) {
try {
return await listProfiles()
} catch {
const me = await getCurrentProfile()
return me ? [me] : []
}
}
const me = await getCurrentProfile()
return me ? [me] : []
} catch {
return []
}
}
// Re-load whenever session changes (login/logout/switch)
useEffect(() => {
if (!session) {
if (!isAuthenticated || !user?.id) {
setActiveProfileState(null)
setProfiles([])
setLoading(false)
return
}
setLoading(true)
loadProfiles().then(data => {
setProfiles(data)
// Always use the profile_id from the session token not localStorage
const match = data.find(p => p.id === session.profile_id)
setActiveProfileState(match || data[0] || null)
loadProfiles(user).then((data) => {
const rows = Array.isArray(data) ? data : []
setProfiles(rows)
const uid = user.id
const match = rows.find((p) => String(p.id) === String(uid))
setActiveProfileState(match || rows[0] || null)
setLoading(false)
})
}, [session?.profile_id]) // re-runs when profile changes
}, [isAuthenticated, user?.id, user?.role])
const setActiveProfile = (profile) => {
setActiveProfileState(profile)
localStorage.setItem('bodytrack_active_profile', profile.id)
localStorage.setItem('shinkan_active_profile', String(profile.id))
}
const refreshProfiles = () => loadProfiles().then(data => {
setProfiles(data)
if (activeProfile) {
const updated = data.find(p => p.id === activeProfile.id)
if (updated) setActiveProfileState(updated)
}
})
const refreshProfiles = () =>
loadProfiles(user).then((data) => {
setProfiles(Array.isArray(data) ? data : [])
if (activeProfile) {
const updated = data.find((p) => String(p.id) === String(activeProfile.id))
if (updated) setActiveProfileState(updated)
}
})
return (
<ProfileContext.Provider value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}>
<ProfileContext.Provider
value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}
>
{children}
</ProfileContext.Provider>
)

View File

@ -92,7 +92,7 @@ export default function AdminCatalogsPage() {
} else if (activeTab === 'trainer-assignments') {
const [assignments, profs, areas] = await Promise.all([
api.listTrainerFocusAreas(),
fetch('/api/profiles').then(r => r.json()),
api.listProfiles(),
api.listFocusAreas()
])
setTrainerAssignments(assignments)