From 4b6fd499400e822652af5f50ad67bcf4f7e2b517 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 21:42:56 +0200 Subject: [PATCH] feat: implement tenant context resolution and update profiles API - Introduced tenant context resolution in the profiles API, allowing for effective club identification based on user memberships. - Updated the `GET /profiles/me` endpoint to return `effective_club_id` and removed reliance on the deprecated `X-Active-Club-Id` header. - Bumped application version to 0.8.22 in both backend and frontend files. - Enhanced changelog to document the new version and changes made in this release. --- .../ACCESS_LAYER_AND_GOVERNANCE_PLAN.md | 123 +++++++++++++ .../MULTI_TENANCY_RBAC_ARCHITECTURE.md | 18 +- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 21 +++ backend/routers/profiles.py | 25 ++- backend/tenant_context.py | 167 ++++++++++++++++++ backend/version.py | 12 +- frontend/src/context/AuthContext.jsx | 5 + frontend/src/version.js | 2 +- 8 files changed, 362 insertions(+), 11 deletions(-) create mode 100644 .claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md create mode 100644 .claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md create mode 100644 backend/tenant_context.py diff --git a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md new file mode 100644 index 0000000..f26f060 --- /dev/null +++ b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md @@ -0,0 +1,123 @@ +# Einheitliche Zugriffsschicht & Governance – Umsetzungsplan + +**Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`) +**Stand:** 2026-05-05 +**Zweck:** Drift vermeiden – eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen. + +**Explizit zurückgestellt (wie vereinbart):** kostenpflichtiges Vereins-Membership / Tier-Limits pro Verein (`club_subscriptions` o. Ä.) – kommt nach stabiler Zugriffs- und Datenisolationsbasis. + +**Separates späteres Konzept:** durch Vereinsadmins definierbare Rollen mit feingranularen Rechten (Capability-Bundles in DB/UI); dieser Plan bereitet nur die **Capability-Idee** und **Erweiterungspunkte** vor. + +--- + +## 1. Leitprinzipien + +1. **Ein Mandant für Datenisolierung:** `club_id` ist die Grenze für „vereinsgeteilte“ Inhalte. Nur Ausnahmen: explizit **plattformweite/offizielle** Objekte und **privat**e Objekte des Erstellers. +2. **Ein Kontext pro HTTP-Request:** Mitgliedschaft und gewählter aktiver Verein werden einmal aufgelöst und validiert; Folgelogik nutzt nur noch dieses Objekt (kein verteiltes erneutes „Rates“ aus Headers). +3. **Eine fachliche Sichtbarkeits-Semantik** über alle Bibliotheks- und Planungsartefakte (Übungen, Vorlagen, Rahmenprogramme, …): gleiche Enums, gleiche Leseprüfung, angepasste Listenfilter. +4. **Sparte optional verschärfen:** `division_id` auf Objekten und später auf Rollenzuweisung ausgewertet – ohne Vereinsgrenze zu sprengen. +5. **Community später additive:** neue Freigabeebene oder Flags ergänzen die bestehende Semantik, ohne `club`-Isolation zu ersetzen. + +--- + +## 2. Architektur der Zugriffsschicht (Schichtenmodell) + +| Schicht | Verantwortung | +|---------|----------------| +| **Authentifizierung** | Session / Token → `profile_id`, globale `role` (`require_auth`, bestehend). | +| **TenantContext** (neu, zentral) | Aus `profile_id` + Header `X-Active-Club-Id` (optional) + DB `profiles.active_club_id`: effektive **`effective_club_id`** nur wenn Mitgliedschaft aktiv existiert; sonch klaren Fehler (403/400 nach Konvention). Optional: Cache der Mitgliedschaftszeilen/Rollen **einmal pro Request**. | +| **Governance / Objekt contra Account** | Für jedes geschützte Objekt: `visibility`, `club_id`, `division_id` (nullable), `created_by` → **eine** zentrale Entscheidung `can_read` / `can_write` (interne Module, keine Copy-Paste-Logik pro Router). | +| **Funktions-/Feature-Rechte** | „Darf dieser Nutzer im Verein X Trainergruppe anlegen?“ → Capability-Checks (`can_manage_club_org`, `can_plan_in_club`, …); später durch dynamische Rollen → gleiche Capability-Namen. | + +**Ziel:** Router werden dünn: laden Daten nur noch durch Hilfen, die **TenantContext** und **Governance** bereits berücksichtigen oder explizit prüfen. + +--- + +## 3. Scope- und Sichtbarkeitsmodell (einheitlich) + +Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). + +**Zielbild (phasenweise DB/API-Anpassung):** + +| Wert | Lesende Regel (kurz) | +|------|----------------------| +| `private` | Nur `created_by` (+ Plattform-Admin nach Policy); keine Vereinsliste ohne Ownership. | +| `club` | Nur aktive Mitglieder des Objekt-`club_id`; **Cross-Verein nie**. | +| `division` *(optional neue Stufe oder `club` + Pflicht `division_id`)* | Nur Mitglieder, die dieser Sparte zugeordnet sind (Regeln gesondert spezifizieren: Mitgliedschaft vs. Gruppe vs. Rolle `division_lead`). | +| `official` | Plattform-weit lesbar (Superadmin publiziert/pflegt); weiterhin strikt von `club`-Daten getrennt. | +| `community` *(reserviert)* | Noch nicht implementieren; Design nur additive Felder/Enum-Einträge dokumentieren, wenn erste Stories starten. | + +**Trainer-Flow:** „Privat anlegen, dann im Verein teilen“ = Transition von `private` zu `club` (oder Kopie + neue Visibility – Produktentscheidung; technisch muss `club_id` gesetzt und Mitgliedschaft geprüft werden). + +--- + +## 4. Roadmap (verbindliche Reihenfolge) + +### Stufe A – Foundations & Audit + +- **Router-Inventar:** Liste aller Endpoints mit Zugriff auf `club_id`, `visibility`, organisationalem Bezug oder Listenfiltern (Excel/Markdown-Tabelle im Repo oder unter `.claude/docs/working/`). +- **Definition of Done je Endpoint:** „Default deny“ für tenant-sensitive Listen wenn Kontext fehlt/ungültig. +- TenantContext-Spezifikation festhalten (Feldnamen, HTTP-Fehlercodes, Superadmin-Ausnahmen). + +### Stufe B – TenantContext (Backend zentral) + +- FastAPI-Dependency z. B. `get_tenant_context(session, x_active_club_id)` → Objekt mit `profile_id`, `global_role`, `effective_club_id`, `club_memberships` (optional gekürzt). +- Alle neuen und bestehenden sicherheitsrelevanten Änderungen **nur** über diese Dependency oder darauf aufbauende Helfer. +- Abgleich mit Frontend: `X-Active-Club-Id` und Persistenz `active_club_id` (bereits vorhanden) als einzige variabler Vereinskontext-Quellen. + +### Stufe C – Governance vereinheitlichen + +- **Eine** interne API-Stilebene (auch wenn mehrere Python-Funktionen): + `content_readable(...)`, `content_writable(...)` oder zweischichtig „Governance“ vs „Org-Rechte“. +- Module angleichen: **Übungen**, **Trainingsplanung**, **Rahmenprogramme**, **Vorlagen** – gleiche Regeln für `club`/`official`/`private`; **`division`** dort einführen, wo fachlich nötig (einheitliche Filter-Chips in UI). +- Regressionstests: **zwei Vereine, zwei Nutzer**, kein Kreuzzugriff auf `club`-Objekte; Superadmin/Global-Path weiterhin getrennt testen. + +### Stufe D – Sparten-Durchsetzung + +- `division_lead` und optional `division_id` auf Mitgliedschaft/Rolle auswerten bei Schreib-/Lesevorgängen für Objekte mit `division_id`. +- Dokumentieren: Was gilt für Objekte mit `division_id=NULL` innerhalb eines Vereins (vereinsweit vs. nur „ohne Sparte“). + +### Stufe E – Capabilities dokumentieren (ohne UI für Custom Roles) + +- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `content.share_club`, `planning.edit_unit`, `org.manage_members`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen. +- Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen – keine zweite Philosophie. + +### Stufe F – Community (eigenes Epic) + +- Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen. + +### Zurückgestellt – Vereinsabo / Limits + +- Wiederöffnen wenn ACCESS_LAYER Stufe C/D stabil; dann Enforcement vor ausgewählten Writes an einen Billing-Stripe binden. + +--- + +## 5. Drift vermeiden (Arbeitsdisziplin) + +| Mechanismus | Inhalt | +|-------------|--------| +| **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). | +| **Beziehung zu MULTI_TENANCY-Doc** | Phasen 1–4 dort größtenteils umgesetzt; **Gap-Analyse §3** im alten Dokument historisch lesen – fachlicher Zielabgleich bleibt dort, **operative Reihenfolge** hier. | + +--- + +## 6. Nächste konkrete Artefakte (nach diesem Plan) + +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. + +**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` + +--- + +## 7. Referenzen + +- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe. +- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang. + +--- + +**Letzte Aktualisierung:** 2026-05-05 diff --git a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md index 1f50e47..3f26dbf 100644 --- a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md +++ b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md @@ -4,6 +4,8 @@ **Stand:** 2026-05-05 **Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008) +**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) – dort sind Stufen A–F, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**. + --- ## 1. Zweck @@ -30,6 +32,8 @@ Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Ma ## 3. Ist-Stand im Code (Gap-Analyse) +> **Hinweis:** Dieser Abschnitt beschreibt den Ausgangspunkt vor Ausbauschritten (**Mitgliedschaften, gefilterte Vereinsliste, Teilen von Governance für Übungen/Rahmen/Planung** sind bereits angegangen). Verbindliche **offene Arbeit und Reihenfolge** sind im Dokument [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) festgehalten. + ### 3.1 Identität und Rollen - `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …). @@ -64,7 +68,7 @@ Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Ma ### 3.5 Frontend -- `AuthContext`: nur globales Profil, **kein** `activeClubId`, keine Mitgliedschaften. +- **Stand 2026-05:** `GET /api/profiles/me` liefert `clubs[]`, `active_club_id`; Frontend setzt `X-Active-Club-Id`. Details und Pflicht zur serverseitigen **TenantContext**-Validierung siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`. ### 3.6 Membership (kommerziell/limits) @@ -206,9 +210,15 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie ## 7. Nächste konkrete Artefakte -1. DDL-Migrationsentwurf für Phase 1 (Review). -2. Aktualisierung `DATABASE_SCHEMA.md` nach Merge der Migration. -3. Sicherheits-Review der `list_*`-Endpunkte mit `club`-Visibility. +1. TenantContext-Spezifikation & Endpoint-Audit (siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` §6). +2. Aktualisierung `DATABASE_SCHEMA.md` bei neuen Governance-/Scope-Feldern. +3. Sicherheits-Review der `list_*`-Endpunkte mit `club`-Visibility (fortlaufend bis Governance vereinheitlicht). + +--- + +## 8. Verwandtes Dokument + +- **`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`** – verbindliche Umsetzungsstufen A–F, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt. --- diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md new file mode 100644 index 0000000..0cde5bf --- /dev/null +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -0,0 +1,21 @@ +# Endpoint-Audit: Mandanten & Governance + +Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. + +| Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen | +|------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------| +| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht | +| profiles | `PUT /api/profiles/{id}` | ja | — | `active_club_id` Mitgliedschaft | TenantContext später auch hier | +| clubs | `GET /api/clubs` | ja | — | Mitgliedschaft vs Admin | Liste gefiltert Nicht-Admins | +| clubs | CRUD Organisation | ja | — | `can_manage_club_org` / member | schrittweise auf TenantContext | +| club_memberships | `/clubs/{id}/members*` | ja | geplant | ja | | +| club_join_requests | `/clubs/{id}/join-requests*` | ja | geplant | ja | | +| exercises | `GET /api/exercises`, Detail | ja | geplant | `visibility` + Mitgliedschaft | | +| training_planning | diverse | ja | geplant | `exercise_visible` / Gruppe | | +| training_framework_programs | diverse | ja | geplant | analog Übungen | | +| admin_users | `GET /api/admin/users` | Plattform | optional | Admin-Rolle | | +| Sonstige | skills, methods, catalogs | zu klären | — | oft global | Zeilen ergänzen | + +**Legende:** „geplant“ = beim nächsten Umbau dieser Router `get_tenant_context` verwenden bzw. zentrale Governance-Helfer. + +Letzte Änderung: 2026-05-05 (Initial) diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index af0c8af..8831a79 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends from db import get_db, get_cursor, r2d from auth import require_auth from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin +from tenant_context import resolve_tenant_context from models import ProfileCreate, ProfileUpdate router = APIRouter(prefix="/api", tags=["profiles"]) @@ -34,9 +35,12 @@ def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str: # ── Current User Profile ────────────────────────────────────────────────────── @router.get("/profiles/me") -def get_current_profile(session=Depends(require_auth)): - """Get current user's profile (for auth check on refresh).""" - profile_id = session['profile_id'] +def get_current_profile( + session=Depends(require_auth), + x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"), +): + """Profil inkl. Vereinsmitgliedschaften; effective_club_id = aufgelöster Request-Kontext (Header vor Profilfeld).""" + profile_id = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,)) @@ -45,7 +49,20 @@ def get_current_profile(session=Depends(require_auth)): raise HTTPException(404, "Profil nicht gefunden") data = r2d(row) data.pop("pin_hash", None) - data["clubs"] = memberships_with_roles(cur, profile_id) + clubs = memberships_with_roles(cur, profile_id) + data["clubs"] = clubs + ac_raw = data.get("active_club_id") + stored_ac = int(ac_raw) if ac_raw is not None and ac_raw != "" else None + tenant = resolve_tenant_context( + cur, + profile_id=int(profile_id), + global_role=session.get("role") or "", + header_raw=x_active_club_id, + memberships=clubs, + stored_active_club_id=stored_ac, + invalid_header_policy="ignore", + ) + data["effective_club_id"] = tenant.effective_club_id return data diff --git a/backend/tenant_context.py b/backend/tenant_context.py new file mode 100644 index 0000000..d8d9817 --- /dev/null +++ b/backend/tenant_context.py @@ -0,0 +1,167 @@ +""" +Request-weiter Mandanten-Kontext (ACCESS_LAYER_AND_GOVERNANCE_PLAN.md, Stufe B). + +Zielt auf einheitliche Auflösung aus Session + Header X-Active-Club-Id + Profilfeld active_club_id. +Router können Depends(get_tenant_context) nutzen oder resolve_tenant_context mit bereits geladenen Mitgliedschaften (ein DB-Block). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from fastapi import Depends, Header, HTTPException + +from auth import require_auth +from club_tenancy import is_platform_admin, memberships_with_roles +from db import get_db, get_cursor + + +def _club_exists(cur, club_id: int) -> bool: + cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,)) + return cur.fetchone() is not None + + +def parse_active_club_header(raw: Optional[str]) -> Optional[int]: + """Parst X-Active-Club-Id; leer → None. Ungültig → HTTP 400.""" + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + try: + v = int(s) + except ValueError: + raise HTTPException(status_code=400, detail="X-Active-Club-Id ungültig") + if v < 1: + raise HTTPException(status_code=400, detail="X-Active-Club-Id ungültig") + return v + + +@dataclass +class TenantContext: + profile_id: int + global_role: str + # Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header oft None + effective_club_id: Optional[int] + club_ids: frozenset[int] + memberships: List[Dict[str, Any]] + + +def resolve_tenant_context( + cur, + *, + profile_id: int, + global_role: str, + header_raw: Optional[str], + memberships: Optional[List[Dict[str, Any]]] = None, + stored_active_club_id: Optional[int] = None, + invalid_header_policy: str = "reject", +) -> TenantContext: + """ + Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften). + + Auflösung effective_club_id: + - Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header → None. + - Sonst: gültiger Header zwingend Mitgliedschaft — bei ``reject`` sonst 403, bei ``ignore`` wie ohne Header. + Ohne gültigen Header: gespeichertes active_club_id wenn Mitglied; sonst einziger Verein; + bei mehreren ohne gültige Vorgabe → min(club_ids) (Fallback). + """ + role_lc = (global_role or "").lower() + header_cid = parse_active_club_header(header_raw) + + if memberships is None: + memberships = memberships_with_roles(cur, profile_id, active_only=True) + + club_ids = frozenset(int(r["id"]) for r in memberships if r.get("id") is not None) + + if is_platform_admin(role_lc): + if header_cid is not None: + if not _club_exists(cur, header_cid): + raise HTTPException(status_code=400, detail="Verein nicht gefunden") + effective = header_cid + else: + effective = None + return TenantContext( + profile_id=profile_id, + global_role=role_lc, + effective_club_id=effective, + club_ids=club_ids, + memberships=memberships, + ) + + chosen_header = header_cid + if chosen_header is not None and chosen_header not in club_ids: + if invalid_header_policy == "reject": + raise HTTPException( + status_code=403, + detail="Keine Mitgliedschaft im gewählten Verein", + ) + chosen_header = None + + if chosen_header is not None: + effective = chosen_header + elif stored_active_club_id is not None and stored_active_club_id in club_ids: + effective = stored_active_club_id + elif len(club_ids) == 1: + effective = next(iter(club_ids)) + elif len(club_ids) == 0: + effective = None + else: + effective = min(club_ids) + + return TenantContext( + profile_id=profile_id, + global_role=role_lc, + effective_club_id=effective, + club_ids=club_ids, + memberships=memberships, + ) + + +def get_tenant_context( + session: dict = Depends(require_auth), + x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"), +) -> TenantContext: + """FastAPI-Dependency: öffnet eine DB-Verbindung und liefert TenantContext.""" + pid = int(session["profile_id"]) + role = session.get("role") or "" + stored: Optional[int] = None + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT active_club_id FROM profiles WHERE id = %s", (pid,)) + row = cur.fetchone() + if row is not None: + ac = row.get("active_club_id") + if ac is not None: + stored = int(ac) + return resolve_tenant_context( + cur, + profile_id=pid, + global_role=role, + header_raw=x_active_club_id, + memberships=None, + stored_active_club_id=stored, + ) + + +def tenant_context_from_session_only( + cur, + session: dict, + header_raw: Optional[str], + *, + memberships: Optional[List[Dict[str, Any]]] = None, + stored_active_club_id: Optional[int] = None, + invalid_header_policy: str = "reject", +) -> TenantContext: + """Variante ohne FastAPI (Tests / gemeinsamer Cursor mit anderen Queries).""" + pid = int(session["profile_id"]) + role = session.get("role") or "" + return resolve_tenant_context( + cur, + profile_id=pid, + global_role=role, + header_raw=header_raw, + memberships=memberships, + stored_active_club_id=stored_active_club_id, + invalid_header_policy=invalid_header_policy, + ) diff --git a/backend/version.py b/backend/version.py index ba20e0a..4ede543 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,12 +1,12 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.21" +APP_VERSION = "0.8.22" BUILD_DATE = "2026-05-05" DB_SCHEMA_VERSION = "20260505041" MODULE_VERSIONS = { "auth": "1.2.0", # Erster/bootstrap Nutzer und ADMIN_BOOTSTRAP_EMAILS → superadmin (nicht mehr admin) - "profiles": "1.3.0", # PUT role/tier (Portal-Admin); GET /profiles; pin_hash aus Liste entfernt + "profiles": "1.4.0", # GET /profiles/me: effective_club_id (TenantContext-Auflösung); TenantContext-Modul "clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints "club_memberships": "1.0.0", "club_join_requests": "1.0.0", @@ -26,6 +26,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.22", + "date": "2026-05-05", + "changes": [ + "ACCESS_LAYER Stufe B: Modul tenant_context (resolve_tenant_context, Depends(get_tenant_context)); GET /profiles/me liefert effective_club_id; veralteter X-Active-Club-Id wird dort verworfen (ignore), strikt auf anderen Endpoints via Depends", + "Arbeitspapier ACCESS_LAYER_ENDPOINT_AUDIT.md für Router-Inventar", + ], + }, { "version": "0.8.21", "date": "2026-05-05", diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 3fb7b99..57b676b 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -6,6 +6,11 @@ const AuthContext = createContext(null) function syncStoredActiveClub(profile) { const clubs = profile?.clubs || [] const ids = new Set(clubs.map((c) => String(c.id))) + const eff = profile?.effective_club_id + if (eff != null && eff !== '' && ids.has(String(eff))) { + localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(eff)) + return + } const stored = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY) if (stored && ids.has(stored)) return diff --git a/frontend/src/version.js b/frontend/src/version.js index 9b10a15..ec2be26 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.21" +export const APP_VERSION = "0.8.22" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = {