Implement Registry-First Approach for Rights and Capabilities Management
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m51s

- Updated the capability catalog to reflect a registry-first approach, requiring modules to register rights and quotas upon implementation.
- Enhanced the backend to synchronize the rights registry with the database, ensuring only registered capabilities and features are displayed in the admin matrix.
- Modified SQL queries in the admin rights router to filter capabilities and features based on module registration.
- Updated documentation to clarify the new rights and features registry process, replacing the previous catalog-first method.
- Incremented application version to 0.8.201 and updated database schema version to 20260606084 to reflect these changes.
This commit is contained in:
Lars 2026-06-07 15:36:31 +02:00
parent 9d52aeab67
commit 4130a63dfe
15 changed files with 525 additions and 14 deletions

View File

@ -304,10 +304,13 @@ assert_club_feature(tenant, "ai_calls", club_id=tenant.effective_club_id) # sie
## 9. Abgrenzung & Drift-Schutz ## 9. Abgrenzung & Drift-Schutz
1. **Neue Nutzerfunktion** → zuerst Capability-ID hier eintragen, dann Endpoint. 1. **Neue Nutzerfunktion**`register_capability()` in `rights_registrations/<modul>.py`, dann Endpoint mit `probe_capability`. Namenskonvention hier dokumentieren — **kein** Bulk-Seed in Migrationen.
2. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback. 2. **Kontingent**`register_feature()` im selben Modul; Consume über `consume_club_feature_with_usage`.
3. Capability ≠ Feature: `exercises.ai.suggest` (darf ich?) vs. `ai_calls` (wie viel übrig?). 3. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback.
4. Plattform-Admin-Bypass dokumentieren und auditieren (`platform_admin` sieht Mandant, nicht automatisch alle Quotas). 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).
--- ---

View File

@ -52,6 +52,20 @@ else:
print(f"[FAIL] Migration-Laufzeitfehler: {e}") print(f"[FAIL] Migration-Laufzeitfehler: {e}")
sys.exit(1) 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 from routers.auth import limiter as auth_rate_limiter
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1 # OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1

View File

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

View File

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

View File

@ -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",
)
)

View File

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

View File

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

View File

@ -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",
)
)

159
backend/rights_registry.py Normal file
View File

@ -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),
}

View File

@ -92,10 +92,10 @@ def get_capability_matrix(session: dict = Depends(require_auth)):
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( 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 FROM capabilities
WHERE active = true WHERE active = true AND module IS NOT NULL
ORDER BY domain, id ORDER BY module, domain, id
""" """
) )
capabilities = [] capabilities = []
@ -130,6 +130,11 @@ def get_capability_matrix(session: dict = Depends(require_auth)):
"capabilities": capabilities, "capabilities": capabilities,
"portal_grants": portal_grants, "portal_grants": portal_grants,
"club_role_grants": club_role_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( 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 FROM features
WHERE app = 'shinkan' AND active = true AND enforcement_subject = 'club' 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()] features = [r2d(r) for r in cur.fetchall()]

View File

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

View File

@ -1,15 +1,16 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.200" APP_VERSION = "0.8.201"
BUILD_DATE = "2026-06-07" BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260606083" DB_SCHEMA_VERSION = "20260606084"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "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 "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 "profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext "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 "account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware
"clubs": "0.4.2", # delete_club: Gründungsanträge → superseded "clubs": "0.4.2", # delete_club: Gründungsanträge → superseded
"club_memberships": "1.0.1", # Depends(get_tenant_context) "club_memberships": "1.0.1", # Depends(get_tenant_context)

View File

@ -9,6 +9,13 @@ Diese Roadmap bündelt **was fertig ist**, **was als Standard gilt** und **was n
## 1. Architektur-Standard (verbindlich) ## 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) ### Request-Kette (Ziel)
``` ```

View File

@ -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/<modul>.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.

View File

@ -328,8 +328,9 @@ export default function AdminRightsPage() {
<strong>Kontingente:</strong> Wie viel darf ein Verein verbrauchen (an Rechten gekoppelt). <strong>Kontingente:</strong> Wie viel darf ein Verein verbrauchen (an Rechten gekoppelt).
<br /> <br />
<span style={{ fontSize: '0.85rem' }}> <span style={{ fontSize: '0.85rem' }}>
= an API angebunden · = nur Legacy oder noch nicht durchgesetzt. Roadmap:{' '} Es erscheinen nur <strong>vom Modul registrierte</strong> Rechte (nicht der alte
<code>docs/working/RBAC_ENFORCEMENT_ROADMAP.md</code> Vollkatalog). = an API angebunden · = registriert, Endpoint fehlt noch.{' '}
<code>docs/working/RIGHTS_AND_FEATURES_REGISTRY.md</code>
</span> </span>
</p> </p>