diff --git a/.claude/docs/technical/CAPABILITY_CATALOG.v1.md b/.claude/docs/technical/CAPABILITY_CATALOG.v1.md index 0e403d5..02c2b5e 100644 --- a/.claude/docs/technical/CAPABILITY_CATALOG.v1.md +++ b/.claude/docs/technical/CAPABILITY_CATALOG.v1.md @@ -304,10 +304,13 @@ assert_club_feature(tenant, "ai_calls", club_id=tenant.effective_club_id) # sie ## 9. Abgrenzung & Drift-Schutz -1. **Neue Nutzerfunktion** → zuerst Capability-ID hier eintragen, dann Endpoint. -2. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback. -3. Capability ≠ Feature: `exercises.ai.suggest` (darf ich?) vs. `ai_calls` (wie viel übrig?). -4. Plattform-Admin-Bypass dokumentieren und auditieren (`platform_admin` sieht Mandant, nicht automatisch alle Quotas). +1. **Neue Nutzerfunktion** → `register_capability()` in `rights_registrations/.py`, dann Endpoint mit `probe_capability`. Namenskonvention hier dokumentieren — **kein** Bulk-Seed in Migrationen. +2. **Kontingent** → `register_feature()` im selben Modul; Consume über `consume_club_feature_with_usage`. +3. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback. +4. Capability ≠ Feature: `exercises.ai.suggest` (darf ich?) vs. `ai_calls` (wie viel übrig?). +5. Plattform-Admin-Bypass dokumentieren und auditieren (`platform_admin` sieht Mandant, nicht automatisch alle Quotas). + +Siehe **`docs/working/RIGHTS_AND_FEATURES_REGISTRY.md`** (Registry-first, ersetzt Katalog-first aus 079). --- diff --git a/backend/main.py b/backend/main.py index dbad225..3a9ad09 100644 --- a/backend/main.py +++ b/backend/main.py @@ -52,6 +52,20 @@ else: print(f"[FAIL] Migration-Laufzeitfehler: {e}") sys.exit(1) +# Registry-first: Module → DB (nur registrierte Rechte/Kontingente in Admin-Matrix) +if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"): + try: + from rights_registry import sync_rights_registry_to_db + + counts = sync_rights_registry_to_db() + print( + f"[OK] Rights registry sync: {counts['capabilities']} capabilities, " + f"{counts['features']} features" + ) + except Exception as e: + print(f"[FAIL] Rights registry sync: {e}") + sys.exit(1) + from routers.auth import limiter as auth_rate_limiter # OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1 diff --git a/backend/migrations/084_rights_registry_module.sql b/backend/migrations/084_rights_registry_module.sql new file mode 100644 index 0000000..24c26e2 --- /dev/null +++ b/backend/migrations/084_rights_registry_module.sql @@ -0,0 +1,15 @@ +-- Migration 084: Modul-Registrierung für Rechte & Kontingente (Registry-first) +-- capabilities/features mit module=NULL = Legacy-Katalog-Seed (nicht in Admin-Matrix). +-- module IS NOT NULL = vom Modul bei Implementierung registriert. + +ALTER TABLE capabilities + ADD COLUMN IF NOT EXISTS module TEXT; + +ALTER TABLE features + ADD COLUMN IF NOT EXISTS module TEXT; + +CREATE INDEX IF NOT EXISTS idx_capabilities_module + ON capabilities(module) WHERE module IS NOT NULL AND active = true; + +CREATE INDEX IF NOT EXISTS idx_features_module + ON features(module) WHERE module IS NOT NULL AND active = true; diff --git a/backend/rights_registrations/__init__.py b/backend/rights_registrations/__init__.py new file mode 100644 index 0000000..c523d02 --- /dev/null +++ b/backend/rights_registrations/__init__.py @@ -0,0 +1,9 @@ +""" +Modul-Registrierungen für Rechte & Kontingente. + +Neues Feature: eigene Datei oder Eintrag hier importieren — kein Eintrag in 079-Katalog-Migration. +""" +from rights_registrations import club_creation # noqa: F401 +from rights_registrations import exercises # noqa: F401 +from rights_registrations import planning # noqa: F401 +from rights_registrations import platform # noqa: F401 diff --git a/backend/rights_registrations/club_creation.py b/backend/rights_registrations/club_creation.py new file mode 100644 index 0000000..2757776 --- /dev/null +++ b/backend/rights_registrations/club_creation.py @@ -0,0 +1,38 @@ +from rights_registry import CapabilityRegistration, register_capability + +register_capability( + CapabilityRegistration( + id="club.creation_request.create", + name="Vereinsgründung beantragen", + domain="club", + module="club_creation_requests", + min_account_state="verified_pending_club", + ) +) +register_capability( + CapabilityRegistration( + id="club.creation_request.read_own", + name="Eigene Gründungsanträge", + domain="club", + module="club_creation_requests", + min_account_state="verified_pending_club", + ) +) +register_capability( + CapabilityRegistration( + id="club.creation_request.withdraw", + name="Gründungsantrag zurückziehen", + domain="club", + module="club_creation_requests", + min_account_state="verified_pending_club", + ) +) +register_capability( + CapabilityRegistration( + id="platform.club_creation.approve", + name="Vereinsgründung freigeben", + domain="platform", + module="club_creation_requests", + min_account_state="platform_admin", + ) +) diff --git a/backend/rights_registrations/exercises.py b/backend/rights_registrations/exercises.py new file mode 100644 index 0000000..e29adaa --- /dev/null +++ b/backend/rights_registrations/exercises.py @@ -0,0 +1,90 @@ +"""Übungen-Modul: nur Rechte/Kontingente mit echter Endpoint-Verdrahtung.""" +from rights_registry import CapabilityRegistration, FeatureRegistration, register_capability, register_feature + +_CLUB_WRITE_ROLES = ( + "club_admin", + "trainer", + "content_editor", + "division_lead", +) + +register_feature( + FeatureRegistration( + id="ai_calls", + name="KI-Aufrufe", + module="exercises", + category="ai", + limit_type="count", + reset_period="monthly", + default_limit=0, + description="KI-Aufrufe pro Monat (Suggest, Regenerate)", + ) +) + +register_capability( + CapabilityRegistration( + id="exercises.ai.suggest", + name="KI-Vorschlag Übung", + domain="exercises", + module="exercises", + linked_feature_id="ai_calls", + default_club_grants=tuple((r, "exercises.ai.suggest") for r in _CLUB_WRITE_ROLES), + ) +) +register_capability( + CapabilityRegistration( + id="exercises.ai.regenerate", + name="KI neu generieren", + domain="exercises", + module="exercises", + linked_feature_id="ai_calls", + default_club_grants=tuple((r, "exercises.ai.regenerate") for r in _CLUB_WRITE_ROLES), + ) +) +register_capability( + CapabilityRegistration( + id="exercises.create", + name="Übung anlegen", + domain="exercises", + module="exercises", + linked_feature_id="exercises", + default_club_grants=tuple((r, "exercises.create") for r in _CLUB_WRITE_ROLES), + ) +) +register_capability( + CapabilityRegistration( + id="exercises.media.upload", + name="Übungsmedien hochladen", + domain="exercises", + module="exercises", + linked_feature_id="exercise_media", + default_club_grants=( + ("club_admin", "exercises.media.upload"), + ("trainer", "exercises.media.upload"), + ("content_editor", "exercises.media.upload"), + ), + ) +) + +register_feature( + FeatureRegistration( + id="exercises", + name="Übungen (Bestand)", + module="exercises", + category="content", + limit_type="count", + reset_period="never", + default_limit=0, + ) +) +register_feature( + FeatureRegistration( + id="exercise_media", + name="Übungsmedien", + module="exercises", + category="media", + limit_type="count", + reset_period="never", + default_limit=0, + ) +) diff --git a/backend/rights_registrations/planning.py b/backend/rights_registrations/planning.py new file mode 100644 index 0000000..8324fbf --- /dev/null +++ b/backend/rights_registrations/planning.py @@ -0,0 +1,24 @@ +from rights_registry import CapabilityRegistration, FeatureRegistration, register_capability, register_feature + +_PLANNING_ROLES = ("club_admin", "trainer", "division_lead") + +register_capability( + CapabilityRegistration( + id="planning.ai.suggest", + name="Planungs-KI Suggest", + domain="planning", + module="planning_exercise_suggest", + linked_feature_id="ai_calls", + default_club_grants=tuple((r, "planning.ai.suggest") for r in _PLANNING_ROLES), + ) +) +register_capability( + CapabilityRegistration( + id="planning.ai.progression_path", + name="Planungs-KI Progressionspfad", + domain="planning", + module="planning_exercise_suggest", + linked_feature_id="ai_calls", + default_club_grants=tuple((r, "planning.ai.progression_path") for r in _PLANNING_ROLES), + ) +) diff --git a/backend/rights_registrations/platform.py b/backend/rights_registrations/platform.py new file mode 100644 index 0000000..d2d2a51 --- /dev/null +++ b/backend/rights_registrations/platform.py @@ -0,0 +1,21 @@ +"""Plattform-Modul: Admin-Zugang und Quota-Bypass (083).""" +from rights_registry import CapabilityRegistration, register_capability + +register_capability( + CapabilityRegistration( + id="platform.admin.access", + name="Plattform-Admin-Bereich", + domain="platform", + module="platform", + min_account_state="platform_admin", + ) +) +register_capability( + CapabilityRegistration( + id="platform.club_quota.bypass", + name="Vereins-Kontingent-Bypass", + domain="quota_bypass", + module="platform", + min_account_state="platform_admin", + ) +) diff --git a/backend/rights_registry.py b/backend/rights_registry.py new file mode 100644 index 0000000..62e282a --- /dev/null +++ b/backend/rights_registry.py @@ -0,0 +1,159 @@ +""" +Registry-first: Module melden Rechte (capabilities) und Kontingente (features) bei Implementierung an. + +Kein vollständiger Vorab-Katalog — nur was ein Modul wirklich liefert, erscheint konfigurierbar +in Admin „Rollen & Rechte“ (Filter: module IS NOT NULL). + +Spez: docs/working/RIGHTS_AND_FEATURES_REGISTRY.md +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Sequence, Tuple + +from db import get_db, get_cursor + +GrantPair = Tuple[str, str] # (role_code, capability_id) + + +@dataclass(frozen=True) +class CapabilityRegistration: + id: str + name: str + domain: str + module: str + min_account_state: str = "active_member" + linked_feature_id: Optional[str] = None + description: Optional[str] = None + default_club_grants: Sequence[GrantPair] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class FeatureRegistration: + id: str + name: str + module: str + category: str = "general" + limit_type: str = "count" + reset_period: str = "never" + default_limit: Optional[int] = None + description: Optional[str] = None + enforcement_subject: str = "club" + + +_CAPABILITY_REGISTRY: Dict[str, CapabilityRegistration] = {} +_FEATURE_REGISTRY: Dict[str, FeatureRegistration] = {} + + +def register_capability(defn: CapabilityRegistration) -> None: + """Modul deklariert ein Recht — wird beim Startup in die DB synchronisiert.""" + if not defn.module or not defn.id: + raise ValueError("CapabilityRegistration: module und id sind Pflicht") + _CAPABILITY_REGISTRY[defn.id] = defn + + +def register_feature(defn: FeatureRegistration) -> None: + """Modul deklariert ein Vereins-Kontingent.""" + if not defn.module or not defn.id: + raise ValueError("FeatureRegistration: module und id sind Pflicht") + _FEATURE_REGISTRY[defn.id] = defn + + +def registered_capabilities() -> Dict[str, CapabilityRegistration]: + return dict(_CAPABILITY_REGISTRY) + + +def registered_features() -> Dict[str, FeatureRegistration]: + return dict(_FEATURE_REGISTRY) + + +def _upsert_capability(cur, defn: CapabilityRegistration) -> None: + cur.execute( + """ + INSERT INTO capabilities ( + id, name, description, domain, min_account_state, + linked_feature_id, active, module, updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, true, %s, NOW()) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + domain = EXCLUDED.domain, + min_account_state = EXCLUDED.min_account_state, + linked_feature_id = EXCLUDED.linked_feature_id, + active = true, + module = EXCLUDED.module, + updated_at = NOW() + """, + ( + defn.id, + defn.name, + defn.description, + defn.domain, + defn.min_account_state, + defn.linked_feature_id, + defn.module, + ), + ) + for role_code, cap_id in defn.default_club_grants: + cur.execute( + """ + INSERT INTO club_role_capability_grants (role_code, capability_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (role_code, cap_id), + ) + + +def _upsert_feature(cur, defn: FeatureRegistration) -> None: + cur.execute( + """ + INSERT INTO features ( + id, app, name, description, category, limit_type, + reset_period, default_limit, enforcement_subject, active, module + ) + VALUES (%s, 'shinkan', %s, %s, %s, %s, %s, %s, %s, true, %s) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + category = EXCLUDED.category, + limit_type = EXCLUDED.limit_type, + reset_period = EXCLUDED.reset_period, + default_limit = EXCLUDED.default_limit, + enforcement_subject = EXCLUDED.enforcement_subject, + active = true, + module = EXCLUDED.module + """, + ( + defn.id, + defn.name, + defn.description, + defn.category, + defn.limit_type, + defn.reset_period, + defn.default_limit, + defn.enforcement_subject, + defn.module, + ), + ) + + +def sync_rights_registry_to_db() -> Dict[str, int]: + """ + Startup: registrierte Module → DB. Admin-Matrix zeigt nur Einträge mit module. + """ + import rights_registrations # noqa: F401 — lädt alle Modul-Registrierungen + + with get_db() as conn: + cur = get_cursor(conn) + for defn in _CAPABILITY_REGISTRY.values(): + _upsert_capability(cur, defn) + for defn in _FEATURE_REGISTRY.values(): + _upsert_feature(cur, defn) + conn.commit() + + return { + "capabilities": len(_CAPABILITY_REGISTRY), + "features": len(_FEATURE_REGISTRY), + } diff --git a/backend/routers/admin_rights.py b/backend/routers/admin_rights.py index ebd5d3c..17c996e 100644 --- a/backend/routers/admin_rights.py +++ b/backend/routers/admin_rights.py @@ -92,10 +92,10 @@ def get_capability_matrix(session: dict = Depends(require_auth)): cur = get_cursor(conn) cur.execute( """ - SELECT id, name, domain, min_account_state, linked_feature_id + SELECT id, name, domain, min_account_state, linked_feature_id, module FROM capabilities - WHERE active = true - ORDER BY domain, id + WHERE active = true AND module IS NOT NULL + ORDER BY module, domain, id """ ) capabilities = [] @@ -130,6 +130,11 @@ def get_capability_matrix(session: dict = Depends(require_auth)): "capabilities": capabilities, "portal_grants": portal_grants, "club_role_grants": club_role_grants, + "registry_only": True, + "hint": ( + "Nur vom Modul registrierte Rechte (capabilities.module). " + "Legacy-Katalog-Seed ohne module erscheint nicht." + ), } @@ -453,10 +458,11 @@ def get_club_plans_matrix(session: dict = Depends(require_auth)): cur.execute( """ - SELECT id, name, description, category, limit_type, reset_period, default_limit + SELECT id, name, description, category, limit_type, reset_period, default_limit, module FROM features WHERE app = 'shinkan' AND active = true AND enforcement_subject = 'club' - ORDER BY category, id + AND module IS NOT NULL + ORDER BY module, category, id """ ) features = [r2d(r) for r in cur.fetchall()] diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py new file mode 100644 index 0000000..4a2a3bf --- /dev/null +++ b/backend/tests/test_rights_registry.py @@ -0,0 +1,33 @@ +"""Registry-first: Modul-Registrierungen.""" +import rights_registrations # noqa: F401 +from rights_registry import ( + CapabilityRegistration, + registered_capabilities, + registered_features, + register_capability, +) + + +def test_exercises_module_registers_wired_capabilities(): + assert "exercises.ai.suggest" in registered_capabilities() + assert registered_capabilities()["exercises.ai.suggest"].module == "exercises" + + +def test_register_capability_requires_module(): + try: + register_capability( + CapabilityRegistration( + id="test.no.module", + name="Test", + domain="test", + module="", + ) + ) + assert False, "expected ValueError" + except ValueError: + pass + + +def test_registered_features_include_ai_calls(): + assert "ai_calls" in registered_features() + assert registered_features()["ai_calls"].module == "exercises" diff --git a/backend/version.py b/backend/version.py index b6cbba4..285b284 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,15 +1,16 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.200" +APP_VERSION = "0.8.201" BUILD_DATE = "2026-06-07" -DB_SCHEMA_VERSION = "20260606083" +DB_SCHEMA_VERSION = "20260606084" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm "profiles": "1.8.1", # GET /profiles/me: account_state + club_roles "tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext - "capabilities": "1.1.0", # quota_bypass-Domain + profile_capability_grants in check_capability + "capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte + "rights_registry": "1.0.0", # register_capability/feature + startup sync "account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware "clubs": "0.4.2", # delete_club: Gründungsanträge → superseded "club_memberships": "1.0.1", # Depends(get_tenant_context) diff --git a/docs/working/RBAC_ENFORCEMENT_ROADMAP.md b/docs/working/RBAC_ENFORCEMENT_ROADMAP.md index 56ad18a..288fbbf 100644 --- a/docs/working/RBAC_ENFORCEMENT_ROADMAP.md +++ b/docs/working/RBAC_ENFORCEMENT_ROADMAP.md @@ -9,6 +9,13 @@ Diese Roadmap bündelt **was fertig ist**, **was als Standard gilt** und **was n ## 1. Architektur-Standard (verbindlich) +### Registry-first (korrigiert 2026-06-07) + +**Nicht:** vollständiger Capability-Katalog in Migration 079. +**Sondern:** Module registrieren Rechte/Kontingente bei Implementierung → `docs/working/RIGHTS_AND_FEATURES_REGISTRY.md`. + +Admin-Matrix zeigt nur `capabilities.module IS NOT NULL` — keine vorgetäuschte Vollständigkeit. + ### Request-Kette (Ziel) ``` diff --git a/docs/working/RIGHTS_AND_FEATURES_REGISTRY.md b/docs/working/RIGHTS_AND_FEATURES_REGISTRY.md new file mode 100644 index 0000000..4760836 --- /dev/null +++ b/docs/working/RIGHTS_AND_FEATURES_REGISTRY.md @@ -0,0 +1,90 @@ +# Rechte & Kontingente — Registry-first (Zielarchitektur) + +**Stand:** 2026-06-07 · **Status:** verbindlich (korrigiert Katalog-first aus Migration 079) + +--- + +## 1. Problem mit dem Katalog-first-Ansatz + +Migration `079_capabilities.sql` hat **~70 Rechte vorab** in die DB geschrieben — aus einer Spekulation über die fertige App. Das ist für ein System im Aufbau **verkehrt herum**: + +- Vollständige Liste ist **nicht möglich** und nicht wünschenswert +- Die Matrix **suggeriert** Funktionen, die es am Endpoint noch nicht gibt +- Module **registrieren sich nicht** — alles war manueller Seed + +**Korrektur:** Registry-first — wie bei anderen Registries im Projekt (z. B. Platzhalter-Pflicht). + +--- + +## 2. Zielbild + +``` +Modul implementiert Feature + → register_capability() / register_feature() in rights_registrations/.py + → Startup: sync_rights_registry_to_db() + → Admin „Rollen & Rechte“ zeigt nur Einträge mit module IS NOT NULL + → Endpoint: probe_capability + probe/consume Kontingent +``` + +| Achse | Registrierung | Konfiguration Admin | +|-------|---------------|---------------------| +| **Recht** | `CapabilityRegistration` | Matrix Vereins-/Portal-Rollen | +| **Kontingent** | `FeatureRegistration` | Vereinspläne / Limits | + +Kein neuer Eintrag in `079`-artigen Bulk-Migrations für fachliche Rechte. + +--- + +## 3. Implementierung (Code) + +| Pfad | Rolle | +|------|--------| +| `backend/rights_registry.py` | `register_capability`, `register_feature`, `sync_rights_registry_to_db` | +| `backend/rights_registrations/*.py` | Pro Modul nur **tatsächlich verdrahtete** Rechte/Kontingente | +| `backend/main.py` | Sync nach Migrationen | +| Migration `084_rights_registry_module.sql` | Spalte `module` auf `capabilities` + `features` | +| `admin_rights.py` | Matrix-Query: `WHERE module IS NOT NULL` | + +### Neues Modul anbinden (Pflicht) + +1. Datei `rights_registrations/mein_modul.py` anlegen +2. `register_capability` / `register_feature` aufrufen +3. In `rights_registrations/__init__.py` importieren +4. Endpoint: `probe_capability` + ggf. `consume_club_feature_with_usage` +5. `capability_enforcement_audit.WIRED_PROBE` ergänzen + +**Kein** Eintrag in `CAPABILITY_CATALOG` als Voraussetzung für DB — der Katalog wird zur **Dokumentation** der Namenskonvention, nicht zur Seed-Quelle. + +--- + +## 4. Legacy-Katalog (079) + +- Bleibt in der DB (`module IS NULL`) für Übergang / `check_capability`-Kompatibilität +- Erscheint **nicht** mehr in der Admin-Matrix +- Wird nicht erweitert — neue Rechte nur über Registry +- Langfristig: ungenutzte Seed-Zeilen deaktivieren oder archivieren + +--- + +## 5. Aktuell registrierte Module (Start) + +| Modul | Rechte | Kontingente | +|-------|--------|-------------| +| `exercises` | KI suggest/regenerate, create, media.upload | `ai_calls`, `exercises`, `exercise_media` | +| `planning_exercise_suggest` | planning.ai.* | (nutzt `ai_calls`) | +| `club_creation_requests` | Gründung + approve | — | +| `platform` | admin.access, quota.bypass | — | + +Weitere Module folgen **mit ihrer Implementierung**, nicht vorher. + +--- + +## 6. Referenzen + +- `docs/working/RBAC_ENFORCEMENT_ROADMAP.md` — Enforcement nach Verdrahtung +- `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` — Produktentscheidungen +- `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` — Kontingent-Semantik + +**Changelog** + +- 2026-06-07: Registry-first als verbindliche Korrektur; Migration 084; Pilot-Registrierungen. diff --git a/frontend/src/pages/AdminRightsPage.jsx b/frontend/src/pages/AdminRightsPage.jsx index e3d6eef..f324188 100644 --- a/frontend/src/pages/AdminRightsPage.jsx +++ b/frontend/src/pages/AdminRightsPage.jsx @@ -328,8 +328,9 @@ export default function AdminRightsPage() { Kontingente: Wie viel darf ein Verein verbrauchen (an Rechten gekoppelt).
- ● = an API angebunden · ○ = nur Legacy oder noch nicht durchgesetzt. Roadmap:{' '} - docs/working/RBAC_ENFORCEMENT_ROADMAP.md + Es erscheinen nur vom Modul registrierte Rechte (nicht der alte + Vollkatalog). ● = an API angebunden · ○ = registriert, Endpoint fehlt noch.{' '} + docs/working/RIGHTS_AND_FEATURES_REGISTRY.md