From c2d9eac151feb7f3e93fac6b8759d47d458b6ab9 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 10:40:10 +0200 Subject: [PATCH 01/15] feat: enhance API and profile management with environment configurations - 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. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 6 + .../PRODUCTION_READINESS_AUDIT_2026-05.md | 82 +++++++++++++ .gitea/workflows/test.yml | 1 + CLAUDE.md | 4 + backend/auth.py | 2 - backend/main.py | 42 ++++++- backend/routers/profiles.py | 48 ++++---- backend/scripts/security_release_checks.py | 32 +++++ backend/tests/test_security_release.py | 116 ++++++++++++++++++ frontend/nginx.conf | 5 + frontend/src/context/ProfileContext.jsx | 68 +++++----- frontend/src/pages/AdminCatalogsPage.jsx | 2 +- 12 files changed, 351 insertions(+), 57 deletions(-) create mode 100644 .claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md create mode 100644 backend/scripts/security_release_checks.py create mode 100644 backend/tests/test_security_release.py diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 9168685..1806218 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -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. diff --git a/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md b/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md new file mode 100644 index 0000000..24b1c40 --- /dev/null +++ b/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md @@ -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. diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 8101e74..fba50b6 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -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 " diff --git a/CLAUDE.md b/CLAUDE.md index 85026c9..4a23c64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: diff --git a/backend/auth.py b/backend/auth.py index a68cf09..980e24c 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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.""" diff --git a/backend/main.py b/backend/main.py index 2699427..6ed81ab 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index e6cefec..daa2dda 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -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) diff --git a/backend/scripts/security_release_checks.py b/backend/scripts/security_release_checks.py new file mode 100644 index 0000000..b540451 --- /dev/null +++ b/backend/scripts/security_release_checks.py @@ -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()) diff --git a/backend/tests/test_security_release.py b/backend/tests/test_security_release.py new file mode 100644 index 0000000..88eccda --- /dev/null +++ b/backend/tests/test_security_release.py @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index bf3cec1..3e8daeb 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/context/ProfileContext.jsx b/frontend/src/context/ProfileContext.jsx index 6a34811..794ba91 100644 --- a/frontend/src/context/ProfileContext.jsx +++ b/frontend/src/context/ProfileContext.jsx @@ -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 ( - + {children} ) diff --git a/frontend/src/pages/AdminCatalogsPage.jsx b/frontend/src/pages/AdminCatalogsPage.jsx index 5ee10ef..9715878 100644 --- a/frontend/src/pages/AdminCatalogsPage.jsx +++ b/frontend/src/pages/AdminCatalogsPage.jsx @@ -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) From b75288339261db238cfb23d9bb237267dd750314 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 10:52:14 +0200 Subject: [PATCH 02/15] feat: enhance media access and security for exercises - Updated PostgreSQL binding in docker-compose to restrict access to localhost only. - Implemented a new API endpoint for secure media file delivery, requiring authentication via token. - Enhanced governance checks for exercise media access, ensuring only authorized users can retrieve files. - Updated frontend components to utilize the new media file access method, improving user experience while maintaining security. - Documented changes in production readiness audit and access layer endpoint audit for clarity on security enhancements. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 2 + .../PRODUCTION_READINESS_AUDIT_2026-05.md | 27 ++++--- backend/main.py | 7 +- backend/routers/clubs.py | 10 ++- backend/routers/exercises.py | 66 +++++++++++++++- backend/routers/maturity_models.py | 4 +- backend/tenant_context.py | 30 +++++++- backend/tests/test_exercise_media_download.py | 75 +++++++++++++++++++ docker-compose.yml | 3 +- frontend/nginx.conf | 2 + .../src/components/ExerciseFullContent.jsx | 16 +--- frontend/src/pages/ExerciseDetailPage.jsx | 16 +--- frontend/src/utils/exerciseMediaUrl.js | 19 +++++ 13 files changed, 235 insertions(+), 42 deletions(-) create mode 100644 backend/tests/test_exercise_media_download.py create mode 100644 frontend/src/utils/exerciseMediaUrl.js diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 1806218..0f8026d 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -11,6 +11,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | | | club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | | | exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin | +| exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC | | exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | | | exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar | | training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | @@ -32,6 +33,7 @@ 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`. +- **2026-05-07 (Phase 2):** Geschützte Übungs-Mediendatei-API; `/media`-Static optional; Mitgliederverzeichnis E-Mail eingeschränkt; GET maturity-by-id nur Admin; Postgres `127.0.0.1` im Prod-Compose. --- diff --git a/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md b/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md index 24b1c40..79f4491 100644 --- a/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md +++ b/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md @@ -14,9 +14,8 @@ | 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 | +| SEC-04 | Statische Auslieferung `/media` ohne Auth (Link-Leak) | **Behoben:** `GET /api/exercises/.../media/.../file` mit Governance + `ssetoken`; `/media` nur mit `ALLOW_PUBLIC_MEDIA_STATIC=1` | +| OPS-01 | PostgreSQL-Hostport in `docker-compose.yml` | **Behoben:** Bind `127.0.0.1:5434` (nur Host-Local) | ### A.2 Mittel / Konsistenz @@ -24,12 +23,15 @@ |----|--------|--------| | 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) | +| MEM-01 | Vereinsmitgliederverzeichnis zeigt E-Mail an alle Mitglieder | **Behoben:** E-Mail nur Plattform-Admin / `can_manage_club_org` | +| MAT-01 | `GET /maturity-models/{id}` für jeden Auth-Nutzer | **Behoben:** nur Portal-Admin (UI nutzt ohnehin nur Admin-Panel) | -### A.3 Niedrig +### A.3 Niedrig / Offen | ID | Befund | Status | |----|--------|--------| +| CON-03 | Sparten (`division`) vs. Zielbild ACCESS_LAYER | **Offen** (Roadmap Stufe D) | +| OPS-02 | CSP / restliche Browser-Härtung | **Teilweise** (Basis-Header nginx; CSP offen) | | 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 | @@ -46,12 +48,12 @@ 5. Profil-Löschung: optionale Tabellen nur bei Existenz. 6. Frontend: Admin-Kataloge Profilliste über `api.js`. -### Phase 2 — Kurzfristig (vor breitem Go-Live) +### Phase 2 — Kurzfristig (vor breitem Go-Live) ✅ Hauptpunkte umgesetzt (2026-05-07) -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. +1. **Medien:** `GET /api/exercises/{id}/media/{mid}/file` — Auth (`X-Auth-Token` oder `?ssetoken=`), Zugriff wie GET Übung; statisches `/media` standardmäßig aus. +2. **Mitgliederverzeichnis:** E-Mail nur wenn Plattform-Admin oder `can_manage_club_org`. +3. **DB-Port:** Prod-Compose Postgres an `127.0.0.1` gebunden. +4. **`GET /api/maturity-models/{id}`:** nur Portal-Admin (Liste/resolve unverändert für Trainer). ### Phase 3 — Mittelfristig @@ -76,7 +78,12 @@ | `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 | +| `ALLOW_PUBLIC_MEDIA_STATIC` | `1` / `true` → öffentliches Mount von `/media/` (Notfall/Legacy; Standard: aus) | --- **Pflege:** Nach jeder relevanten Änderung **Status-Spalte** und ggf. **Phase** aktualisieren; bei neuem Audit Datum im Titel anpassen oder Abschnitt „Changelog“ ergänzen. + +### Changelog + +- **2026-05-07:** Phase 2 Medien, Mitgliederverzeichnis E-Mail, maturity GET admin-only, Postgres localhost bind, Tests `test_exercise_media_download.py`. diff --git a/backend/main.py b/backend/main.py index 6ed81ab..64e3652 100644 --- a/backend/main.py +++ b/backend/main.py @@ -205,10 +205,13 @@ app.include_router(matrix_stack_bundle.router) app.include_router(import_wiki.router) app.include_router(import_wiki_admin.router) -# Lokale Medien (Übungen-Uploads) unter MEDIA_ROOT, ausliefern unter /media/... +# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad +# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für /