feat: update access layer governance and visibility checks
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 37s

- 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.
This commit is contained in:
Lars 2026-05-05 22:11:05 +02:00
parent abee6171df
commit e0ecfe927f
10 changed files with 108 additions and 13 deletions

View File

@ -97,7 +97,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
| Mechanismus | Inhalt | | Mechanismus | Inhalt |
|-------------|--------| |-------------|--------|
| **Cursor / IDE** | Projektregel `.cursor/rules/access-layer.mdc` (Router); Agents sollen nicht auf „nur require_auth“ ausweichen. | | **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? | | **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. | | **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). | | **Ä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. 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). 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` **Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`

View File

@ -149,6 +149,7 @@ def exercise_visible_to_profile(
created_by: Optional[int], created_by: Optional[int],
global_role: Optional[str], global_role: Optional[str],
) -> bool: ) -> bool:
"""Leserechte einer Übung. Für neue Codepfade lieber `library_content_visible_to_profile` verwenden."""
if is_platform_admin(global_role): if is_platform_admin(global_role):
return True return True
if visibility == "official": if visibility == "official":

3
backend/pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .

View File

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

View File

@ -15,7 +15,11 @@ from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File,
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d 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 from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -765,7 +769,7 @@ def get_exercise(
if not exercise: if not exercise:
raise HTTPException(status_code=404, detail="Übung nicht gefunden") raise HTTPException(status_code=404, detail="Übung nicht gefunden")
if not exercise_visible_to_profile( if not library_content_visible_to_profile(
cur, cur,
profile_id, profile_id,
exercise["visibility"], exercise["visibility"],

View File

@ -11,8 +11,8 @@ from fastapi import APIRouter, Depends, HTTPException
from club_tenancy import ( from club_tenancy import (
assert_valid_governance_visibility, assert_valid_governance_visibility,
exercise_visible_to_profile,
is_platform_admin, is_platform_admin,
library_content_visible_to_profile,
) )
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
@ -44,7 +44,7 @@ def _framework_assert_readable(
) -> None: ) -> None:
if is_platform_admin(role): if is_platform_admin(role):
return return
if not exercise_visible_to_profile( if not library_content_visible_to_profile(
cur, cur,
profile_id, profile_id,
row.get("visibility") or "private", row.get("visibility") or "private",

View File

@ -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 tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import ( from club_tenancy import (
assert_valid_governance_visibility, assert_valid_governance_visibility,
exercise_visible_to_profile,
is_platform_admin, is_platform_admin,
library_content_visible_to_profile,
) )
router = APIRouter(prefix="/api", tags=["training_planning"]) 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: def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role): if is_platform_admin(role):
return return
if not exercise_visible_to_profile( if not library_content_visible_to_profile(
cur, cur,
profile_id, profile_id,
row.get("visibility") or "club", row.get("visibility") or "club",

View File

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

View File

@ -1,13 +1,13 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.27" APP_VERSION = "0.8.28"
BUILD_DATE = "2026-05-05" BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041" DB_SCHEMA_VERSION = "20260505041"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "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 "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 "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_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "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", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "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_units": "0.1.0",
"training_programs": "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", "import_wiki": "1.0.0",
"admin": "1.0.0", "admin": "1.0.0",
"membership": "1.0.0", "membership": "1.0.0",
@ -27,6 +27,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.27",
"date": "2026-05-05", "date": "2026-05-05",

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version // 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 BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = { export const PAGE_VERSIONS = {