From fc33bfbdeb72c212e25624fa6f8b416e751dafd0 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 10 May 2026 08:26:15 +0200 Subject: [PATCH] feat(compliance): update retention policy and enhance password reset validation - Adjusted retention policy to align with compliance requirements: - Changed HIDDEN_TO_PURGE_DAYS from 90 to 30 days. - Enhanced password reset functionality to enforce a minimum password length of 8 characters. - Updated tests to validate new password requirements and retention logic. - Corrected umlaut in copyright error messages for clarity. --- backend/media_lifecycle.py | 3 +- backend/models.py | 2 +- backend/routers/media_assets.py | 4 +- .../test_auth_password_reset_minlength.py | 100 ++++++++ .../test_media_assets_copyright_promotion.py | 28 ++- backend/version.py | 17 +- docker-compose.yml | 3 +- docs/compliance-implementation.md | 219 +++++++++++++----- frontend/src/version.js | 4 +- 9 files changed, 294 insertions(+), 86 deletions(-) create mode 100644 backend/tests/test_auth_password_reset_minlength.py diff --git a/backend/media_lifecycle.py b/backend/media_lifecycle.py index e2d273a..61fd671 100644 --- a/backend/media_lifecycle.py +++ b/backend/media_lifecycle.py @@ -21,7 +21,8 @@ LC_TRASH_SOFT = "trash_soft" LC_TRASH_HIDDEN = "trash_hidden" SOFT_TO_HIDDEN_DAYS = max(1, int(os.getenv("MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS", "30"))) -HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "90"))) +# P-03b: Default gemaess fachlichem Loeschkonzept (Audit 2026-05-09): 30+30 Tage. +HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "30"))) def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None: diff --git a/backend/models.py b/backend/models.py index 68f0ac5..ded7613 100644 --- a/backend/models.py +++ b/backend/models.py @@ -26,7 +26,7 @@ class PasswordResetRequest(BaseModel): class PasswordResetConfirm(BaseModel): token: str - new_password: str + new_password: str = Field(min_length=8, max_length=128) class ProfileCreate(BaseModel): """Nur für POST /api/profiles (Plattform-Admin): neues Nutzerprofil ohne Self-Registration.""" diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index e5f6de3..118113e 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -1167,7 +1167,7 @@ def bulk_media_patch( { "id": asset_id, "detail": ( - "Fur Vereins- oder offizielle Medien ist eine " + "Für Vereins- oder offizielle Medien ist eine " "Urheberrechtsangabe (copyright_notice) Pflicht." ), } @@ -1280,7 +1280,7 @@ def patch_media_asset( raise HTTPException( status_code=400, detail=( - "Fur Vereins- oder offizielle Medien ist eine Urheberrechtsangabe " + "Für Vereins- oder offizielle Medien ist eine Urheberrechtsangabe " "(copyright_notice) Pflicht. Bitte vor oder zusammen mit der " "Freigabe angeben." ), diff --git a/backend/tests/test_auth_password_reset_minlength.py b/backend/tests/test_auth_password_reset_minlength.py new file mode 100644 index 0000000..c006364 --- /dev/null +++ b/backend/tests/test_auth_password_reset_minlength.py @@ -0,0 +1,100 @@ +""" +P-05b: POST /api/auth/reset-password muss Passwoerter unter 8 Zeichen ablehnen. +Prueft Pydantic-Validierung (HTTP 422) BEVOR die DB befragt wird. +""" +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 main import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _mock_db_valid_token() -> MagicMock: + mock_cur = MagicMock() + mock_cur.fetchone.return_value = {"profile_id": 1} + mock_conn = MagicMock() + mock_cm = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + return mock_cm, mock_cur + + +# ── Mindestlaenge-Grenzwerte ────────────────────────────────────────────────── + + +@pytest.mark.parametrize("short_pw", ["", "a", "1234567"]) +def test_reset_password_too_short_rejected(client: TestClient, short_pw: str) -> None: + """Passwort < 8 Zeichen muss mit 422 abgelehnt werden (Pydantic-Validierung).""" + r = client.post( + "/api/auth/reset-password", + json={"token": "sometoken", "new_password": short_pw}, + ) + assert r.status_code == 422, ( + f"Erwartet 422 fuer Passwort {short_pw!r} ({len(short_pw)} Zeichen), " + f"erhalten {r.status_code}" + ) + + +def test_reset_password_exactly_8_chars_accepted(client: TestClient) -> None: + """Passwort mit genau 8 Zeichen muss die Validierung passieren.""" + mock_cm, mock_cur = _mock_db_valid_token() + with patch("routers.auth.get_db", return_value=mock_cm), patch( + "routers.auth.get_cursor", return_value=mock_cur + ): + r = client.post( + "/api/auth/reset-password", + json={"token": "sometoken", "new_password": "12345678"}, + ) + assert r.status_code == 200 + assert r.json().get("ok") is True + + +def test_reset_password_long_password_accepted(client: TestClient) -> None: + """Langes Passwort (>= 8 Zeichen) muss akzeptiert werden.""" + mock_cm, mock_cur = _mock_db_valid_token() + with patch("routers.auth.get_db", return_value=mock_cm), patch( + "routers.auth.get_cursor", return_value=mock_cur + ): + r = client.post( + "/api/auth/reset-password", + json={"token": "sometoken", "new_password": "sicheresPasswort123!"}, + ) + assert r.status_code == 200 + + +def test_reset_password_missing_field_rejected(client: TestClient) -> None: + """Fehlendes new_password-Feld muss mit 422 abgelehnt werden.""" + r = client.post( + "/api/auth/reset-password", + json={"token": "sometoken"}, + ) + assert r.status_code == 422 + + +def test_reset_password_invalid_token_returns_400(client: TestClient) -> None: + """Gueltiges Passwort aber ungueltiger Token muss 400 liefern.""" + mock_cur = MagicMock() + mock_cur.fetchone.return_value = None + mock_conn = MagicMock() + mock_cm = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + with patch("routers.auth.get_db", return_value=mock_cm), patch( + "routers.auth.get_cursor", return_value=mock_cur + ): + r = client.post( + "/api/auth/reset-password", + json={"token": "ungueltig", "new_password": "validesPw123"}, + ) + assert r.status_code == 400 diff --git a/backend/tests/test_media_assets_copyright_promotion.py b/backend/tests/test_media_assets_copyright_promotion.py index 542c863..7e01e25 100644 --- a/backend/tests/test_media_assets_copyright_promotion.py +++ b/backend/tests/test_media_assets_copyright_promotion.py @@ -125,13 +125,11 @@ def test_patch_promote_to_official_without_copyright_returns_400(client: TestCli 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.""" + """Promotion private -> club MIT copyright_notice im Body muss 200 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"}, - ] + updated_asset = {**_PRIVATE_ASSET, "visibility": "club", "copyright_notice": "Verein 2026"} + mock_cur.fetchone.side_effect = [_PRIVATE_ASSET, updated_asset] with ExitStack() as stack: stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) @@ -143,17 +141,19 @@ def test_patch_promote_to_club_with_copyright_in_body_allowed(client: TestClient headers={"X-Auth-Token": "t"}, ) - assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower() + assert r.status_code == 200 + body = r.json() + assert body["id"] == 42 + assert body["visibility"] == "club" + assert body["copyright_notice"] == "Verein 2026" def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) -> None: - """Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright erlaubt.""" + """Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright muss 200 liefern.""" app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT + promoted_asset = {**_ASSET_WITH_COPYRIGHT, "visibility": "club"} mock_cm, mock_cur = _make_db_mocks(_ASSET_WITH_COPYRIGHT) - mock_cur.fetchone.side_effect = [ - _ASSET_WITH_COPYRIGHT, - {**_ASSET_WITH_COPYRIGHT, "visibility": "club"}, - ] + mock_cur.fetchone.side_effect = [_ASSET_WITH_COPYRIGHT, promoted_asset] with ExitStack() as stack: stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) @@ -165,7 +165,11 @@ def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) -> headers={"X-Auth-Token": "t"}, ) - assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower() + assert r.status_code == 200 + body = r.json() + assert body["id"] == 42 + assert body["visibility"] == "club" + assert body["copyright_notice"] == "Verein 2026" def test_patch_filename_only_no_copyright_check(client: TestClient) -> None: diff --git a/backend/version.py b/backend/version.py index 0ded75f..a48f8fa 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,11 +1,11 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.66" -BUILD_DATE = "2026-05-09" +APP_VERSION = "0.8.67" +BUILD_DATE = "2026-05-10" DB_SCHEMA_VERSION = "20260508049" MODULE_VERSIONS = { - "auth": "1.2.2", # Passwort-Mindestlange PUT /auth/pin: 4 auf 8 Zeichen angehoben + "auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm "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 "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) "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) - "media_assets": "1.12.2", # P-04: Copyright-Pflicht bei Promotion auf club/official in PATCH und bulk-patch + "media_assets": "1.12.3", # P-04b: Umlautkorrektur Fehlermeldung; Tests gehaertet "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", @@ -29,6 +29,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.67", + "date": "2026-05-10", + "changes": [ + "Sicherheit P-05b: PasswordResetConfirm.new_password min_length=8 (POST /auth/reset-password)", + "Sicherheit P-03b: Retention-Default HIDDEN_TO_PURGE 90->30 Tage (gemaess Loeschkonzept 30+30)", + "Sicherheit P-04: Positive Promotion-Tests haerten (explizit 200 + Payload); Umlaut in Fehlermeldung", + ], + }, { "version": "0.8.66", "date": "2026-05-09", diff --git a/docker-compose.yml b/docker-compose.yml index 4f0b189..147266b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,8 +108,9 @@ services: DB_USER: shinkan_user DB_PASSWORD: ${DB_PASSWORD} MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}" + # Loeschkonzept (Audit P-03b): 30 Tage Soft-Trash, dann 30 Tage Hidden, dann Purge (gesamt 60 Tage). 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}" + MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS: "${MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS:-30}" volumes: - ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media} depends_on: diff --git a/docs/compliance-implementation.md b/docs/compliance-implementation.md index 2020a24..c30f0d1 100644 --- a/docs/compliance-implementation.md +++ b/docs/compliance-implementation.md @@ -1,8 +1,9 @@ # Compliance-Implementierung – Umsetzungsbericht **Erstellt:** 2026-05-09 +**Zuletzt aktualisiert:** 2026-05-10 **Audit-Basis:** `docs/compliance-audit.md` -**App-Version nach Umsetzung:** 0.8.66 +**App-Version nach Umsetzung:** 0.8.67 --- @@ -20,84 +21,171 @@ Neuer Docker-Service `retention-cron` nutzt dasselbe Backend-Image und führt `s **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-03b – Retention-Zeiten mit Löschkonzept abgleichen ✅ + +**Status:** Umgesetzt (2026-05-10) + +**Betroffene Dateien:** +- `backend/media_lifecycle.py:24` – Default `HIDDEN_TO_PURGE_DAYS` +- `docker-compose.yml` – explizite Env-Variable im `retention-cron`-Service + +**Befund des Re-Audits:** +Das fachliche Löschkonzept (Audit §6.4) sieht 30 + 30 Tage vor: +- Stufe 1 → Stufe 2 (Soft → Hidden): 30 Tage ✓ (war bereits korrekt) +- Stufe 2 → Purge (Hidden → gelöscht): **weitere 30 Tage** → Code-Default war 90 Tage ❌ + +**Technische Änderung:** +```python +# media_lifecycle.py Zeile 24 (vorher "90", jetzt "30"): +HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "30"))) +``` + +`docker-compose.yml` enthält jetzt explizit `MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS: "${...:-30}"` mit Kommentar, der das 30+30-Konzept dokumentiert. Der Wert kann per Env-Variable in einzelnen Deployments überschrieben werden. --- ### P-04 – Copyright-Pflicht bei Archiv-Promotion vereinheitlichen ✅ -**Status:** Umgesetzt +**Status:** Umgesetzt; Tests nachgehärtet (2026-05-10) **Betroffene Dateien:** - `backend/routers/media_assets.py` – `patch_media_asset()` und `bulk_media_patch()` +- `backend/tests/test_media_assets_copyright_promotion.py` **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. +Beide Endpoints prüfen, 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. +Fehlermeldung: `"Für Vereins- oder offizielle Medien ist eine Urheberrechtsangabe (copyright_notice) Pflicht."` (Umlaut korrekt, 2026-05-10 korrigiert) -**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` +**Tests:** 7 Tests, alle grün: +- Promotion zu `club` ohne Copyright → **400** (exakt) +- Promotion zu `official` ohne Copyright → **400** (exakt) +- Promotion zu `club` mit Copyright im Body → **200** + Payload-Prüfung (gehärtet) +- Promotion zu `club`, Copyright bereits auf Asset → **200** + Payload-Prüfung (gehärtet) +- Kein Visibility-Wechsel → keine Copyright-Prüfung → **200** (exakt) +- Bulk: ohne Copyright → in `failed`, `updated_count == 0` (exakt) +- Bulk: mit Copyright → in `updated`, `updated_count == 1` (exakt) --- -### P-05 – Passwort-Mindestlänge angleichen ✅ +### P-05 – Passwort-Mindestlänge angleichen ⚠️ (Teil 1 von 2 – Re-Audit-Auflage offen) -**Status:** Umgesetzt +**Status:** Teilweise umgesetzt + +**Betroffene Dateien (initialer Fix 2026-05-09):** +- `backend/routers/auth.py:101` – `PUT /api/auth/pin`: `< 4` → `< 8` +- `frontend/src/pages/LoginPage.jsx:175` – `minLength="6"` → `minLength="8"` +- `frontend/src/pages/AccountSettingsPage.jsx:403` – `minLength={4}` → `minLength={8}` + +**Verbleibende Lücke identifiziert im Re-Audit 2026-05-09:** `POST /api/auth/reset-password` hatte kein Mindestlängen-Limit → siehe P-05b. + +--- + +### P-05b – reset-password Mindestlänge 8 Zeichen ✅ + +**Status:** Umgesetzt (2026-05-10) **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 +- `backend/models.py:29` – `PasswordResetConfirm.new_password` +- `backend/tests/test_auth_password_reset_minlength.py` – 7 neue Tests **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}` +```python +# models.py (vorher): +class PasswordResetConfirm(BaseModel): + token: str + new_password: str -Alle drei Stellen sind jetzt konsistent mit dem bereits korrekten `POST /api/auth/register` (war schon `< 8`). +# models.py (nachher): +class PasswordResetConfirm(BaseModel): + token: str + new_password: str = Field(min_length=8, max_length=128) +``` -**Tests:** Keine neuen Tests; Änderung ist trivial und durch bestehende Auth-Tests (Register) indirekt abgedeckt. +FastAPI lehnt Requests mit `new_password < 8 Zeichen` nun mit HTTP **422** (Pydantic Validation Error) ab, bevor der Endpoint-Handler ausgeführt wird. Kein DB-Zugriff erfolgt für unvalide Requests. + +**Tests:** 7 Tests, alle grün: +- Leer-String → 422 +- 1-Zeichen-Passwort → 422 +- 7-Zeichen-Passwort (`"1234567"`) → 422 +- Exakt 8 Zeichen (`"12345678"`) → **200** ✓ +- Langes Passwort → **200** ✓ +- Fehlendes `new_password`-Feld → 422 +- Gültiges Passwort, ungültiger Token → 400 + +**P-05 vollständig geschlossen?** Ja — alle passwortverarbeitenden Backend-Endpoints erzwingen jetzt Mindestlänge 8: + +| Endpoint | Mindestlänge | Mechanismus | +|---|---|---| +| `POST /api/auth/register` | 8 | `if len(password) < 8` im Handler | +| `PUT /api/auth/pin` | 8 | `if len(new_pin) < 8` im Handler | +| `POST /api/auth/reset-password` | 8 | Pydantic `Field(min_length=8)` | +| Management-Reset (profiles.py) | 8 | Pydantic `Field(min_length=8)` | +| Frontend LoginPage | 8 | `minLength="8"` | +| Frontend AccountSettingsPage | 8 | `minLength={8}` | --- -### P-07 – ALLOW_PUBLIC_MEDIA_STATIC dokumentieren + Release-Test ✅ +### P-07 – ALLOW_PUBLIC_MEDIA_STATIC 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: +**Tests:** Beide grün. -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). +### P-12 – sessionStorage bei Logout bereinigen ❌ -**Tests:** Beide Tests grün (16/16 in `test_security_release.py`). +**Status:** Nicht umgesetzt — weiterhin offen (MITT-05) + +**Befund:** +`logout()` in `frontend/src/context/AuthContext.jsx` löscht nur `localStorage`-Einträge: +```javascript +const logout = () => { + setUser(null) + localStorage.removeItem('authToken') + localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY) + // sessionStorage wird NICHT geleert +} +``` + +`TrainingCoachPage` schreibt folgende Schlüssel in `sessionStorage`: +- `storageStepKey(unitId)` – aktueller Trainingsschritt (Zahl) +- `storageDeltasKey(unitId)` – Trainingsdeltas (JSON) +- `storageDebriefKey(unitId)` – Debrief-Status (Boolean) + +Nach einem Logout bleiben diese Daten im `sessionStorage` des Tabs erhalten. Bei einem Nutzerwechsel im selben Tab (geteilter Rechner) kann der neue Nutzer Trainingsfortschritt des Vorgängers sehen, bis der Tab geschlossen wird. + +**Risikoeinstufung:** MITT-05 (mittleres Risiko; sessionStorage ist tab-lokal und wird beim Tab-Schließen gelöscht; erfordert physischen Zugang zum selben offenen Tab) + +**Fix (nicht durchgeführt, ca. 15 Minuten Aufwand):** +```javascript +// AuthContext.jsx logout(): +const logout = () => { + setUser(null) + localStorage.removeItem('authToken') + localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY) + sessionStorage.clear() // oder gezielt nach 'shinkan_coach_' prefix +} +``` --- ### P-23 – LoginPage: minLength + Versionsstring ✅ -**Status:** Umgesetzt (zusammen mit P-05) +**Status:** Umgesetzt **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. +- `minLength="6"` → `minLength="8"` +- Hardcodierter Versionsstring `v0.1.0 • Development` entfernt --- @@ -106,49 +194,54 @@ Zwei neue Tests in der bestehenden Release-Test-Suite: **Status:** Umgesetzt **Betroffene Dateien:** -- `backend/main.py:85-86` – CORSMiddleware-Konfiguration +- `backend/main.py:85-86` **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 (Stand 0.8.67) + +``` +tests/test_auth_password_reset_minlength.py 7 passed (neu, P-05b) +tests/test_media_assets_copyright_promotion.py 7 passed (gehärtet, P-04) +tests/test_security_release.py 9 passed (inkl. 2 P-07-Tests) +Weitere bestehende Tests: 81 passed, 6 skipped +Gesamt: 104 passed, 6 skipped, 1 failed + +Fehlgeschlagener Test: test_list_media_assets_invalid_lifecycle_400 +→ Pre-existing: benötigt laufenden PostgreSQL-Container (Hostname "postgres") +→ Bestand bereits vor allen Compliance-Änderungen (verifiziert per git stash) +``` --- -## Test-Zusammenfassung +## Nicht umgesetzte Pakete -``` -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-13–P-16 und alle weiteren Findings bleiben offen. -Vollständige Liste und Begründungen: `docs/compliance-audit.md`. +| Paket | Status | Begründung | +|-------|--------|------------| +| P-01 | offen | Rechtstexte — Scope ausgeschlossen | +| P-02 | offen | Self-Service-Löschworkflow — Scope ausgeschlossen | +| P-06 | offen | HSTS-Header — Scope ausgeschlossen | +| P-09 | offen | Kein Einwilligungsdialog Recht am eigenen Bild — Scope ausgeschlossen | +| P-10 | offen | DSA-Meldeverfahren — Scope ausgeschlossen | +| P-11 | offen | HttpOnly-Cookie-Migration — Scope ausgeschlossen | +| P-12 | offen | sessionStorage bei Logout — nicht freigegeben, Aufwand ca. 15 min | +| P-13–P-16 | offen | Scope ausgeschlossen | --- ## Re-Audit-Empfehlung -Nach Deployment der Version 0.8.66 sollten folgende Punkte operativ verifiziert werden: +Operativ nach Deployment 0.8.67 prüfen: -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 +1. **P-03/P-03b**: `docker logs shinkan-retention-cron` — Job läuft täglich 03:00 Uhr; Retention-Zeiten: 30 → 30 Tage +2. **P-04**: Manuell: PATCH privates Medium auf `official` ohne `copyright_notice` → muss 400 liefern +3. **P-05b**: Manuell: Reset-Link mit 7-Zeichen-Passwort → muss mit Fehler abgewiesen werden +4. **P-24**: Browser DevTools Preflight → `Access-Control-Allow-Headers: content-type, x-auth-token, x-active-club-id` -Nächster vollständiger Re-Audit empfohlen nach Umsetzung der kritischen Findings (P-01: Impressum/Datenschutz, P-02: Löschkonzept/DSGVO-Request-Workflow). +Nächster vollständiger Re-Audit nach Umsetzung der kritischen Findings (P-01: Impressum/Datenschutz, P-02: DSGVO-Löschanfragen). diff --git a/frontend/src/version.js b/frontend/src/version.js index 6134012..26da7ea 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,7 +1,7 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.66" -export const BUILD_DATE = "2026-05-09" +export const APP_VERSION = "0.8.67" +export const BUILD_DATE = "2026-05-10" export const PAGE_VERSIONS = { LoginPage: "1.0.1",