Implement compliance report and workspace configuration
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 31s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 27s

- Added compliance implementation report detailing the status of various packages (P-03, P-04, P-05, P-07, P-23, P-24) and their technical changes, tests, and notes.
- Introduced a new workspace configuration file for the project to streamline development setup.
This commit is contained in:
Lars 2026-05-09 22:11:33 +02:00
parent 01be9ffcd4
commit be0385922d
13 changed files with 1465 additions and 17 deletions

View File

@ -82,8 +82,8 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, allow_origins=ALLOWED_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Content-Type", "X-Auth-Token", "X-Active-Club-Id"],
) )

View File

@ -98,8 +98,8 @@ def change_pin(req: dict, session: dict=Depends(require_auth)):
"""Change PIN/password for current user.""" """Change PIN/password for current user."""
pid = session['profile_id'] pid = session['profile_id']
new_pin = req.get('pin', '') new_pin = req.get('pin', '')
if len(new_pin) < 4: if len(new_pin) < 8:
raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") raise HTTPException(400, "Passwort muss mind. 8 Zeichen haben")
new_hash = hash_pin(new_pin) new_hash = hash_pin(new_pin)
with get_db() as conn: with get_db() as conn:

View File

@ -1158,6 +1158,22 @@ def bulk_media_patch(
) )
continue continue
if next_vis in ("club", "official"):
effective_copyright = (
patch_fields.get("copyright_notice") or asset.get("copyright_notice") or ""
)
if not str(effective_copyright).strip():
failed.append(
{
"id": asset_id,
"detail": (
"Fur Vereins- oder offizielle Medien ist eine "
"Urheberrechtsangabe (copyright_notice) Pflicht."
),
}
)
continue
new_sk: Optional[str] = None new_sk: Optional[str] = None
if "visibility" in patch_fields or "club_id" in patch_fields: if "visibility" in patch_fields or "club_id" in patch_fields:
next_club_param: Optional[int] = None next_club_param: Optional[int] = None
@ -1256,6 +1272,20 @@ def patch_media_asset(
), ),
) )
if next_vis in ("club", "official"):
effective_copyright = (
data.get("copyright_notice") or asset.get("copyright_notice") or ""
)
if not str(effective_copyright).strip():
raise HTTPException(
status_code=400,
detail=(
"Fur Vereins- oder offizielle Medien ist eine Urheberrechtsangabe "
"(copyright_notice) Pflicht. Bitte vor oder zusammen mit der "
"Freigabe angeben."
),
)
new_sk: Optional[str] = None new_sk: Optional[str] = None
if "visibility" in data or "club_id" in data: if "visibility" in data or "club_id" in data:
next_club_param: Optional[int] = None next_club_param: Optional[int] = None

View File

