feat: enhance media access and security for exercises
All checks were successful
Deploy Development / deploy (push) Successful in 41s
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 25s

- 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.
This commit is contained in:
Lars 2026-05-07 10:52:14 +02:00
parent c2d9eac151
commit b752883392
13 changed files with 235 additions and 42 deletions

View File

@ -11,6 +11,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| 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.
---

View File

@ -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`.

View File

@ -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 <img>/<video>).
# Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
Path(_media_dir).mkdir(parents=True, exist_ok=True)
app.mount("/media", StaticFiles(directory=_media_dir), name="media")
if os.getenv("ALLOW_PUBLIC_MEDIA_STATIC", "").strip().lower() in ("1", "true", "yes"):
app.mount("/media", StaticFiles(directory=_media_dir), name="media")
if __name__ == "__main__":
import uvicorn

View File

@ -74,9 +74,10 @@ def public_club_directory():
return [r2d(r) for r in cur.fetchall()]
# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ──
# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (Vereinsmitglied) ──
@router.get("/clubs/{club_id}/members/directory")
def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""id + name für alle aktiven Mitglieder; E-Mail nur für Plattform-Admin oder Vereinsadmin (Org-Verwaltung)."""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
@ -96,7 +97,12 @@ def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_ten
""",
(club_id,),
)
return [r2d(r) for r in cur.fetchall()]
rows = [r2d(r) for r in cur.fetchall()]
show_email = is_platform_admin(role) or can_manage_club_org(cur, profile_id, club_id, role)
if not show_email:
for d in rows:
d["email"] = None
return rows
# ── Get Club ──────────────────────────────────────────────────────────

View File

@ -12,6 +12,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d
@ -23,7 +24,7 @@ from club_tenancy import (
is_platform_admin,
library_content_visible_to_profile,
)
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql
logger = logging.getLogger(__name__)
@ -303,6 +304,36 @@ def _detect_embed_platform(url: str) -> Optional[str]:
return None
def _fetch_exercise_governance_row(cur, exercise_id: int) -> Optional[dict]:
cur.execute(
"SELECT id, visibility, club_id, created_by FROM exercises WHERE id = %s",
(exercise_id,),
)
row = cur.fetchone()
return r2d(row) if row else None
def _assert_can_view_exercise_media(
cur,
exercise_id: int,
tenant: TenantContext,
) -> dict:
"""403 wenn Übung für den Nutzer nicht lesbar (wie GET /exercises/{id})."""
ex = _fetch_exercise_governance_row(cur, exercise_id)
if not ex:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
if not library_content_visible_to_profile(
cur,
tenant.profile_id,
(ex.get("visibility") or "").strip().lower(),
ex.get("club_id"),
ex.get("created_by"),
tenant.global_role,
):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
return ex
def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int):
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone()
@ -1801,6 +1832,39 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
return r2d(row) if row else None
@router.get("/exercises/{exercise_id}/media/{media_id}/file")
def download_exercise_media_file(
exercise_id: int,
media_id: int,
tenant: TenantContext = Depends(get_tenant_context_flexible),
):
"""
Dateiauslieferung mit Governance wie GET Übung Auth via X-Auth-Token oder ?ssetoken (für <img>/<video>).
Direktes /media/... ohne Token ist nicht vorgesehen (kein anonymes Hosting).
"""
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_view_exercise_media(cur, exercise_id, tenant)
media = _fetch_media_row(cur, exercise_id, media_id)
if not media:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
if (media.get("embed_url") or "").strip():
raise HTTPException(status_code=400, detail="Embed-Medien haben keine Datei-URL")
fp = media.get("file_path")
abs_p = _abs_media_path(fp) if fp else None
if not abs_p or not abs_p.is_file():
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
mime = media.get("mime_type") or "application/octet-stream"
fname = media.get("original_filename") or abs_p.name
return FileResponse(
path=str(abs_p.resolve()),
media_type=mime,
filename=str(fname),
)
@router.post("/exercises/{exercise_id}/media", status_code=201)
async def upload_exercise_media(
exercise_id: int,

View File

@ -3,7 +3,8 @@ Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen)
Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt überall).
Lesen: alle authentifizierten Nutzer.
Lesen: Liste & resolve für alle authentifizierten Nutzer; GET eines Modells nach ID nur Portal-Admin (Admin-UI).
Schreiben: admin, superadmin.
"""
from datetime import datetime, timezone
@ -534,6 +535,7 @@ def list_maturity_models(
@router.get("/maturity-models/{model_id}")
def get_maturity_model(model_id: int, session: dict = Depends(require_auth)):
_require_admin(session)
with get_db() as conn:
cur = get_cursor(conn)
return _load_full_model(cur, model_id)

View File

@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional
from fastapi import Depends, Header, HTTPException
from auth import require_auth
from auth import require_auth, require_auth_flexible
from club_tenancy import is_platform_admin, memberships_with_roles
from db import get_db, get_cursor
@ -183,6 +183,34 @@ def get_tenant_context(
)
def get_tenant_context_flexible(
session: dict = Depends(require_auth_flexible),
x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"),
) -> TenantContext:
"""
Wie get_tenant_context, aber Auth per Header oder Query ?ssetoken (für <img>/<video> ohne Custom-Header).
"""
pid = int(session["profile_id"])
role = session.get("role") or ""
stored: Optional[int] = None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT active_club_id FROM profiles WHERE id = %s", (pid,))
row = cur.fetchone()
if row is not None:
ac = row.get("active_club_id")
if ac is not None:
stored = int(ac)
return resolve_tenant_context(
cur,
profile_id=pid,
global_role=role,
header_raw=x_active_club_id,
memberships=None,
stored_active_club_id=stored,
)
def tenant_context_from_session_only(
cur,
session: dict,

View File

@ -0,0 +1,75 @@
"""
Geschützte Übungs-Mediendatei: Auth + Governance (kein anonymes /media/).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth_flexible
from main import app
from tenant_context import TenantContext, get_tenant_context_flexible
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides():
yield
app.dependency_overrides.pop(require_auth_flexible, None)
app.dependency_overrides.pop(get_tenant_context_flexible, None)
def test_exercise_media_file_unauthenticated_401(client: TestClient) -> None:
r = client.get("/api/exercises/1/media/2/file")
assert r.status_code == 401
def test_exercise_media_file_forbidden_when_not_visible(client: TestClient) -> None:
app.dependency_overrides[get_tenant_context_flexible] = lambda: TenantContext(
profile_id=22,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cm = MagicMock()
mock_conn = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.return_value = {
"id": 3,
"visibility": "private",
"club_id": None,
"created_by": 99,
}
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
), patch("routers.exercises.library_content_visible_to_profile", return_value=False):
r = client.get("/api/exercises/3/media/2/file", headers={"X-Auth-Token": "t"})
assert r.status_code == 403
def test_get_maturity_model_requires_admin(client: TestClient) -> None:
from auth import require_auth
def _user() -> dict:
return {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[require_auth] = _user
try:
r = client.get("/api/maturity-models/1", headers={"X-Auth-Token": "x"})
assert r.status_code == 403
finally:
app.dependency_overrides.pop(require_auth, None)

View File

@ -8,8 +8,9 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- shinkan-db-data:/var/lib/postgresql/data
# Nur localhost: DB nicht im LAN exponieren (Beta/Prod). Entferne 127.0.0.1: nur wenn du bewusst remote willst.
ports:
- "5434:5432"
- "127.0.0.1:5434:5432"
restart: unless-stopped
networks:
- shinkan-network

View File

@ -31,6 +31,8 @@ server {
}
location ^~ /media/ {
# Auslieferung Übungsdateien erfolgt geschützt über /api/exercises/.../media/.../file (?ssetoken).
# Optional: Backend mit ALLOW_PUBLIC_MEDIA_STATIC=1 wieder /media/ ohne Auth.
set $docker_backend_svc backend;
proxy_pass http://$docker_backend_svc:8000$request_uri;
proxy_http_version 1.1;

View File

@ -4,15 +4,7 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
function resolveMediaUrl(filePath) {
if (!filePath) return null
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return filePath
const p = filePath.startsWith('/') ? filePath : `/${filePath}`
return `${API_BASE}${p}`
}
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
function HtmlBlock({ html, className = '' }) {
if (!html || !String(html).trim()) return null
@ -22,7 +14,7 @@ function HtmlBlock({ html, className = '' }) {
)
}
function MediaBlock({ media }) {
function MediaBlock({ media, exerciseId }) {
if (media.embed_url) {
return (
<div style={{ marginTop: '0.5rem' }}>
@ -37,7 +29,7 @@ function MediaBlock({ media }) {
</div>
)
}
const src = resolveMediaUrl(media.file_path)
const src = resolveExerciseMediaFileUrl(exerciseId, media)
if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return (
@ -178,7 +170,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
<div key={m.id} style={{ marginBottom: '12px' }}>
<strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong>
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
<MediaBlock media={m} />
<MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} />
</div>
))}
</section>

View File

@ -1,18 +1,10 @@
import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
import { formatSkillLevelSlug } from '../constants/skillLevels'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
function resolveMediaUrl(filePath) {
if (!filePath) return null
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return filePath
const p = filePath.startsWith('/') ? filePath : `/${filePath}`
return `${API_BASE}${p}`
}
function HtmlBlock({ html, className = '' }) {
if (!html || !String(html).trim()) return null
const safe = sanitizeTrainerHtml(html)
@ -24,7 +16,7 @@ function HtmlBlock({ html, className = '' }) {
)
}
function MediaBlock({ media }) {
function MediaBlock({ media, exerciseId }) {
if (media.embed_url) {
return (
<div style={{ marginTop: '0.5rem' }}>
@ -39,7 +31,7 @@ function MediaBlock({ media }) {
</div>
)
}
const src = resolveMediaUrl(media.file_path)
const src = resolveExerciseMediaFileUrl(exerciseId, media)
if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return (
@ -226,7 +218,7 @@ function ExerciseDetailPage() {
<div key={m.id} style={{ marginBottom: '1.25rem' }}>
<strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
<MediaBlock media={m} />
<MediaBlock media={m} exerciseId={exercise.id} />
</div>
))}
</section>

View File

@ -0,0 +1,19 @@
/** URL für Übungs-Mediendateien: API mit Token (Legacy /media/ ohne Auth ist abgeschaltet). */
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
/**
* @param {number|string} exerciseId
* @param {object} media exercise_media Zeile mit id, file_path
* @returns {string|null}
*/
export function resolveExerciseMediaFileUrl(exerciseId, media) {
if (!media?.file_path) return null
const fp = String(media.file_path)
if (fp.startsWith('http://') || fp.startsWith('https://')) return fp
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
const id = media.id
if (id == null || exerciseId == null) return null
return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
}