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.
This commit is contained in:
parent
413a096432
commit
4b6fd49940
123
.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md
Normal file
123
.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
21
.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
Normal file
21
.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
167
backend/tenant_context.py
Normal file
167
backend/tenant_context.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user