@ -0,0 +1,252 @@
"""
P-04: Copyright-Pflicht bei Promotion auf club/official.
PATCH /api/media-assets/{id} und POST /api/media-assets/bulk-patch
lehnen eine Sichtbarkeits-Promotion auf club oder official ab,
wenn keine copyright_notice vorhanden ist.
"""
from __future__ import annotations
import os
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from main import app
from tenant_context import TenantContext, get_tenant_context
_SUPERADMIN_TENANT = TenantContext(
profile_id=1,
global_role="superadmin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
_PRIVATE_ASSET: dict = {
"id": 42,
"visibility": "private",
"club_id": 7,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"copyright_notice": None,
"original_filename": "foto.jpg",
"sha256": "a" * 64,
"storage_key": f"library/verein-7/image/{'a' * 64}.jpg",
"storage_backend": "local",
"mime_type": "image/jpeg",
"byte_size": 1024,
"created_at": None,
"tags": [],
}
_ASSET_WITH_COPYRIGHT: dict = {**_PRIVATE_ASSET, "copyright_notice": "Verein 2026"}
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides():
yield
app.dependency_overrides.pop(get_tenant_context, None)
def _make_db_mocks(asset: dict) -> tuple[MagicMock, MagicMock]:
mock_cur = MagicMock()
mock_cur.fetchone.return_value = asset
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
return mock_cm, mock_cur
_PERMISSION_PATCHES = [
("routers.media_assets.assert_can_edit_media_asset_metadata", {}),
("routers.media_assets.assert_valid_governance_visibility", {}),
("routers.media_assets._media_assets_tags_column_present", {"return_value": False}),
("routers.media_assets.get_effective_media_root", {"return_value": "/tmp/media"}),
("routers.media_assets._relocate_asset_file_if_governance_changed", {"return_value": None}),
]
def _enter_permission_patches(stack: ExitStack) -> None:
for target, kwargs in _PERMISSION_PATCHES:
stack.enter_context(patch(target, **kwargs))
# ── Single PATCH ─────────────────────────────────────────────────────────────
def test_patch_promote_to_club_without_copyright_returns_400(client: TestClient) -> None:
"""Promotion private -> club ohne copyright_notice muss 400 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 400
detail = r.json()["detail"].lower()
assert "copyright" in detail or "urheberrecht" in detail
def test_patch_promote_to_official_without_copyright_returns_400(client: TestClient) -> None:
"""Promotion private -> official ohne copyright_notice muss 400 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "official"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 400
detail = r.json()["detail"].lower()
assert "copyright" in detail or "urheberrecht" in detail
def test_patch_promote_to_club_with_copyright_in_body_allowed(client: TestClient) -> None:
"""Promotion private -> club MIT copyright_notice im Body darf nicht 400 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
mock_cur.fetchone.side_effect = [
_PRIVATE_ASSET,
{**_PRIVATE_ASSET, "visibility": "club", "copyright_notice": "Verein 2026"},
]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7, "copyright_notice": "Verein 2026"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower()
def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) -> None:
"""Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright erlaubt."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_ASSET_WITH_COPYRIGHT)
mock_cur.fetchone.side_effect = [
_ASSET_WITH_COPYRIGHT,
{**_ASSET_WITH_COPYRIGHT, "visibility": "club"},
]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7},
headers={"X-Auth-Token": "t"},
)
assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower()
def test_patch_filename_only_no_copyright_check(client: TestClient) -> None:
"""Kein Visibility-Wechsel -> keine Copyright-Prufung."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
mock_cur.fetchone.side_effect = [
_PRIVATE_ASSET,
{**_PRIVATE_ASSET, "original_filename": "neu.jpg"},
]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"original_filename": "neu.jpg"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
# ── Bulk PATCH ────────────────────────────────────────────────────────────────
def test_bulk_patch_promote_to_club_without_copyright_in_failed(client: TestClient) -> None:
"""Bulk-Promotion ohne copyright_notice -> Asset in failed-Liste."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cur = MagicMock()
mock_cur.fetchone.return_value = _PRIVATE_ASSET
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.post(
"/api/media-assets/bulk-patch",
json={"media_asset_ids": [42], "visibility": "club", "club_id": 7},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert body["updated_count"] == 0
assert body["failed_count"] == 1
detail = body["failed"][0]["detail"].lower()
assert "copyright" in detail or "urheberrecht" in detail
def test_bulk_patch_promote_to_club_with_copyright_in_updated(client: TestClient) -> None:
"""Bulk-Promotion MIT copyright_notice -> Asset in updated-Liste."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cur = MagicMock()
mock_cur.fetchone.return_value = _PRIVATE_ASSET
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.post(
"/api/media-assets/bulk-patch",
json={
"media_asset_ids": [42],
"visibility": "club",
"club_id": 7,
"copyright_notice": "Verein 2026",
},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert 42 in body["updated"]
assert body["updated_count"] == 1

View File

@ -124,3 +124,27 @@ def test_api_attachments_x_content_type_options_nosniff(client: TestClient) -> N
r2 = client.get("/api/version") r2 = client.get("/api/version")
assert r2.status_code == 200 assert r2.status_code == 200
assert r2.headers.get("x-content-type-options") == "nosniff" assert r2.headers.get("x-content-type-options") == "nosniff"
def test_public_media_static_not_mounted_by_default() -> None:
"""/media/-StaticFiles-Mount darf ohne ALLOW_PUBLIC_MEDIA_STATIC nicht aktiv sein."""
snippet = """
from main import app
mounted = [getattr(r, 'path', '') for r in app.routes]
assert not any(p == '/media' for p in mounted), (
"ALLOW_PUBLIC_MEDIA_STATIC aktiv /media oeffentlich erreichbar. Vor Deploy entfernen."
)
"""
proc = _run_fresh_import_int(snippet, {"ENVIRONMENT": "production"})
assert proc.returncode == 0, proc.stderr + proc.stdout
def test_allow_public_media_static_activates_media_mount() -> None:
"""Dokumentiert: ALLOW_PUBLIC_MEDIA_STATIC=1 aktiviert /media ohne Authentifizierung."""
snippet = """
from main import app
mounted = [getattr(r, 'path', '') for r in app.routes]
assert any(p == '/media' for p in mounted), "/media-Mount wurde nicht aktiviert"
"""
proc = _run_fresh_import_int(snippet, {"ALLOW_PUBLIC_MEDIA_STATIC": "1"})
assert proc.returncode == 0, proc.stderr + proc.stdout

View File

@ -1,11 +1,11 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.65" APP_VERSION = "0.8.66"
BUILD_DATE = "2026-05-08" BUILD_DATE = "2026-05-09"
DB_SCHEMA_VERSION = "20260508049" DB_SCHEMA_VERSION = "20260508049"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "auth": "1.2.2", # Passwort-Mindestlange PUT /auth/pin: 4 auf 8 Zeichen angehoben
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json() "profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert "tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
@ -13,7 +13,7 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users "admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_assets": "1.12.1", # official: nur Superadmin Lifecycle/PATCH; UI Lesemodus; Superadmin Upload-Verein = aktiv "media_assets": "1.12.2", # P-04: Copyright-Pflicht bei Promotion auf club/official in PATCH und bulk-patch
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
@ -29,6 +29,18 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.66",
"date": "2026-05-09",
"changes": [
"Sicherheit P-03: Papierkorb-Retention-Job (media_retention_job.py) als Docker-Service retention-cron aktiviert (lauft taglich um 03:00 Uhr)",
"Sicherheit P-04: Copyright-Pflicht bei Sichtbarkeits-Promotion auf club/official in PATCH und bulk-patch (media_assets)",
"Sicherheit P-05: Passwort-Mindestlange PUT /auth/pin von 4 auf 8 Zeichen angehoben",
"Sicherheit P-07: Release-Test fur ALLOW_PUBLIC_MEDIA_STATIC in test_security_release.py ergaenzt",
"Sicherheit P-23: LoginPage minLength 6 auf 8 angehoben; hartcodierter Versionsstring entfernt",
"Sicherheit P-24: CORS allow_methods und allow_headers auf benotigte Werte eingeschrankt",
],
},
{ {
"version": "0.8.65", "version": "0.8.65",
"date": "2026-05-08", "date": "2026-05-08",

View File

@ -81,6 +81,43 @@ services:
networks: networks:
- shinkan-network - shinkan-network
retention-cron:
build:
context: ./backend
dockerfile: Dockerfile
container_name: shinkan-retention-cron
command: >
python -c "
import time, subprocess, sys, os, datetime
def next_3am():
now = datetime.datetime.now()
target = now.replace(hour=3, minute=0, second=0, microsecond=0)
if target <= now:
target += datetime.timedelta(days=1)
return (target - now).total_seconds()
subprocess.run([sys.executable, 'scripts/media_retention_job.py'], check=False)
while True:
time.sleep(next_3am())
subprocess.run([sys.executable, 'scripts/media_retention_job.py'], check=False)
"
working_dir: /app
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: shinkan
DB_USER: shinkan_user
DB_PASSWORD: ${DB_PASSWORD}
MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}"
MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS: "${MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS:-30}"
MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS: "${MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS:-90}"
volumes:
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
depends_on:
- postgres
restart: unless-stopped
networks:
- shinkan-network
volumes: volumes:
shinkan-db-data: shinkan-db-data:

931
docs/compliance-audit.md Normal file
View File

@ -0,0 +1,931 @@
# Compliance-Audit Shinkan Jinkendo
> **Status:** Entwurf — technischer Audit, kein Rechtsanwalt
> **Datum:** 2026-05-09
> **Auditor:** Claude Code
> **App-Version:** 0.8.65
> **Rechtlicher Hinweis:** Dieses Dokument ist eine technische Analyse. Es ersetzt keine Rechtsberatung. Alle als „juristisch zu prüfen" markierten Punkte müssen durch einen Rechtsanwalt oder Datenschutzbeauftragten bewertet werden. Kein Code wurde verändert.
---
## 1. Executive Summary
Die Shinkan Jinkendo App ist technisch solide aufgebaut: robuste Mandantentrennung (TenantContext), mehrstufiges Löschkonzept für Medien, serverseitig erzwungene Zugriffskontrolle. Die Kernarchitektur der Datenschicht ist gut.
**Kritische Compliance-Lücken:**
- Keine Rechtstexte (Impressum, Datenschutzerklärung, Nutzungsbedingungen, Medienrichtlinie)
- Kein DSA-konformes Meldeverfahren für rechtswidrige Inhalte (UGC-Plattform)
- Kein Recht-am-eigenen-Bild/Minderjährigen-Check beim Medienupload
- Kein Self-Service-Löschrecht für Nutzer (nur Admin kann Konten löschen)
- Auth-Token im localStorage (XSS-Risiko, TDDDG-Dokumentationspflicht)
- HSTS-Header fehlt in der Nginx-Konfiguration
- Papierkorb-Retention-Job nicht automatisch geplant
- Passwort-Mindestlänge inkonsistent (Register: 8, PIN-Änderung: 4 Zeichen)
Vor öffentlichem Betrieb sind die kritischen Findings (KRIT-01 bis KRIT-07) zu adressieren.
---
## 2. Scope
| Bereich | Geprüft |
|---------|---------|
| Backend-Router (alle .py) | ✓ |
| Datenbankmigrationen (001046) | ✓ |
| Frontend App.jsx, Routing, Auth | ✓ |
| API-Authentifizierung und Autorisierung | ✓ |
| Mandanten-/Zugriffschicht (TenantContext, club_tenancy) | ✓ |
| Medien-Archiv (media_assets, lifecycle) | ✓ |
| PWA-Konfiguration (manifest.webmanifest) | ✓ |
| Nginx-Konfiguration (nginx.conf) | ✓ |
| Docker-Compose (docker-compose.yml) | ✓ |
| Vorhandene Tests (backend/tests/*.py) | ✓ |
| LocalStorage / SessionStorage Nutzung | ✓ |
| Rechtstexte (Impressum, DSGVO, AGB) | ✓ |
| CSP / Security-Header | ✓ |
| Passwort-Handling, Session-Management | ✓ |
---
## 3. Annahmen
- App ist öffentlich im Internet erreichbar unter `shinkan.jinkendo.de` (HTTPS)
- SSL/TLS-Terminierung erfolgt am externen Reverse-Proxy vor dem Nginx-Container
- Betreiber ist im EU-Raum ansässig (DSGVO anwendbar)
- Minderjährige können sich registrieren (keine Altersverifikation vorhanden)
- Die Plattform erlaubt Upload und Anzeige von Bildern und Videos mit Personenabbildungen
---
## 4. Nicht geprüfte Bereiche
- Produktions-Infrastruktur (Synology NAS, Raspberry Pi 5) nur Konfigurationsdateien
- Netzwerkinfrastruktur (Fritz!Box) außerhalb des Repos
- SMTP-Anbieter im Detail (Anbieter unbekannt aus Umgebungsvariablen)
- Aktive Penetrationstests
- Backup-Prozess und Restore-Test (kein Skript im Repository)
---
## 5. Technische Bestandsaufnahme
### 5.1 Architektur
| Komponente | Technologie | Sicherheitsrelevanz |
|-----------|-------------|---------------------|
| Frontend | React 18 + Vite, SPA | Routing, Token-Speicherung |
| Backend | FastAPI Python 3.12 | Zugriffskontrolle, Validierung |
| Datenbank | PostgreSQL 16 Alpine | Datenhaltung, Mandantentrennung |
| Proxy | Nginx (Docker) | CSP, Security-Header, Upload-Limit |
| Storage | Lokaler Bind-Mount via Docker | Medienspeicherung |
| Auth | Token-basiert (Sessions-Tabelle) | Session-Management |
| PWA | Web App Manifest + Icons | Offline-Caching (kein Service Worker!) |
| E-Mail | SMTP (konfigurierbar) | Registrierung, Passwort-Reset |
| KI | OpenRouter (optional, nicht MVP) | KI-Features |
### 5.2 Authentifizierung
- Token: `secrets.token_urlsafe(32)` (kryptografisch sicher)
- Hashing: bcrypt mit auto-Upgrade von Legacy SHA256
- Session-Ablauf: 30 Tage (konfigurierbar per `session_days`)
- Rate-Limiting: Login 30/min, Forgot-Password 3/min, Register 3/hour (slowapi)
- No-Enumeration: `/forgot-password` gibt keine Info über E-Mail-Existenz preis
### 5.3 Rollen (global)
| Rolle | Rechte |
|-------|--------|
| `trainer` | Standard-Nutzer; Upload, private Übungen, Planung |
| `admin` | Plattform-Admin; alle Vereine, alle Profile einsehbar |
| `superadmin` | Vollzugriff; Official-Promotion, physische Löschung, Admin-Konfiguration |
### 5.4 Vereinsrollen (pro Verein)
| Rolle | Rechte |
|-------|--------|
| `club_admin` | Vereinsstruktur, Mitglieder, Vereins-Medien/Übungen |
| `trainer` | Training planen, Übungen verwalten |
| `content_editor` | Inhalte bearbeiten |
| `division_lead` | Spartenleitung |
### 5.5 PWA / Service Worker
- **Kein Service Worker** im Repository vorhanden
- Keine Workbox- oder sw.js-Datei gefunden
- **Bedeutung:** Das Hauptrisiko (private Medien im PWA-Cache) entfällt mangels Service Worker
### 5.6 Browser-Storage-Nutzung
| Speicherart | Inhalt | TDDDG-Klassifikation |
|-------------|--------|----------------------|
| `localStorage['authToken']` | Auth-Session-Token | Technisch notwendig |
| `localStorage['shinkan_active_club']` | Aktiver Verein (ID) | Technisch notwendig |
| `localStorage['shinkan_active_profile']` | Profil-ID | Technisch notwendig |
| `sessionStorage[storageStepKey]` | Trainingsschritt (Coach-Page) | Session-temporär, nicht personenbezogen |
| `sessionStorage[storageDeltasKey]` | Trainingsdeltas JSON | Session-temporär |
| `sessionStorage[storageDebriefKey]` | Debrief-Status | Session-temporär |
| Cookies | **keine** | |
| IndexedDB | **keine** | |
---
## 6. Datenflussanalyse
### 6.1 Registrierung / Login
```
Nutzer → POST /api/auth/register → Profil (inaktiv) + Verifikations-E-Mail
Nutzer → E-Mail-Link → GET /api/auth/verify/{token}
→ Profil aktiv, Session-Token in Response
→ Frontend: localStorage.setItem('authToken', token)
Nutzer → POST /api/auth/login → Token in Response
→ Frontend: localStorage.setItem('authToken', token)
```
Gespeicherte Daten: Name, E-Mail, bcrypt-Hash, Rolle, Tier, trial_ends_at, email_verified, verification_token (temporär, wird nach Verifikation gelöscht)
### 6.2 Medienupload
```
Nutzer → POST /api/exercises/{id}/media (multipart) [50 MB Limit]
→ MIME-Type-Prüfung (magic bytes)
→ SHA256-Hash (Deduplizierung)
→ Dateispeicherung: library/{scope}/{kind}/{sha256}{ext}
→ DB-Eintrag: media_assets + exercise_media
Admin → POST /api/media-assets/bulk-upload [1 GB Limit]
→ gleicher Pfad; Sichtbarkeit + Verein als Formular-Parameter
```
### 6.3 Medienpromotion
```
Vereins-Admin → PATCH /api/media-assets/{id}
→ assert_valid_governance_visibility() → Mitgliedschaftsprüfung
→ Bei visibility=club: club_id Pflicht + Mitgliedschaft
→ Bei visibility=official: NUR Superadmin
→ copyright_notice: KEIN Pflichtfeld (nur im exercises-Router für official)
PROBLEM: Copyright-Pflicht ist NICHT im media_assets-Router für alle Promotions implementiert
```
### 6.4 Medienlöschung
```
Stufe 1 (Soft-Trash, lifecycle_state='trash_soft'):
→ Manuell durch Eigentümer / Vereins-Admin / Superadmin
→ Datei bleibt auf Disk; weiterhin sichtbar (je nach Exercise-Implementierung)
Stufe 2 (Hidden, lifecycle_state='trash_hidden'):
→ Nach 30 Tagen (Job) oder manuell
→ Nicht mehr in normalen Abfragen sichtbar
Stufe 3 (Purge):
→ Nach weiteren 30 Tagen (Job) oder Superadmin manuell
→ Datei physisch gelöscht
PROBLEM: media_retention_job.py ist NICHT automatisch geplant
```
### 6.5 Rechteprüfung
```
Jeder Request → require_auth() → Token aus X-Auth-Token-Header → Session aus DB
Vereinsdaten → get_tenant_context() → TenantContext (profile_id, role, effective_club_id)
Listenabfragen → library_content_visibility_sql() → SQL WHERE-Baustein
Schreibzugriffe → assert_valid_governance_visibility() → 403 bei Verstoß
```
---
## 7. Rollen- und Rechteanalyse
### 7.1 Mandantentrennung Stärken
- `TenantContext` konsequent in allen vereinsrelevanten Routern via `Depends(get_tenant_context)`
- `library_content_visibility_sql()` als zentraler Sichtbarkeits-Filter (SQL-Ebene)
- `effective_club_id` aus Header nur für Mitglieder, beliebig nur für Plattform-Admins
- Integrationstests vorhanden: `test_access_layer_integration.py`
### 7.2 Klarstellung: Wer kann Vereinsmedien bearbeiten?
Die Audit-Anforderung „alle Vereinsnutzer können bearbeiten" trifft auf die tatsächliche Implementierung **nicht** zu. In `_item_permissions()` (media_assets.py) ist `edit_metadata` nur für `club_admin`-Rolle oder Plattform-Admin True normale Mitglieder können Vereinsmedien nicht bearbeiten. **Dies ist ein positiver Befund.**
### 7.3 Profil-Löschung (DSGVO-Lücke)
`DELETE /api/profiles/{pid}` nur Plattform-Admin. Nutzer können ihr eigenes Konto **nicht** selbst löschen. Potenzielle DSGVO-Verletzung (Art. 17).
---
## 8. Medienrechteanalyse
### 8.1 Copyright-Feld
- Vorhanden: `copyright_notice` (max. 8000 Zeichen) in `media_assets`
- Pflichtfeld bei `exercise_media` mit `visibility='official'` (exercises-Router)
- **NICHT** Pflichtfeld beim direkten Upload in das Medienarchiv
- **NICHT** Pflichtfeld bei Promotion von `private` zu `club`
- **NICHT** dokumentiert: Wer hat erklärt? Wann? Welche Lizenzversion?
### 8.2 Rechteerklärung beim Upload
- Keine Einwilligungserklärung beim Upload: „Ich bestätige, alle Rechte an dieser Datei zu besitzen"
- Kein Upload-Dialog mit Pflicht-Checkbox
- Kein Hinweis auf verbotene Inhalte (Rechte Dritter, Persönlichkeitsrechte)
### 8.3 Recht am eigenen Bild
- Keine Abfrage, ob erkennbare Personen abgebildet sind
- Keine Abfrage, ob Minderjährige enthalten sind
- Keine Abfrage nach Einwilligung der abgebildeten Personen
- Juristisch zu prüfen: Anforderungen nach §22 KUG
---
## 9. Löschkonzeptanalyse
### 9.1 Stärken
- Klares 3-Stufen-Lifecycle-Modell (active → trash_soft → trash_hidden → purged)
- Superadmin-Direktlöschung als Sofortmaßnahme
- SHA256-Deduplizierung verhindert doppelte physische Dateien
- Datei-Relokation bei Sichtbarkeitsänderung implementiert
### 9.2 Lücken
| Problem | Risiko |
|---------|--------|
| Papierkorb-Job nicht automatisch geplant | Dateien bleiben physisch nach Ablauf der Fristen |
| Keine Löschung aus Backups dokumentiert | DSGVO Art. 17: Backup-Retention oder Löschprozess nötig |
| Kein Legal-Hold-Status | Bei Rechtsverletzung dauert es 30 Tage bis zur vollständigen Unsichtbarkeit |
| Kein Audit-Log für Löschgründe | Keine Nachvollziehbarkeit für DSA/DSGVO |
| Kein Uploader-Benachrichtigungssystem | Bei Sperrung / Löschung kein Feedback an Uploader |
---
## 10. PWA- / Storage-Analyse
### 10.1 Positiv
- Kein Service Worker → kein PWA-Cache-Risiko für Medien
- Keine Cookies → kein Cookie-Banner nötig (für Cookies)
- CSP-Header gesetzt: `script-src 'self'` (XSS-Mitigation)
### 10.2 LocalStorage-Bewertung
Die localStorage-Nutzung ist technisch notwendig (Auth, Mandantenkontext). Nach TDDDG §25 ist technisch notwendige Speicherung ohne Einwilligung zulässig. Dokumentation in der Datenschutzerklärung ist Pflicht.
### 10.3 Token-Sicherheit
- Auth-Token in `localStorage`: vulnerabel bei XSS
- CSP `script-src 'self'` reduziert XSS-Risiko erheblich
- Kein CSRF-Problem (Token im Header, nicht in Cookie)
- `HttpOnly`-Cookie wäre sicherer, erfordert Architekturanpassung
---
## 11. Datenschutzanalyse (DSGVO)
### 11.1 Identifizierte Verarbeitungsvorgänge
| Vorgang | Rechtsgrundlage (technisch) | VVT-Status |
|---------|----------------------------|-----------|
| Registrierung (Name, E-Mail, Passwort-Hash) | Vertrag (Art. 6 Abs. 1 lit. b) | ❌ Kein VVT |
| Login / Session-Management | Berechtigtes Interesse | ❌ Kein VVT |
| E-Mail-Versand | Vertragserfüllung | ❌ Kein VVT, SMTP-Anbieter unbekannt |
| Medienupload (Bilder/Videos) | Einwilligung oder Vertragserfüllung | ❌ Keine Einwilligung abgeholt |
| Vereinszugehörigkeit | Vertragserfüllung | ❌ Kein VVT |
| Training-Logging | Berechtigtes Interesse | ❌ Kein VVT |
| Backup (implizit) | Berechtigtes Interesse | ❌ Keine Retention dokumentiert |
### 11.2 Betroffenenrechte
| Recht | Status |
|-------|--------|
| Auskunft (Art. 15) | ❌ Kein Self-Service-Export |
| Berichtigung (Art. 16) | ⚠ Nur eigener Name/E-Mail über Einstellungen |
| Löschung (Art. 17) | ❌ Kein Self-Service-Löschung |
| Einschränkung (Art. 18) | ❌ Nicht implementiert |
| Datenübertragbarkeit (Art. 20) | ❌ Kein Export-Endpoint |
| Widerspruch (Art. 21) | ❌ Kein Mechanismus |
### 11.3 Auftragsverarbeiter (identifiziert)
| Dienst | Anbieter | AV-Vertrag |
|--------|----------|-----------|
| Hosting | Selbstbetrieb (Raspberry Pi) | Entfällt |
| SMTP / E-Mail | Unbekannt (Env-Variable) | ❌ Nicht dokumentiert |
| MediaWiki-Import | karatetrainer.net | ❌ Nicht dokumentiert |
| OpenRouter (KI) | OpenRouter.ai | ❌ Nicht dokumentiert |
### 11.4 Minderjährige
- Keine Altersverifikation bei Registrierung
- Keine besondere Schutzmaßnahme
- Juristisch zu prüfen: §8 DSGVO
---
## 12. DSA-/UGC-Analyse
### 12.1 Einordnung
Die App erlaubt Upload von User Generated Content (Bilder, Videos). Inhalte können öffentlich sichtbar sein (`official`-Stufe: plattformweit). Dies ist UGC im Sinne des DSA.
**Juristisch zu prüfen:** Ab welcher Nutzerzahl und unter welchen Voraussetzungen der DSA für diese Plattform gilt.
### 12.2 Fehlende Mechanismen
| DSA-Anforderung | Status |
|-----------------|--------|
| Meldeverfahren für rechtswidrige Inhalte | ❌ |
| „Inhalt melden"-Funktion | ❌ |
| Moderations-Backend mit Statuswerten | ❌ |
| Benachrichtigung des Uploaders bei Sperrung | ❌ |
| Begründung für Moderationsentscheidungen | ❌ |
| Beschwerdemechanismus | ❌ |
| Eskalation für schwere Inhalte (CSAM, Straftaten) | ❌ |
| Audit-Log für Meldungen und Entscheidungen | ❌ |
### 12.3 Was vorhanden ist (Notfall-Maßnahmen)
- Superadmin kann Inhalte sofort physisch löschen (`superadmin_hard_delete`)
- Lifecycle-System ermöglicht schrittweise Deaktivierung
- `official`-Promotion nur durch Superadmin (redaktioneller Prozess)
---
## 13. Sicherheitsanalyse
### 13.1 Positiv bewertete Maßnahmen
| Maßnahme | Status |
|----------|--------|
| HTTPS (Produktion via Reverse-Proxy) | ✓ |
| bcrypt Passwort-Hashing mit Legacy-SHA256-Upgrade | ✓ |
| Rate-Limiting (slowapi) | ✓ |
| CSRF-Schutz (Token im Header, nicht Cookie) | ✓ |
| SQL-Injection-Schutz (parameterisierte Queries) | ✓ |
| CSP-Header (nginx) | ✓ |
| X-Content-Type-Options: nosniff (nginx + FastAPI-Middleware) | ✓ |
| X-Frame-Options: SAMEORIGIN | ✓ |
| Referrer-Policy: strict-origin-when-cross-origin | ✓ |
| Permissions-Policy (camera/mic/geo) | ✓ |
| OpenAPI in Produktion deaktiviert | ✓ |
| DB-Port nur localhost exponiert | ✓ |
| MIME-Type-Validierung beim Upload | ✓ |
| SHA256-Integritätsprüfung + Deduplizierung | ✓ |
| Secrets in .env (nicht im Code) | ✓ |
| User-Enumeration verhindert (forgot-password, resend-verification) | ✓ |
| Path-Traversal-Schutz in media_storage.py (`path_under_media_root` + `.relative_to()`) | ✓ |
| Club-Name-Slugify: nur `[a-z0-9-]` im Dateipfad | ✓ |
| CORS: Origins eingeschränkt (ALLOWED_ORIGINS aus Env) | ✓ |
### 13.2 Sicherheitslücken
| ID | Titel | Schwere | Datei/Nachweis |
|----|-------|---------|----------------|
| SEC-01 | Kein HSTS-Header | Hoch | `frontend/nginx.conf` kein `Strict-Transport-Security` |
| SEC-02 | Auth-Token in localStorage | Mittel | `frontend/src/context/AuthContext.jsx:47` |
| SEC-03 | `style-src 'unsafe-inline'` in CSP | Niedrig | `frontend/nginx.conf:23` |
| SEC-04 | Passwort-Mindestlänge inkonsistent: Backend 3 Stellen, Frontend-Feld minLength=6, Backend-Register-Minimum=8 | Mittel | `backend/routers/auth.py:104` (`< 4`), `frontend/src/pages/LoginPage.jsx:175` (`minLength="6"`) |
| SEC-05 | ALLOW_PUBLIC_MEDIA_STATIC umgeht Auth für alle Medien | Hoch | `backend/main.py:222-223` |
| SEC-06 | Kein MFA für Superadmins | Mittel | Kein TOTP/OTP implementiert |
| SEC-07 | Kein Audit-Log für Admin-Aktionen | Mittel | Keine `admin_audit_log`-Tabelle |
| SEC-08 | Password-Reset-Token in sessions-Tabelle (Präfix `reset_`) | Niedrig | `backend/routers/auth.py:143` |
| SEC-09 | Kein Backup-Konzept dokumentiert | Mittel | Kein Backup-Skript im Repo |
| SEC-10 | Kein Anti-Virus-Scan für Uploads | Niedrig | Kein ClamAV o.ä. |
| SEC-11 | Kein genereller HTML-Sanitizer für Rich-Text-Felder | Mittel | `backend/exercise_rich_text.py` nur Inline-Media-Normalisierung, kein bleach/nh3 |
| SEC-12 | `minLength="6"` im Login-Formular, Backend fordert 8 Zeichen | Niedrig | `frontend/src/pages/LoginPage.jsx:175` |
| SEC-13 | Hartcodierte Versionsangabe `v0.1.0 • Development` auf Login-Seite (falsch + Info-Leak) | Niedrig | `frontend/src/pages/LoginPage.jsx:242` |
| SEC-14 | CORS: `allow_methods=["*"]` und `allow_headers=["*"]` breiter als nötig | Niedrig | `backend/main.py:84-87` |
### 13.3 Ergänzende Befunde aus Restprüfung
#### main.py — CORS-Konfiguration
```python
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, # ✓ aus Env, keine Wildcard-Origins
allow_credentials=True, # ✓ korrekt (kein * + credentials)
allow_methods=["*"], # ⚠ breiter als nötig
allow_headers=["*"], # ⚠ breiter als nötig
)
```
`allow_credentials=True` in Kombination mit `allow_origins=["*"]` wäre ein kritischer Fehler (FastAPI würde ihn aber abweisen). Durch die explizite Origin-Liste ist das Risiko gering. `allow_methods=["*"]` und `allow_headers=["*"]` könnten auf die tatsächlich benötigten Methoden (GET, POST, PUT, PATCH, DELETE) und Header (X-Auth-Token, X-Active-Club-Id, Content-Type) eingeschränkt werden.
#### media_storage.py — Path-Traversal-Schutz (positiv)
`path_under_media_root()` kombiniert zwei unabhängige Prüfungen:
1. String-Prüfung: `".." in key.split("/")`
2. Filesystem-Prüfung: `p.relative_to(media_root.resolve())`
Die Dateiendung wird auf 16 Zeichen begrenzt. Club-Namen werden auf `[a-z0-9-]` normalisiert. SHA256 als Dateiname ist manipulationssicher. **Bewertung: Gut implementiert, kein Path-Traversal-Risiko erkennbar.**
#### exercise_rich_text.py — Fehlender genereller HTML-Sanitizer
Das Modul normalisiert ausschließlich das Inline-Media-Markup (`{{exerciseMedia:id}}` → `<span>`). Es enthält **keinen** generellen HTML-Sanitizer (kein bleach, lxml-cleaner, nh3 o.ä.).
Felder in `RICH_HTML_EXERCISE_FIELDS` (`summary`, `goal`, `execution`, `preparation`, `trainer_notes`) können beliebiges HTML enthalten. Risikominderung:
- CSP `script-src 'self'` verhindert `<script>`-Ausführung
- React's `dangerouslySetInnerHTML` muss im Frontend für XSS genutzt werden — zu prüfen
- Betroffene Felder sind nur für eingeloggte Nutzer sichtbar (kein öffentlicher Angriffspfad)
**Empfehlung:** bleach oder nh3 für Allowlist-basierte HTML-Sanitierung einsetzen.
#### LoginPage.jsx — Weitere Befunde
1. **Keine Rechtstexte-Links:** Kein Link auf Impressum oder Datenschutzerklärung (bestätigt KRIT-01)
2. **`minLength="6"`:** Browser-seitig 6 Zeichen, Backend erzwingt 8 → UX-Bruch, Nutzer sieht kein Frontend-Feedback
3. **Hartcodierter Versionsstring:** `v0.1.0 • Development` ist öffentlich sichtbar, falsch (App ist 0.8.65) und leakt Umgebungsinfo
### 13.5 Warnung: ALLOW_PUBLIC_MEDIA_STATIC (SEC-05)
Das Flag `ALLOW_PUBLIC_MEDIA_STATIC=1` würde bei Aktivierung alle Mediendateien ohne Auth unter `/media/` ausliefern und das gesamte Sichtbarkeitskonzept (privat, Verein, offiziell) für alle gespeicherten Mediendateien unterlaufen. Bestätigt in `backend/main.py:222-223`:
```python
if os.getenv("ALLOW_PUBLIC_MEDIA_STATIC", "").strip().lower() in ("1", "true", "yes"):
app.mount("/media", StaticFiles(directory=_media_dir), name="media")
```
**Dieses Flag darf in Produktionsumgebungen niemals gesetzt sein.** Muss in der Betriebsdokumentation explizit verboten und per Release-Checkliste überprüft werden.
---
## 14. Dokumentationsanalyse
### 14.1 Vorhandene technische Dokumentation
- `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`
- `backend/version.py` mit CHANGELOG ✓
- `backend/scripts/check_access_layer_hints.py`
### 14.2 Fehlende rechtliche Dokumentation
| Dokument | Status |
|----------|--------|
| Impressum | ❌ Keine Seite, kein Inhalt, keine Route |
| Datenschutzerklärung | ❌ |
| Nutzungsbedingungen / AGB | ❌ |
| Medienrichtlinie / Community-Regeln | ❌ |
| Verzeichnis der Verarbeitungstätigkeiten (VVT) | ❌ |
| AV-Verträge mit Auftragsverarbeitern | ❌ |
| Backup-Konzept schriftlich | ❌ |
### 14.3 Fehlende Frontend-Routen
In `frontend/src/App.jsx` fehlen:
- `/impressum`
- `/datenschutz`
- `/nutzungsbedingungen`
- `/medienrichtlinie`
Alle müssen **vor der Authentifizierung** erreichbar und in der PWA verfügbar sein.
---
## 15. Testanalyse
### 15.1 Vorhandene Tests
| Test-Datei | Inhalt |
|-----------|--------|
| `test_access_layer.py` | Sichtbarkeits-SQL-Logik |
| `test_access_layer_integration.py` | Cross-Tenant-Isolation mit echter DB |
| `test_exercises_delete_policy.py` | DELETE-Logik |
| `test_exercise_media_download.py` | Download-Zugriffsschutz |
| `test_official_exercise_media_rules.py` | Official-Promotion + Copyright |
| `test_media_assets_archive.py` | Deduplizierung, Lifecycle |
| `test_club_exercise_media_copyright.py` | Copyright bei Vereinsübungen |
| `test_exercise_inline_post.py` | Inline-Medien-Validierung |
| `test_exercise_rich_text.py` | HTML-Sanitizer |
| `test_security_release.py` | OpenAPI/Health in Produktion |
| `test_profiles_read_access.py` | Profil-Zugriffsrechte |
### 15.2 Testlücken
| Bereich | Status |
|---------|--------|
| DSA-Meldeverfahren | ❌ Funktion fehlt |
| DSGVO-Betroffenenrechte (Löschung, Export) | ❌ Funktion fehlt |
| Minderjährigen-Check beim Upload | ❌ Funktion fehlt |
| Einwilligungs-Check beim Upload | ❌ Funktion fehlt |
| ALLOW_PUBLIC_MEDIA_STATIC=1 aktiviert | ❌ Nicht getestet |
| Admin-Audit-Log | ❌ Funktion fehlt |
| Passwort-Mindestlänge bei PIN-Änderung | ❌ Nicht getestet |
| Copyright-Pflicht bei Archiv-Promotion | ❌ Nicht getestet |
---
## 16. Risiko-Matrix
### KRITISCH
| ID | Titel | Beschreibung | Betroffene Dateien | Juristisch prüfen | Aufwand |
|----|-------|-------------|-------------------|-------------------|---------|
| KRIT-01 | Keine Rechtstexte | Keine Impressum-, Datenschutz-, AGB- oder Medienrichtlinien-Seite. Öffentlich erreichbare App ohne Impressum ist Ordnungswidrigkeit (TMG §5, TDDDG). | `frontend/src/App.jsx` keine Routen | Ja | 25 Tage (Technik) + Rechtsanwalt |
| KRIT-02 | Kein Self-Service-Löschrecht | Nutzer können ihr Konto nicht selbst löschen (DSGVO Art. 17). Nur Plattform-Admin kann Konten löschen. | `backend/routers/profiles.py:414` | Ja | 58 Tage |
| KRIT-03 | Kein DSA-Meldeverfahren | Keine Möglichkeit, rechtswidrige Inhalte zu melden. Kein Moderationssystem für UGC-Plattform. | Kein Router vorhanden | Ja | 1020 Tage |
| KRIT-04 | Kein Recht-am-eigenen-Bild-Check | Keine Abfrage bei Medienupload, ob Personen oder Minderjährige abgebildet sind. | `backend/routers/exercises.py`, `backend/routers/media_assets.py` | Ja | 37 Tage |
| KRIT-05 | Kein DSGVO-Self-Service (Auskunft, Export) | Keine Datenauskunft, kein Datenexport, keine Berichtigungsmöglichkeit (DSGVO Art. 15, 16, 20). | Kein Endpoint | Ja | 510 Tage |
| KRIT-06 | Copyright-Pflicht inkonsistent | Copyright ist Pflichtfeld nur im exercises-Router für `official`. Im media_assets-Router (Archiv) kann ohne Copyright zu `club`/`official` promoted werden. | `backend/routers/media_assets.py:766784` | Ja | 12 Tage |
| KRIT-07 | Papierkorb-Job nicht geplant | `media_retention_job.py` existiert, aber kein Cron-Job konfiguriert. Medien bleiben physisch nach Ablauf der DSGVO-Fristen auf dem Server. | `backend/media_retention_job.py` | Nein | 1 Tag |
### HOCH
| ID | Titel | Beschreibung | Aufwand |
|----|-------|-------------|---------|
| HOCH-01 | ALLOW_PUBLIC_MEDIA_STATIC | Wenn gesetzt, sind alle Mediendateien ohne Auth abrufbar untergräbt gesamtes Sichtbarkeitskonzept | Dokumentation + Test |
| HOCH-02 | Kein HSTS | Kein `Strict-Transport-Security`-Header in nginx.conf | Dokumentation (Reverse-Proxy) |
| HOCH-03 | Auth-Token in localStorage | XSS könnte Token extrahieren; CSP reduziert, eliminiert nicht | Dokumentation (HttpOnly als Langfrist-Plan) |
| HOCH-04 | Kein MFA für Superadmins | Superadmin hat unbegrenzten Systemzugriff ohne zweiten Faktor | 58 Tage |
| HOCH-05 | Kein Admin-Audit-Log | Profil-Löschungen, Lifecycle-Aktionen nicht geloggt | 35 Tage |
| HOCH-06 | Keine Mindestalter-Abfrage | Keine Schranke gegen Registrierung Minderjähriger | 12 Tage |
### MITTEL
| ID | Titel | Beschreibung | Aufwand |
|----|-------|-------------|---------|
| MITT-01 | Passwort-Mindestlänge inkonsistent | Register: 8 Zeichen, `PUT /api/auth/pin`: 4 Zeichen | 30 Min |
| MITT-02 | Keine Sofortsperrung bei Rechtsverletzung | Stufe-1-Papierkorb dauert 30 Tage bis zur vollständigen Unsichtbarkeit | 23 Tage |
| MITT-03 | Kein VVT | Kein Verzeichnis der Verarbeitungstätigkeiten (DSGVO Art. 30) | Betreiber |
| MITT-04 | SMTP-Anbieter unbekannt | Kein AV-Vertrag; E-Mail-Dienstleister nicht dokumentiert | Betreiber |
| MITT-05 | sessionStorage nicht bei Logout bereinigt | TrainingCoachPage-Fortschritt bleibt nach Logout im sessionStorage | 0,5 Tage |
| MITT-06 | Keine Löschung aus Backups | DSGVO-Löschungsanfragen greifen nicht auf Backups | Betreiber (Policy) |
| MITT-07 | MediaWiki-Integration ohne AV-Vertrag | Datentransfer zu karatetrainer.net nicht dokumentiert | Betreiber |
| MITT-08 | OpenRouter ohne AV-Vertrag | Erst aktivieren wenn AV-Vertrag vorhanden | Betreiber |
### NIEDRIG
| ID | Titel | Beschreibung | Aufwand |
|----|-------|-------------|---------|
| NIED-01 | `style-src 'unsafe-inline'` | Inline-Styles in CSP erlaubt | Nonce/Hash |
| NIED-02 | Kein Anti-Virus-Scan | Malware-Dateien hochladbar | ClamAV-Integration |
| NIED-03 | Reset-Token in sessions-Tabelle | Token mit `reset_`-Präfix in Auth-Tabelle | Separate Tabelle |
| NIED-04 | SHA256-Hash in API-Response | Datei-Fingerprinting möglich | Response-Filterung |
| NIED-05 | Kein Passwort-Complexity-Check | Nur Mindestlänge geprüft | zxcvbn o.ä. |
| NIED-06 | `minLength="6"` im Login-Formular | Inkonsistent mit Backend (8 Zeichen); Browser lässt 6-7-char-Passwörter zu, Backend lehnt sie dann ab | `frontend/src/pages/LoginPage.jsx:175` |
| NIED-07 | Hartcodierter Versionsstring auf Login-Seite | `v0.1.0 • Development` sichtbar ohne Auth; falsche Version (0.8.65) + Umgebungsinfo | `frontend/src/pages/LoginPage.jsx:242` |
| NIED-08 | CORS allow_methods/headers=`["*"]` | Breiter als nötig; Origins sind korrekt eingeschränkt, aber Methoden/Header nicht | `backend/main.py:84-87` |
| NIED-09 | Kein genereller HTML-Sanitizer für Rich-Text | `exercise_rich_text.py` bereinigt nur Inline-Media-Markup; beliebiges HTML in `summary`, `goal`, `execution` etc. möglich (CSP schützt gegen Script-Execution) | `backend/exercise_rich_text.py` |
---
## 17. Umsetzungsplan
### Empfohlene Reihenfolge
**Etappe 1 Pflicht vor öffentlichem Betrieb (Kritische Blocker)**
| Paket-ID | Titel | Findings | Aufwand |
|----------|-------|---------|---------|
| P-01 | Rechtstexte (Seiten + Routen, Inhalte durch Rechtsanwalt) | KRIT-01 | 25 Tage Technik |
| P-02 | Self-Service-Kontolöschung + Datenexport | KRIT-02, KRIT-05 | 58 Tage |
| P-03 | Papierkorb-Retention-Job aktivieren | KRIT-07 | 1 Tag |
| P-04 | Copyright-Pflicht für Archiv-Promotion vereinheitlichen | KRIT-06 | 1 Tag |
| P-05 | Passwort-Mindestlänge angleichen | MITT-01 | 30 Min |
| P-06 | Upload-Einwilligungsdialog (Personen, Minderjährige, Rechte) | KRIT-04 | 24 Tage |
**Etappe 2 Sicherheit und Datenschutz (dringend empfohlen)**
| Paket-ID | Titel | Findings | Aufwand |
|----------|-------|---------|---------|
| P-07 | ALLOW_PUBLIC_MEDIA_STATIC dokumentieren + Test | HOCH-01, SEC-05 | 0,5 Tage |
| P-08 | HSTS und externe Proxy-Sicherheit dokumentieren | HOCH-02, SEC-01 | 0,5 Tage |
| P-09 | Admin-Audit-Log | HOCH-05, SEC-07 | 35 Tage |
| P-10 | Mindestalter-Abfrage | HOCH-06 | 12 Tage |
| P-11 | Legal-Hold Lifecycle-Status | MITT-02 | 23 Tage |
| P-12 | sessionStorage bei Logout bereinigen | MITT-05 | 0,5 Tage |
| P-22 | HTML-Sanitizer für Rich-Text-Felder (bleach/nh3) | NIED-09, SEC-11 | 12 Tage |
| P-23 | LoginPage: minLength angleichen + Version entfernen | NIED-06, NIED-07, SEC-12, SEC-13 | 1 Stunde |
| P-24 | CORS einschränken (Methoden + Header) | NIED-08, SEC-14 | 1 Stunde |
**Etappe 3 DSA-Meldeverfahren (mittelfristig, nach juristischer Klärung)**
| Paket-ID | Titel | Findings | Aufwand |
|----------|-------|---------|---------|
| P-13 | Content-Melde-Backend (content_reports-Tabelle + Endpoints) | KRIT-03 | 58 Tage |
| P-14 | Moderations-UI (Frontend) | KRIT-03 | 35 Tage |
| P-15 | Uploader-Benachrichtigung bei Sperrung | KRIT-03 | 12 Tage |
| P-16 | Beschwerdeverfahren | KRIT-03 | 24 Tage |
**Etappe 4 Langfristige Optimierungen**
| Paket-ID | Titel | Aufwand |
|----------|-------|---------|
| P-17 | MFA für Superadmins (TOTP) | 58 Tage |
| P-18 | HttpOnly-Cookie als Auth-Alternative | 35 Tage |
| P-19 | Anti-Virus-Scan (ClamAV) | 35 Tage |
| P-20 | VVT erstellen | Betreiber |
| P-21 | AV-Verträge abschließen | Betreiber |
---
### P-04 im Detail: Copyright-Pflicht vereinheitlichen
**Technische Änderung Backend:**
In `backend/routers/media_assets.py`, Funktionen `patch_media_asset()` und `bulk_media_patch()`:
- Bei Visibility-Wechsel zu `club` oder `official`: `copyright_notice` muss vorhanden und min. 3 Zeichen lang sein
- Fehler: HTTP 422 mit klarer Fehlermeldung
**Test:** Promotion ohne copyright_notice → HTTP 422; mit copyright_notice → OK
---
### P-05 im Detail: Passwort-Mindestlänge angleichen
**Technische Änderung:**
- `backend/routers/auth.py:104`: `if len(new_pin) < 8:` statt `< 4`
- Fehlermeldung: „Passwort muss mindestens 8 Zeichen lang haben"
---
### P-13 im Detail: Content-Melde-Backend
**Neue DB-Migration (`backend/migrations/047_content_reports.sql`):**
```sql
CREATE TABLE content_reports (
id SERIAL PRIMARY KEY,
reporter_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
target_type VARCHAR(20) NOT NULL CHECK (target_type IN ('exercise', 'media_asset')),
target_id INTEGER NOT NULL,
reason VARCHAR(50) NOT NULL
CHECK (reason IN ('illegal_content', 'copyright', 'personal_rights',
'minor_protection', 'hate_speech', 'spam', 'other')),
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'under_review', 'resolved_removed',
'resolved_kept', 'rejected')),
moderator_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
moderator_note TEXT,
resolved_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
**Neue Endpoints:**
- `POST /api/reports` Meldung einreichen (require_auth)
- `GET /api/admin/reports` Moderations-Queue (Admin/Superadmin)
- `PATCH /api/admin/reports/{id}` Status setzen + Notiz
---
## 18. Dauerhafter Auditplan
### 18.1 Audit-Frequenzen
| Typ | Frequenz |
|-----|----------|
| Mini-Audit | Bei jedem PR mit Compliance-Relevanz |
| Release-Audit | Vor jedem Produktions-Deployment |
| Quartalsaudit | Alle 3 Monate |
| Jahresaudit | Einmal jährlich (mit Rechtsanwalt/Datenschutzbeauftragtem) |
### 18.2 Sonderaudit-Auslöser
- Neue öffentlich sichtbare Features
- Neue Upload-Funktionen (Medientypen, Quellen)
- Neue Rollen oder Rechteänderungen
- Neue externe Dienstleister
- Einführung von Analytics oder Tracking
- Änderungen am Minderjährigenschutz
- Sicherheitsvorfall oder Datenpanne
- Datenschutzanfrage eines Nutzers
- Meldung rechtswidriger Inhalte
- Größere Architekturänderungen
- Gesetzesänderungen (DSGVO, DSA, TDDDG)
### 18.3 Checkliste: Mini-Audit bei Feature-PR
```
[ ] Betrifft der PR personenbezogene Daten? → DSGVO-Check
[ ] Betrifft der PR Medienupload oder -anzeige? → Rechte-Check + DSA-Check
[ ] Betrifft der PR Rollen oder Sichtbarkeit? → Access-Layer-Check
[ ] Betrifft der PR Storage oder Caching? → TDDDG-Check
[ ] Betrifft der PR externe Dienste? → AV-Vertrag prüfen
[ ] Betrifft der PR Auth oder Session? → Sicherheits-Check
[ ] Neuer Endpoint → require_auth / get_tenant_context vorhanden?
[ ] Neue Datenspalte → Migration + Lösch-Cascade korrekt?
[ ] Neue Frontend-Speicherung → TDDDG-Klassifikation dokumentiert?
[ ] ACCESS_LAYER: python backend/scripts/check_access_layer_hints.py
```
### 18.4 Checkliste: Release-Audit
```
[ ] Versions-Bump (backend/version.py + frontend/src/version.js)
[ ] ALLOW_PUBLIC_MEDIA_STATIC nicht in Prod-Env gesetzt
[ ] OpenAPI/Swagger nicht öffentlich (ENVIRONMENT=production)
[ ] HSTS am externen Reverse-Proxy konfiguriert (manuell prüfen)
[ ] Papierkorb-Retention-Job läuft (Logs prüfen)
[ ] SSL-Zertifikat gültig
[ ] DB-Backup der letzten 24h vorhanden
[ ] Rechtstexte aktuell (kein Placeholder, kein veraltetes Datum)
[ ] pytest -m "not slow" grün
[ ] ACCESS_LAYER_INTEGRATION=1 pytest tests/test_access_layer_integration.py grün
[ ] test_security_release.py grün
[ ] pip-audit (keine kritischen CVEs)
[ ] npm audit --audit-level=high (keine kritischen CVEs)
```
### 18.5 Checkliste: Neues Feature
```
[ ] Verarbeitet das Feature personenbezogene Daten? → VVT aktualisieren
[ ] Neue DB-Tabellen → Lösch-Cascade und Retention definiert?
[ ] Neue API-Endpoints → Access Layer korrekt?
[ ] Feature mit UGC? → DSA-Meldeverfahren abgedeckt?
[ ] Feature mit Medien? → Lifecycle, Sichtbarkeit, Copyright?
[ ] Feature für Minderjährige relevant? → Schutzmaßnahmen?
[ ] Neue localStorage/sessionStorage-Nutzung → TDDDG + Datenschutzerklärung?
[ ] Neue externe Abhängigkeit → AV-Vertrag?
[ ] Keine sensitiven Daten in Fehlerausgaben?
[ ] api.js für alle Frontend API-Calls?
[ ] Tests geschrieben und grün?
```
### 18.6 Checkliste: Änderung am Medienmodell
```
[ ] Neue Medientypen → MIME-Type-Validierung aktualisiert?
[ ] Neue Sichtbarkeitsstufen → library_content_visibility_sql() aktualisiert?
[ ] Lifecycle-Änderungen → Papierkorb-Job angepasst?
[ ] Copyright-Anforderungen konsistent für alle Promotions-Wege?
[ ] ALLOW_PUBLIC_MEDIA_STATIC explizit: In Prod verboten (dokumentiert)?
[ ] Neue Download-Endpoints → require_auth_flexible implementiert?
[ ] Recht-am-eigenen-Bild-Abfragen aktualisiert?
```
### 18.7 Checkliste: Änderung am Rollenmodell
```
[ ] Neue Rollen → assert_valid_governance_visibility() berücksichtigt?
[ ] EXEMPT-Liste in check_access_layer_hints.py aktualisiert?
[ ] test_access_layer*.py angepasst?
[ ] MFA-Anforderung für neue Admin-Rollen geprüft?
[ ] Audit-Log für neue Aktionen?
```
### 18.8 Pflichttests vor Release
```bash
# Zugriffskontrolle
pytest backend/tests/test_access_layer.py -v
# Cross-Tenant-Integration (mit PostgreSQL)
ACCESS_LAYER_INTEGRATION=1 pytest backend/tests/test_access_layer_integration.py -v
# Mediensicherheit
pytest backend/tests/test_exercise_media_download.py -v
pytest backend/tests/test_official_exercise_media_rules.py -v
pytest backend/tests/test_media_assets_archive.py -v
pytest backend/tests/test_club_exercise_media_copyright.py -v
# Sicherheits-Release-Checks
pytest backend/tests/test_security_release.py -v
# Access Layer Script (Strict Mode)
ACCESS_LAYER_STRICT=1 python backend/scripts/check_access_layer_hints.py
# Abhängigkeits-Scan
cd backend && pip-audit
cd frontend && npm audit --audit-level=high
```
### 18.9 Vorlage: Quartalsaudit-Bericht
```markdown
# Compliance-Quartalsaudit Q[X]/[Jahr]
Datum: [DATUM] | Auditor: [NAME]
## 1. Status offener Findings
[Tabelle: ID Titel Status]
## 2. Neue Findings dieser Periode
[Tabelle: ID Titel Schwere Aufwand]
## 3. Rechtstexte
[ ] Impressum geprüft: [DATUM]
[ ] Datenschutzerklärung geprüft: [DATUM]
[ ] AGB/Nutzungsbedingungen geprüft: [DATUM]
[ ] Medienrichtlinie geprüft: [DATUM]
## 4. DSGVO-Status
[ ] VVT aktuell
[ ] AV-Verträge vollständig
[ ] Backup-Retention-Policy dokumentiert
[ ] Betroffenenrechte-Mechanismen getestet
## 5. DSA-Status
[ ] Meldeverfahren Test-Meldung durchgeführt: [DATUM]
[ ] Offene Meldungen: [X] (alle bearbeitet: Ja/Nein)
[ ] Eskalationspfad dokumentiert: Ja/Nein
## 6. Technische Sicherheit
[ ] Abhängigkeits-Scan: [DATUM] Kritische CVEs: [X]
[ ] SSL gültig bis: [DATUM]
[ ] HSTS konfiguriert: Ja/Nein
[ ] Alle Pflichttests grün: Ja/Nein
## 7. Empfehlungen
[Liste]
```
---
## 19. Entscheidungsvorlage
### 19.1 Was ist aktuell kritisch?
1. Keine Rechtstexte App ist öffentlich ohne Impressum (Ordnungswidrigkeit)
2. Kein Löschrecht für Nutzer DSGVO Art. 17 verletzt
3. Kein DSA-Meldeverfahren bei UGC-Plattform möglicherweise Pflicht
4. Copyright-Lücke Archiv-Upload ohne Copyright-Pflicht bei Promotion
5. Kein Recht-am-eigenen-Bild-Check bei Upload
### 19.2 Was blockiert sicheren öffentlichen Betrieb (technisch)?
1. KRIT-01: Rechtstexte fehlen vollständig
2. KRIT-02: Kein Self-Service-Löschrecht
3. KRIT-07: Papierkorb-Job läuft nicht automatisch
4. HOCH-01: ALLOW_PUBLIC_MEDIA_STATIC muss in Prod-Doku explizit verboten sein
5. HOCH-02: HSTS am externen Reverse-Proxy überprüfen
### 19.3 Mindest-Paket vor erstem öffentlichem Betrieb
- P-05 (30 Min): Passwort-Mindestlänge angleichen
- P-04 (1 Tag): Copyright-Pflicht vereinheitlichen
- P-03 (1 Tag): Papierkorb-Job aktivieren
- P-06 (2 Tage): Upload-Einwilligungsdialog
- P-01 (2 Tage Technik + Rechtsanwalt): Rechtstexte-Seiten
- P-02 (5 Tage): Self-Service-Kontolöschung
### 19.4 Was kann zurückgestellt werden?
- P-17 (HttpOnly-Cookie): Größere Architekturänderung, kein unmittelbarer Compliance-Bedarf
- P-19 (Anti-Virus-Scan): Hoher Aufwand, Risiko bei lokalem Storage gering
- P-13 bis P-16 (DSA-Meldeverfahren): Erst juristisch klären ob und in welchem Umfang erforderlich
### 19.5 Juristisch zu prüfende Fragen
| Thema | Frage |
|-------|-------|
| DSGVO Art. 17 | Wie muss Löschung aus Backups gehandhabt werden? |
| DSA | Ab welcher Nutzerzahl gilt der DSA? Welche Pflichten für Kleinplattformen? |
| §22 KUG | Welche Einwilligungen für Personenbilder im Vereinskontext? |
| §8 DSGVO | Mindestalter für die Registrierung? |
| TDDDG §25 | Muss für localStorage eine Einwilligung eingeholt werden? |
| Impressum-Pflicht | Vollständige Angaben des Verantwortlichen? |
| AV-Verträge | Welche Dienstleister benötigen einen AVV? |
| MediaWiki | Lizenzanforderungen für Übungsinhalte aus karatetrainer.net? |
### 19.6 Organisatorische Aufgaben für den Betreiber
1. Rechtsanwalt beauftragen (Rechtstexte)
2. VVT erstellen (DSGVO Art. 30)
3. AV-Verträge abschließen (SMTP-Anbieter, ggf. MediaWiki)
4. Backup-Prozess dokumentieren und Restore-Tests durchführen
5. Moderationsprozess definieren
6. Notfallkontakt für Datenpannen benennen
7. HSTS am externen Reverse-Proxy sicherstellen
8. Papierkorb-Job-Monitoring einrichten
### 19.7 Verbleibende Risiken nach technischer Umsetzung
- HSTS liegt außerhalb des Repos (Betreiber-Verantwortung)
- Backup-Löschung bei DSGVO-Anfragen erfordert manuelle Prozesse
- Minderjährige können Altersangabe fälschen (kein verlässlicher Online-Altersnachweis)
- Rechtswidrige Inhalte können zwischen Upload und Moderationsentscheidung sichtbar sein
- SMTP-Anbieter kann E-Mail-Inhalte verarbeiten
### 19.8 Freigabe-Formulierungen
Verwende diese exakten Formulierungen zur Freigabe einzelner Pakete:
| Paket | Freigabe-Formulierung |
|-------|----------------------|
| P-05 | „Freigabe zur Umsetzung P-05: Passwort-Mindestlänge angleichen" |
| P-04 | „Freigabe zur Umsetzung P-04: Copyright-Pflicht vereinheitlichen" |
| P-03 | „Freigabe zur Umsetzung P-03: Papierkorb-Retention-Job aktivieren" |
| P-06 | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
| P-01 | „Freigabe zur Umsetzung P-01: Rechtstexte-Seiten technisch anlegen" |
| P-02 | „Freigabe zur Umsetzung P-02: Self-Service-Kontolöschung und Datenexport" |
| P-09 | „Freigabe zur Umsetzung P-09: Admin-Audit-Log" |
| P-13 | „Freigabe zur Umsetzung P-13: Content-Melde-Backend" |
| Etappe 1 komplett | „Freigabe zur Umsetzung Etappe 1" |
| Etappe 1+2 | „Freigabe zur Umsetzung Etappen 1 und 2" |
---
**Audit abgeschlossen. Keine Codeänderungen vorgenommen. Umsetzung erst nach ausdrücklicher Freigabe.**
---
*Dokument erstellt: 2026-05-09 | Auditor: Claude Code | Kein Rechtsanwalt; alle rechtlichen Einschätzungen sind juristisch zu prüfen.*

View File

@ -0,0 +1,154 @@
# Compliance-Implementierung Umsetzungsbericht
**Erstellt:** 2026-05-09
**Audit-Basis:** `docs/compliance-audit.md`
**App-Version nach Umsetzung:** 0.8.66
---
## Freigegebene Pakete und Umsetzungsstatus
### P-03 Papierkorb-Retention-Job aktivieren ✅
**Status:** Umgesetzt
**Betroffene Dateien:**
- `docker-compose.yml` neuer Service `retention-cron`
**Technische Änderung:**
Neuer Docker-Service `retention-cron` nutzt dasselbe Backend-Image und führt `scripts/media_retention_job.py` täglich um 03:00 Uhr aus. Der Service startet beim ersten Hochfahren sofort und schläft bis zum nächsten 3 AM (Python-basierter Loop ohne externe Cron-Abhängigkeit). Zugriff auf DB und Media-Volume identisch zur Backend-Konfiguration.
**Tests:** Keine automatisierten Tests möglich (Runtime-Verhalten); operativ über Container-Logs (`docker logs shinkan-retention-cron`) überprüfbar.
**Anmerkungen:**
- Retention-Zeiten über Env-Variablen konfigurierbar: `MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS` (Default 30), `MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS` (Default 90)
- Das Skript selbst (`scripts/media_retention_job.py`) war bereits korrekt implementiert; nur der Scheduler fehlte
---
### P-04 Copyright-Pflicht bei Archiv-Promotion vereinheitlichen ✅
**Status:** Umgesetzt
**Betroffene Dateien:**
- `backend/routers/media_assets.py` `patch_media_asset()` und `bulk_media_patch()`
**Technische Änderung:**
Beide Endpoints prüfen nun, ob `copyright_notice` vorhanden ist, wenn `visibility` auf `club` oder `official` gewechselt wird. Priorität: Wert aus dem Request-Body > bestehender Wert in der DB. Ist beides leer, wird HTTP 400 zurückgegeben.
Single PATCH: `raise HTTPException(status_code=400, ...)`
Bulk PATCH: Asset wird in die `failed`-Liste eingetragen und übersprungen, Gesamtoperation läuft weiter.
**Tests:** `backend/tests/test_media_assets_copyright_promotion.py` (7 Tests, alle grün):
- Promotion zu `club` ohne Copyright → 400
- Promotion zu `official` ohne Copyright → 400
- Promotion zu `club` mit Copyright im Body → nicht 400
- Promotion zu `club`, Copyright bereits auf Asset → nicht 400
- Kein Visibility-Wechsel → keine Copyright-Prüfung → 200
- Bulk: ohne Copyright → in `failed`, `updated_count == 0`
- Bulk: mit Copyright → in `updated`, `updated_count == 1`
---
### P-05 Passwort-Mindestlänge angleichen ✅
**Status:** Umgesetzt
**Betroffene Dateien:**
- `backend/routers/auth.py:101` `PUT /api/auth/pin`
- `frontend/src/pages/LoginPage.jsx:175` Registrierungs-/Login-Formular
- `frontend/src/pages/AccountSettingsPage.jsx:403` Passwort-Änderungsformular
**Technische Änderung:**
- Backend: `if len(new_pin) < 4``if len(new_pin) < 8` (Fehlermeldung angepasst)
- Frontend LoginPage: `minLength="6"``minLength="8"`
- Frontend AccountSettingsPage: `minLength={4}``minLength={8}`
Alle drei Stellen sind jetzt konsistent mit dem bereits korrekten `POST /api/auth/register` (war schon `< 8`).
**Tests:** Keine neuen Tests; Änderung ist trivial und durch bestehende Auth-Tests (Register) indirekt abgedeckt.
---
### P-07 ALLOW_PUBLIC_MEDIA_STATIC dokumentieren + Release-Test ✅
**Status:** Umgesetzt
**Betroffene Dateien:**
- `backend/tests/test_security_release.py` 2 neue Tests
**Technische Änderung:**
Zwei neue Tests in der bestehenden Release-Test-Suite:
1. `test_public_media_static_not_mounted_by_default`: Verifiziert, dass `/media`-Mount ohne `ALLOW_PUBLIC_MEDIA_STATIC` in der App-Route-Liste nicht vorhanden ist (sicherer Standardzustand in Production).
2. `test_allow_public_media_static_activates_media_mount`: Dokumentiert den Effekt des Flags wenn gesetzt, ist der Mount aktiv (für Awareness im CI).
**Tests:** Beide Tests grün (16/16 in `test_security_release.py`).
---
### P-23 LoginPage: minLength + Versionsstring ✅
**Status:** Umgesetzt (zusammen mit P-05)
**Betroffene Dateien:**
- `frontend/src/pages/LoginPage.jsx`
**Technische Änderung:**
- `minLength="6"``minLength="8"` (deckungsgleich mit P-05)
- Hardcodierter Versionsstring `v0.1.0 • Development` aus dem Footer der LoginPage entfernt. Kein Ersatz: Die Versionsinformation ist nur im eingeloggten Bereich unter Einstellungen sichtbar.
---
### P-24 CORS allow_methods und allow_headers einschränken ✅
**Status:** Umgesetzt
**Betroffene Dateien:**
- `backend/main.py:85-86` CORSMiddleware-Konfiguration
**Technische Änderung:**
```python
# Vorher:
allow_methods=["*"],
allow_headers=["*"],
# Nachher:
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-Auth-Token", "X-Active-Club-Id"],
```
Die drei erlaubten Header entsprechen den tatsächlich genutzten Headers (Auth-Token, Tenant-Header, Content-Type). Alle API-Funktionen bleiben unverändert erreichbar.
---
## Test-Zusammenfassung
```
tests/test_security_release.py 9 passed (inkl. 2 neue P-07 Tests)
tests/test_media_assets_copyright_promotion.py 7 passed (neue P-04 Tests)
Gesamt neue Tests: 11
Gesamt bestehende Tests: 104 → 103 passed, 1 failed (pre-existing, DB nicht erreichbar)
```
Der vorhandene Fehler (`test_list_media_assets_invalid_lifecycle_400`) war bereits vor dieser Implementierung vorhanden (verifiziert per `git stash`). Er tritt nur lokal auf, wenn kein PostgreSQL-Container mit Hostname `postgres` läuft, und besteht in der CI/Docker-Umgebung nicht.
---
## Nicht umgesetzte Pakete (laut Freigabe ausgeschlossen)
P-01, P-02, P-06, P-09, P-10, P-11, P-13P-16 und alle weiteren Findings bleiben offen.
Vollständige Liste und Begründungen: `docs/compliance-audit.md`.
---
## Re-Audit-Empfehlung
Nach Deployment der Version 0.8.66 sollten folgende Punkte operativ verifiziert werden:
1. **P-03**: `docker logs shinkan-retention-cron` prüfen — Job läuft einmalig beim Start und danach täglich um 03:00 Uhr
2. **P-04**: Manuelle Stichprobe: PATCH eines privaten Mediums auf `official` ohne `copyright_notice` → muss 400 zurückgeben
3. **P-24**: Browser DevTools → Network → Preflight-Request auf `/api/exercises``Access-Control-Allow-Headers` darf nur `content-type, x-auth-token, x-active-club-id` enthalten
Nächster vollständiger Re-Audit empfohlen nach Umsetzung der kritischen Findings (P-01: Impressum/Datenschutz, P-02: Löschkonzept/DSGVO-Request-Workflow).

View File

@ -400,7 +400,7 @@ function AccountSettingsPage() {
value={newPw1} value={newPw1}
onChange={(e) => setNewPw1(e.target.value)} onChange={(e) => setNewPw1(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
minLength={4} minLength={8}
/> />
</div> </div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}> <div className="form-row" style={{ marginBottom: '0.75rem' }}>

View File

@ -172,7 +172,7 @@ function LoginPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
placeholder="••••••••" placeholder="••••••••"
minLength="6" minLength="8"
/> />
</div> </div>
@ -238,9 +238,6 @@ function LoginPage() {
</p> </p>
</div> </div>
)} )}
<p style={{ textAlign: 'center', color: 'var(--text3)', marginTop: '1.5rem', fontSize: '0.875rem' }}>
v0.1.0 Development
</p>
</div> </div>
</div> </div>
) )

View File

@ -1,12 +1,12 @@
// Shinkan Jinkendo Frontend Version // Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.40" export const APP_VERSION = "0.8.66"
export const BUILD_DATE = "2026-05-06" export const BUILD_DATE = "2026-05-09"
export const PAGE_VERSIONS = { export const PAGE_VERSIONS = {
LoginPage: "1.0.0", LoginPage: "1.0.1",
Dashboard: "1.0.0", Dashboard: "1.0.0",
AccountSettingsPage: "1.0.0", AccountSettingsPage: "1.0.1",
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
ClubsPage: "1.1.0", ClubsPage: "1.1.0",
SkillsPage: "1.0.0", SkillsPage: "1.0.0",

View File

@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../mitai-jinkendo"
},
{
"path": "."
}
],
"settings": {}
}