From e0ecfe927f7d26c374e0f25c1416d704b8a61d71 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 22:11:05 +0200 Subject: [PATCH] feat: update access layer governance and visibility checks - Enhanced ACCESS_LAYER_AND_GOVERNANCE_PLAN.md with additional details on heuristic checks and testing procedures for cross-tenant scenarios. - Updated club_tenancy.py to recommend using `library_content_visible_to_profile` for exercise visibility checks. - Refactored multiple routers to utilize `library_content_visible_to_profile`, improving consistency in access control across exercises and training planning. - Bumped application version to 0.8.28 and updated changelog to reflect these changes. --- .../ACCESS_LAYER_AND_GOVERNANCE_PLAN.md | 4 +- backend/club_tenancy.py | 1 + backend/pytest.ini | 3 + backend/requirements-dev.txt | 3 + backend/routers/exercises.py | 8 +- .../routers/training_framework_programs.py | 4 +- backend/routers/training_planning.py | 4 +- backend/tests/test_access_layer.py | 76 +++++++++++++++++++ backend/version.py | 16 +++- frontend/src/version.js | 2 +- 10 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/test_access_layer.py diff --git a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md index f055312..51b392e 100644 --- a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md +++ b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md @@ -97,7 +97,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). | Mechanismus | Inhalt | |-------------|--------| | **Cursor / IDE** | Projektregel `.cursor/rules/access-layer.mdc` (Router); Agents sollen nicht auf „nur require_auth“ ausweichen. | -| **Heuristik-Check** | `python backend/scripts/check_access_layer_hints.py`; CI optional mit `ACCESS_LAYER_STRICT=1`. | +| **Heuristik-Check** | `python backend/scripts/check_access_layer_hints.py`; CI optional mit `ACCESS_LAYER_STRICT=1`. Optional danach: `cd backend && pytest tests/` (nach `pip install -r requirements-dev.txt`). | | **PR-Checkliste** | Neuer/changed Endpoint: TenantContext verwendet? Governance für Listen + Detail? Tests für zweiten Verein? | | **Single Source of Truth** | Sichtbarkeitsregeln nur in Zugriffsmodul(en), nicht in Routers dupliziert. | | **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). | @@ -109,7 +109,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). 1. **TenantContext-Spezifikation** (ein Abschnitt in diesem Dokument oder Kurz-ADR): Request-Lebenszyklus, Fehlerbilder, Superadmin. 2. **Endpoint-Audit-Tabelle** (Working-Dokument, bei jedem Merge pflegen bis Stufe C abgeschlossen). -3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine. +3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — erste rein-funktionale Tests unter `backend/tests/test_access_layer.py` (ohne DB); Integration folgt. **Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index f96d3f8..e81e374 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -149,6 +149,7 @@ def exercise_visible_to_profile( created_by: Optional[int], global_role: Optional[str], ) -> bool: + """Leserechte einer Übung. Für neue Codepfade lieber `library_content_visible_to_profile` verwenden.""" if is_platform_admin(global_role): return True if visibility == "official": diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..784f297 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +# Entwicklung / CI — zusätzlich zu backend/requirements.txt installieren: +# pip install -r backend/requirements.txt -r backend/requirements-dev.txt +pytest>=8.0,<9 diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 5bd3b83..3151141 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -15,7 +15,11 @@ from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, from pydantic import BaseModel, Field, model_validator from db import get_db, get_cursor, r2d -from club_tenancy import assert_valid_governance_visibility, exercise_visible_to_profile, is_platform_admin +from club_tenancy import ( + assert_valid_governance_visibility, + is_platform_admin, + library_content_visible_to_profile, +) from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql logger = logging.getLogger(__name__) @@ -765,7 +769,7 @@ def get_exercise( if not exercise: raise HTTPException(status_code=404, detail="Übung nicht gefunden") - if not exercise_visible_to_profile( + if not library_content_visible_to_profile( cur, profile_id, exercise["visibility"], diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 3e38b62..cc8a367 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -11,8 +11,8 @@ from fastapi import APIRouter, Depends, HTTPException from club_tenancy import ( assert_valid_governance_visibility, - exercise_visible_to_profile, is_platform_admin, + library_content_visible_to_profile, ) from db import get_db, get_cursor, r2d @@ -44,7 +44,7 @@ def _framework_assert_readable( ) -> None: if is_platform_admin(role): return - if not exercise_visible_to_profile( + if not library_content_visible_to_profile( cur, profile_id, row.get("visibility") or "private", diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 9e1cb95..e664209 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -12,8 +12,8 @@ from db import get_db, get_cursor, r2d from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql from club_tenancy import ( assert_valid_governance_visibility, - exercise_visible_to_profile, is_platform_admin, + library_content_visible_to_profile, ) router = APIRouter(prefix="/api", tags=["training_planning"]) @@ -531,7 +531,7 @@ def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]: def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: if is_platform_admin(role): return - if not exercise_visible_to_profile( + if not library_content_visible_to_profile( cur, profile_id, row.get("visibility") or "club", diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py new file mode 100644 index 0000000..11654b3 --- /dev/null +++ b/backend/tests/test_access_layer.py @@ -0,0 +1,76 @@ +"""Unit tests ohne Datenbank für die Zugriffsschicht (Visibility-SQL, Header-Parsing).""" +import pytest +from fastapi import HTTPException + +from tenant_context import library_content_visibility_sql, parse_active_club_header + + +def test_library_visibility_sql_platform_admin_no_filter(): + sql, params = library_content_visibility_sql( + alias="e", + profile_id=1, + role="admin", + effective_club_id=None, + ) + assert sql == "TRUE" + assert params == [] + + +def test_library_visibility_sql_superadmin(): + sql, params = library_content_visibility_sql( + alias="fp", + profile_id=2, + role="superadmin", + effective_club_id=100, + ) + assert sql == "TRUE" + assert params == [] + + +def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branch(): + sql, params = library_content_visibility_sql( + alias="g", + profile_id=42, + role="trainer", + effective_club_id=None, + ) + assert "official" in sql + assert "private" in sql + assert "visibility = 'club'" not in sql + assert params == [42] + + +def test_library_visibility_sql_user_with_active_club_includes_club_branch(): + sql, params = library_content_visibility_sql( + alias="t", + profile_id=7, + role="user", + effective_club_id=99, + ) + assert "visibility = 'club'" in sql + assert "club_members" in sql + assert params[0] == 7 # private branch created_by + assert 99 in params + assert params.count(7) >= 2 # private + EXISTS membership + + +def test_parse_active_club_header_none_and_empty(): + assert parse_active_club_header(None) is None + assert parse_active_club_header("") is None + assert parse_active_club_header(" ") is None + + +def test_parse_active_club_header_valid(): + assert parse_active_club_header("12") == 12 + + +def test_parse_active_club_header_invalid(): + with pytest.raises(HTTPException) as exc: + parse_active_club_header("not-int") + assert exc.value.status_code == 400 + + +def test_parse_active_club_header_non_positive(): + with pytest.raises(HTTPException) as exc: + parse_active_club_header("0") + assert exc.value.status_code == 400 diff --git a/backend/version.py b/backend/version.py index 7e37fa6..ceba5b4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,13 +1,13 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.27" +APP_VERSION = "0.8.28" BUILD_DATE = "2026-05-05" DB_SCHEMA_VERSION = "20260505041" MODULE_VERSIONS = { "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "profiles": "1.4.1", # PUT /profiles*, Legacy /profile: Depends(get_tenant_context); profile_document für internes Laden - "tenant_context": "1.0.2", # Dokumentation: ACCESS_LAYER Pflicht + Script check_access_layer_hints.py + "tenant_context": "1.0.3", # pytest: backend/tests/test_access_layer.py (Visibility-SQL, Header) "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "club_memberships": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context) @@ -15,10 +15,10 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.6.3", # Progressionsgraphen: library_content_visible_to_profile aus club_tenancy + "exercises": "2.6.4", # Detail-Lesen über library_content_visible_to_profile "training_units": "0.1.0", "training_programs": "0.1.0", - "planning": "0.8.0", # TenantContext auf allen Planungs-Endpunkten; Vorlagen-Liste wie Übungen nach aktivem Verein + "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -27,6 +27,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.28", + "date": "2026-05-05", + "changes": [ + "ACCESS_LAYER: Übungen-, Trainingsplanung-, Rahmenprogramme-Detail nutzen library_content_visible_to_profile (einheitliche Leseprüfung)", + "pytest: backend/requirements-dev.txt, pytest.ini, backend/tests/test_access_layer.py — ohne DB (library_content_visibility_sql, parse_active_club_header)", + ], + }, { "version": "0.8.27", "date": "2026-05-05", diff --git a/frontend/src/version.js b/frontend/src/version.js index 607eb36..d2d2e11 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.27" +export const APP_VERSION = "0.8.28" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = {