feat(rbac): add roles and permissions

This commit is contained in:
Schubert Ferenc 2026-07-02 23:05:59 +02:00
parent 86a32a942c
commit 694b7bd09a
37 changed files with 2682 additions and 218 deletions

411
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,411 @@
# Olympus CRM Architektur
## Projektueberblick
Olympus CRM ist als mehrschichtige CRM-Anwendung aufgebaut. Die Architektur trennt klar zwischen Browser, Frontend/BFF, Backend-API und Datenbank.
Die zentrale Regel lautet:
**Der Browser kommuniziert ausschliesslich mit Athena. Athena kommuniziert serverseitig mit Hermes. Hermes kommuniziert mit PostgreSQL.**
Diese Trennung ist verbindlich, besonders fuer Authentifizierung, API-Zugriffe und spaetere Modul-Erweiterungen wie Kunden, Projekte, Lieferanten, Kontakte und Mitarbeiter.
## Systemkomponenten
### Athena: Next.js 16 Frontend
Athena liegt unter `frontend/athena` und ist das Frontend sowie die BFF-Schicht des Systems.
Aufgaben:
- rendert die Benutzeroberflaeche mit Next.js 16 App Router
- stellt eigene API-Routes unter `/api/...` bereit
- setzt und loescht das HttpOnly-Auth-Cookie
- ruft Hermes serverseitig auf
- schuetzt Seiten ueber `proxy.ts`
Der Browser darf keine Hermes-URLs direkt aufrufen.
### Hermes: FastAPI Backend
Hermes liegt unter `backend/hermes` und stellt die interne Backend-API bereit.
Aufgaben:
- validiert Requests mit Pydantic v2
- verarbeitet Fachlogik und Persistenz
- prueft JWT Bearer Tokens
- stellt REST-Endpunkte bereit
- nutzt SQLAlchemy 2.x fuer Datenbankzugriffe
- setzt keine Browser-Cookies
Hermes bleibt Cookie-frei. Authentifizierte Hermes-Endpunkte erwarten `Authorization: Bearer <jwt>`.
### PostgreSQL Datenbank
PostgreSQL ist die relationale Datenbank fuer Olympus CRM. Hermes greift ueber SQLAlchemy darauf zu.
Schemaaenderungen erfolgen ueber Alembic-Migrationen. Manuelle Datenbankeingriffe sind nur in Notfaellen zulaessig und muessen nachvollziehbar dokumentiert werden.
### Docker Compose
Die gemeinsame Docker-Compose-Konfiguration liegt im Projektroot in `docker-compose.yml`.
Sie verbindet:
- `hermes`
- `athena`
- das konfigurierte Docker-Netzwerk
- die notwendigen Runtime-Umgebungsvariablen
## BFF-Architektur
Olympus nutzt eine Backend-for-Frontend-Architektur.
Regeln:
- Browser spricht ausschliesslich mit Athena.
- Athena spricht serverseitig mit Hermes.
- Hermes setzt keine Browser-Cookies.
- Cross-Origin-Cookies werden nicht verwendet.
- Frontend-API-Zugriffe laufen ueber relative Athena-Routen wie `/api/users`.
Der Datenfluss bleibt dadurch kontrolliert, sicher und leichter hinter Reverse Proxies betreibbar.
## Authentifizierungsfluss
### Login
1. Der Browser sendet Benutzername und Passwort an Athena: `POST /api/login`.
2. Athena ruft serverseitig Hermes auf: `POST /auth/login`.
3. Hermes validiert die Zugangsdaten.
4. Hermes gibt JWT, Ablaufzeit und Benutzerdaten als JSON zurueck.
5. Athena setzt das JWT in einem HttpOnly-Cookie.
6. Der Browser wird auf eine geschuetzte Seite weitergeleitet.
Hermes setzt kein Cookie.
### Logout
1. Der Browser sendet `POST /api/logout` an Athena.
2. Athena loescht das HttpOnly-Cookie.
3. Der Benutzer wird zur Login-Seite gefuehrt.
### HttpOnly Cookie
Das Auth-Cookie wird von Athena gesetzt.
Eigenschaften:
- `HttpOnly`
- `SameSite=Lax`
- `Path=/`
- `Secure` in Produktion
- Ablaufzeit orientiert sich an der JWT-Ablaufzeit
Tokens duerfen nicht in `localStorage` oder `sessionStorage` gespeichert werden.
### JWT
Hermes erzeugt JWT Access Tokens.
Wichtige Claims:
- `sub`: Benutzername
- `exp`: Ablaufzeit
- `iat`: Ausstellungszeitpunkt
- `iss`: konfigurierter Issuer
- `type`: Token-Typ, aktuell `access`
### Issuer
Der Issuer wird ueber `JWT_ISSUER` konfiguriert. Hermes prueft den Issuer bei der Token-Validierung.
### Ablaufzeit
Die Ablaufzeit wird ueber `ACCESS_TOKEN_EXPIRE_MINUTES` konfiguriert. Athena nutzt die von Hermes gelieferte Ablaufzeit fuer das Cookie.
### `proxy.ts`
Athena schuetzt Routen ueber `frontend/athena/proxy.ts`.
Aufgaben:
- geschuetzte Bereiche ohne Cookie auf `/login` umleiten
- eingeloggte Benutzer von `/login` auf `/dashboard` umleiten
## API-Datenfluss
Standardfluss:
```text
Browser -> Athena /api/... -> Hermes -> PostgreSQL
```
Beispiel Benutzerliste:
```text
Browser
GET /api/users
Athena
GET {HERMES_INTERNAL_URL}/users
Authorization: Bearer <jwt aus HttpOnly Cookie>
Hermes
SQLAlchemy Query gegen PostgreSQL
```
Athena ist die einzige API-Oberflaeche fuer den Browser.
## RBAC: Rollen und Berechtigungen
Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module.
Konzepte:
- Benutzer besitzen genau eine primaere Rolle.
- Rollen besitzen mehrere Berechtigungen.
- Berechtigungen sind stabile String-Keys wie `users.read`.
- Hermes prueft Berechtigungen serverseitig ueber Dependencies.
- Athena darf Permissions fuer UI-Sichtbarkeit nutzen, aber niemals als Auth-Quelle.
Tabellen:
- `roles`
- `permissions`
- `role_permissions`
- `users.role_id`
Das alte `users.role`-Feld bleibt vorerst als Legacy-Kompatibilitaet erhalten. Neue Logik verwendet `users.role_id` und die `roles`-Beziehung.
### Standardrollen
- `administrator`
- `management`
- `sales`
- `technician`
- `support`
- `warehouse`
- `guest`
`administrator` erhaelt alle Berechtigungen. `management` erhaelt mindestens Lesezugriff auf Dashboard, Benutzer, Kunden, Projekte und Tickets. `guest` erhaelt minimal `dashboard.read`.
### Standardberechtigungen
Benutzer:
- `users.read`
- `users.create`
- `users.update`
- `users.delete`
- `users.password.update`
Rollen:
- `roles.read`
- `roles.create`
- `roles.update`
- `roles.delete`
- `roles.assign`
Kunden:
- `customers.read`
- `customers.create`
- `customers.update`
- `customers.delete`
Projekte:
- `projects.read`
- `projects.create`
- `projects.update`
- `projects.delete`
Tickets:
- `tickets.read`
- `tickets.create`
- `tickets.update`
- `tickets.delete`
Dashboard und System:
- `dashboard.read`
- `system.settings.read`
- `system.settings.update`
### Berechtigungspruefung
Zentrale Hermes-Dependencies:
- `get_current_user`
- `get_current_active_user`
- `require_permission("permission.name")`
- `require_any_permission([...])`
- `require_all_permissions([...])`
Backend-Endpunkte muessen immer selbst pruefen. Frontend-Helfer wie `hasPermission` dienen nur dazu, Buttons oder Navigation auszublenden.
### Rollen-API
Hermes:
- `GET /roles`
- `GET /roles/{id}`
- `POST /roles`
- `PUT /roles/{id}`
- `DELETE /roles/{id}`
- `PUT /roles/{id}/permissions`
- `GET /permissions`
Athena stellt entsprechende BFF-Routen unter `/api/roles`, `/api/permissions` und `/api/me` bereit.
## Benutzerverwaltung
Die Benutzerverwaltung ist das erste Enterprise-Modul und dient als Vorlage fuer weitere Module.
Funktionen:
- Benutzerliste
- Detailseite
- Erstellen
- Bearbeiten
- Loeschen
- Passwort separat aendern
- Suche, Filter, Sortierung und Pagination im Frontend
Backend-Endpunkte in Hermes:
- `GET /users`
- `GET /users/{id}`
- `POST /users`
- `PUT /users/{id}`
- `PUT /users/{id}/password`
- `DELETE /users/{id}`
Athena stellt die passenden BFF-Routen unter `/api/users` und `/api/users/[id]` bereit.
Diese Endpunkte sind per RBAC geschuetzt:
- `GET /users` und `GET /users/{id}`: `users.read`
- `POST /users`: `users.create` und fuer Rollenzuweisung `roles.assign`
- `PUT /users/{id}`: `users.update`; Rollenwechsel benoetigt `roles.assign`
- `PUT /users/{id}/password`: `users.password.update`
- `DELETE /users/{id}`: `users.delete`
### Passwortaenderung
Wenn beim Bearbeiten kein Passwort uebergeben wird, bleibt das bestehende Passwort unveraendert.
Wenn ein Passwort gesetzt wird, wird es in Hermes mit `pwdlib` neu gehasht.
### Self-Delete-Schutz
Der aktuell angemeldete Benutzer darf sich nicht selbst loeschen. Hermes verhindert dies serverseitig.
### Rollenmodell
Benutzer besitzen eine primaere RBAC-Rolle ueber `role_id`. Die Rolle bestimmt die serverseitigen Berechtigungen.
## Verzeichnisstruktur
```text
backend/hermes
app/
api/
core/
db/
models/
repositories/
schemas/
alembic/
alembic.ini
dockerfile
pyproject.toml
frontend/athena
app/
api/
roles/
users/
components/
lib/
types/
proxy.ts
Dockerfile
package.json
docker-compose.yml
```
## Umgebungsvariablen
### Hermes
`DATABASE_URL`
: PostgreSQL-Verbindungsstring.
`SECRET_KEY`
: Signierschluessel fuer JWTs. Muss geheim bleiben.
`JWT_ISSUER`
: Erwarteter JWT-Issuer, z. B. `hermes`.
`ACCESS_TOKEN_EXPIRE_MINUTES`
: Ablaufzeit fuer Access Tokens in Minuten.
### Athena
`HERMES_INTERNAL_URL`
: Serverseitige URL, ueber die Athena Hermes erreicht.
`AUTH_COOKIE_SECURE`
: Steuert das `Secure`-Flag des Cookies. In Produktion `true`.
## Datenbankmigrationen
Olympus verwendet Alembic fuer Datenbankmigrationen.
Regeln:
- Schemaaenderungen immer ueber Alembic.
- Migrationen muessen ins Repository aufgenommen werden.
- Vor Deployment `alembic upgrade head` testen.
- Keine manuellen DB-Aenderungen ausser in Notfaellen.
Typischer Ablauf:
```bash
cd backend/hermes
alembic upgrade head
```
## Sicherheitsprinzipien
Verbindliche Regeln:
- Keine Tokens im `localStorage`.
- Keine Tokens im `sessionStorage`.
- Keine Browserzugriffe auf Hermes.
- Keine Cross-Origin-Cookies.
- Hermes bleibt Cookie-frei.
- Athena setzt HttpOnly Cookies.
- Mutierende Athena-API-Routes pruefen Same-Origin.
- Hermes prueft JWTs serverseitig.
- Hermes prueft RBAC-Berechtigungen serverseitig.
- Secrets gehoeren in Umgebungsvariablen, nicht in den Code.
## Produktionshinweise
Fuer Produktion gilt:
- HTTPS ist erforderlich.
- `AUTH_COOKIE_SECURE=true` setzen.
- Reverse Proxy ist moeglich und soll Forwarded Header korrekt setzen.
- `HERMES_INTERNAL_URL` muss serverseitig erreichbar sein.
- `SECRET_KEY` muss stark, geheim und stabil sein.
- Datenbankmigrationen vor App-Rollout ausfuehren.
Lokaler HTTP-Betrieb ist nur fuer Entwicklung gedacht. In Produktion duerfen keine unsicheren Cookie-Einstellungen verwendet werden.

270
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,270 @@
# Mitwirken an Olympus CRM
Dieses Dokument beschreibt verbindliche Entwicklungsregeln fuer Olympus CRM.
Ziel ist ein stabiler, nachvollziehbarer und produktionsfaehiger Entwicklungsprozess. Keine Quickfixes, keine Workarounds, keine Architektur-Abkuerzungen.
## Branching-Modell
`main` bleibt immer lauffaehig.
Neue Arbeit erfolgt in eigenen Branches:
- Features: `feature/<name>`
- Bugfixes: `fix/<name>`
- Dokumentation: `docs/<name>`
Beispiele:
```text
feature/users-detail-page
feature/customer-module
fix/auth-cookie-localhost
docs/architecture
```
Direkte Commits auf `main` sind zu vermeiden.
## Commit-Konventionen
Olympus verwendet Conventional Commits.
Format:
```text
type(scope): kurze beschreibung
```
Beispiele:
```text
feat(auth): add bff login route
feat(users): implement user detail page
fix(auth): accept forwarded origin behind proxy
docs: add architecture documentation
refactor(users): extract reusable data table
```
Zulaessige Typen:
- `feat`: neue Funktion
- `fix`: Fehlerbehebung
- `docs`: Dokumentation
- `refactor`: interne Umstrukturierung ohne Verhaltensaenderung
- `test`: Tests
- `chore`: Wartung, Build, Tooling
Commits sollen klein, fachlich zusammenhaengend und reviewbar sein.
## Definition of Done
Eine Aenderung gilt erst als fertig, wenn diese Punkte erfuellt sind:
- `npm run lint` erfolgreich in `frontend/athena`
- `npx next build --webpack` erfolgreich in `frontend/athena`
- `python3 -m compileall backend/hermes/app` erfolgreich im Projektroot
- Alembic-Migrationen geprueft, falls Datenbankschema betroffen ist
- Docker Compose startet
- keine `.env` im Git
- keine `node_modules` im Git
- keine `.next` im Git
- keine `.venv` im Git
- keine toten Imports
- keine ungenutzten Dateien
- keine Debug-Ausgaben wie `console.log`, `alert` oder `confirm`
Standard-Checks:
```bash
cd frontend/athena
npm run lint
npx next build --webpack
```
```bash
python3 -m compileall backend/hermes/app
```
## Coding Standards
### Frontend
- TypeScript strikt verwenden.
- Next.js 16 App Router Konventionen einhalten.
- Browser-API-Zugriffe nur ueber Athena `/api/...`.
- Wiederverwendbare Komponenten bevorzugen.
- UI-Zustaende immer abbilden: Loading, Error, Empty State.
- Keine Browser-Dialoge wie `alert()` oder `confirm()`.
- Keine Tokens in Browser-JavaScript speichern.
### Backend
- FastAPI sauber nach API, Schema, Repository und Core trennen.
- SQLAlchemy 2.x Patterns verwenden.
- Pydantic v2 fuer Request- und Response-Modelle verwenden.
- HTTP-Fehler bewusst mit passenden Statuscodes ausgeben.
- Fachliche Konflikte als `409 Conflict`.
- Nicht gefundene Ressourcen als `404 Not Found`.
- Authentifizierung serverseitig pruefen.
- Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen.
### Allgemein
- Keine TODOs als Ersatz fuer fertige Implementierung.
- Keine Quickfixes.
- Keine Workarounds.
- Keine ungeprueften Annahmen bei Auth, Datenbank oder Docker.
- Bestehende Architektur respektieren.
## Architekturregeln
Diese Regeln sind verbindlich:
- Browser spricht niemals direkt mit Hermes.
- Alle Frontend-API-Zugriffe laufen ueber Athena `/api`.
- Athena ruft Hermes serverseitig auf.
- Hermes bleibt Cookie-frei.
- Authentifizierung bleibt serverseitig.
- Hermes erwartet JWT Bearer Tokens.
- Athena setzt und loescht das HttpOnly Cookie.
- Keine Cross-Origin-Cookie-Loesungen.
- RBAC wird serverseitig in Hermes durchgesetzt.
- Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung.
Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden.
## Neue Module
Neue CRM-Module sollen die bestehenden Muster wiederverwenden.
Beispiele fuer spaetere Module:
- Kunden
- Projekte
- Lieferanten
- Kontakte
- Mitarbeiter
Verbindliche Wiederverwendung:
- `DataTable` fuer tabellarische Listen
- `ConfirmDialog` fuer destruktive Aktionen
- `SearchInput` fuer Suchfelder
- `StatusBadge` oder aehnliche Badge-Komponenten fuer Statusanzeigen
- `api` Client aus `frontend/athena/lib/api.ts`
- Athena API-Routes als BFF-Schicht
- konsistente Fehlerbehandlung
- zentrale Permission-Helfer aus `frontend/athena/lib/permissions.ts`
Neue Module sollen mindestens diese UI-Zustaende unterstuetzen:
- Ladezustand
- Fehlerzustand
- Empty State
- Validierung
- Erfolg ohne Page Reload
### Neue Permissions
Neue Module muessen eigene stabile Permission-Strings erhalten.
Namensschema:
```text
<module>.<action>
```
Beispiele:
```text
customers.read
customers.create
projects.update
tickets.delete
```
Regeln:
- Permission in `backend/hermes/app/rbac/defaults.py` ergaenzen.
- Rollen-Mapping in `ROLE_PERMISSION_NAMES` pruefen.
- Falls noetig Alembic-Migration fuer bestehende Installationen ergaenzen.
- Hermes-Endpunkte mit `require_permission(...)` schuetzen.
- Athena UI-Aktionen mit `hasPermission(...)` ausblenden oder deaktivieren.
- Backend bleibt immer massgeblich.
## Review-Checkliste
Vor Merge pruefen:
- Auth-Architektur nicht gebrochen
- Browser ruft Hermes nicht direkt auf
- Docker laeuft
- Migrationen vorhanden, falls Schema geaendert wurde
- Migrationen getestet
- UI konsistent mit bestehenden Komponenten
- keine Secrets committed
- keine `.env` committed
- keine toten Imports
- keine ungenutzten Dateien
- keine Debug-Ausgaben
- keine Build-Artefakte committed
- keine neuen CORS- oder Cookie-Workarounds
- Permission-Pruefung fuer neue Endpunkte vorhanden
- Permission-UI nur als Komfort, nicht als Sicherheitsgrenze
## Migrationsregeln
Datenbankschema wird ausschliesslich ueber Alembic geaendert.
Regeln:
- Jede Schemaaenderung braucht eine Migration.
- Migrationen werden ins Repository aufgenommen.
- `alembic upgrade head` muss getestet werden.
- Downgrade sollte sinnvoll sein, sofern moeglich.
- Keine manuellen Produktions-DB-Aenderungen ohne Dokumentation.
Typischer Ablauf:
```bash
cd backend/hermes
alembic upgrade head
```
## Sicherheitsregeln
Verbindlich:
- Keine Secrets im Code.
- Keine Secrets in Commits.
- Keine Tokens im Browser-JavaScript.
- Keine Auth in `localStorage`.
- Keine Auth in `sessionStorage`.
- Keine CORS-Cookie-Workarounds.
- Keine Cross-Origin-Cookies.
- Produktiv nur HTTPS.
- `AUTH_COOKIE_SECURE=true` in Produktion.
- `SECRET_KEY` stark und geheim halten.
Bei Auth-Aenderungen muessen Login, Logout, geschuetzte Seiten und Athena-API-Routes gemeinsam geprueft werden.
Bei RBAC-Aenderungen muessen Rollen, Permissions, betroffene API-Endpunkte, Navigation und UI-Aktionen gemeinsam geprueft werden.
## Lokale Entwicklung
Lokaler Betrieb kann ueber Docker Compose erfolgen.
Wichtige lokale Einstellungen:
```env
AUTH_COOKIE_SECURE=false
ATHENA_PUBLIC_ORIGIN=http://localhost:3001
```
Diese Werte sind nur fuer lokale HTTP-Entwicklung gedacht. In Produktion muessen sichere Werte verwendet werden.
## Dokumentationspflicht
Architekturentscheidungen, neue Module, neue Umgebungsvariablen und Datenbankmigrationen muessen dokumentiert werden.
Wenn sich die BFF-Architektur, Authentifizierung, Docker-Konfiguration oder Datenbankstruktur aendert, muss `ARCHITECTURE.md` aktualisiert werden.

View file

@ -0,0 +1,269 @@
"""add rbac
Revision ID: 7b2c9f4a0d31
Revises: 3f4c7b8d1e2a
Create Date: 2026-07-02 13:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "7b2c9f4a0d31"
down_revision: Union[str, Sequence[str], None] = "3f4c7b8d1e2a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ROLES = [
("administrator", "Administrator", "Vollzugriff auf Olympus CRM"),
("management", "Management", "Lesender Zugriff auf operative Kernmodule"),
("sales", "Vertrieb", "Zugriff auf Kunden und Projekte"),
("technician", "Technik", "Zugriff auf Tickets und Projekte"),
("support", "Support", "Operativer Support-Zugriff"),
("warehouse", "Lager", "Zugriff auf lagernahe Prozesse"),
("guest", "Gast", "Minimaler Zugriff"),
]
PERMISSIONS = [
("users.read", "Benutzer lesen", "Benutzer anzeigen", "users"),
("users.create", "Benutzer erstellen", "Benutzer anlegen", "users"),
("users.update", "Benutzer bearbeiten", "Benutzer aktualisieren", "users"),
("users.delete", "Benutzer loeschen", "Benutzer entfernen", "users"),
("users.password.update", "Passwort aendern", "Benutzerpasswort aendern", "users"),
("roles.read", "Rollen lesen", "Rollen anzeigen", "roles"),
("roles.create", "Rollen erstellen", "Rollen anlegen", "roles"),
("roles.update", "Rollen bearbeiten", "Rollen aktualisieren", "roles"),
("roles.delete", "Rollen loeschen", "Rollen entfernen", "roles"),
("roles.assign", "Rollen zuweisen", "Berechtigungen Rollen zuweisen", "roles"),
("customers.read", "Kunden lesen", "Kunden anzeigen", "customers"),
("customers.create", "Kunden erstellen", "Kunden anlegen", "customers"),
("customers.update", "Kunden bearbeiten", "Kunden aktualisieren", "customers"),
("customers.delete", "Kunden loeschen", "Kunden entfernen", "customers"),
("projects.read", "Projekte lesen", "Projekte anzeigen", "projects"),
("projects.create", "Projekte erstellen", "Projekte anlegen", "projects"),
("projects.update", "Projekte bearbeiten", "Projekte aktualisieren", "projects"),
("projects.delete", "Projekte loeschen", "Projekte entfernen", "projects"),
("tickets.read", "Tickets lesen", "Tickets anzeigen", "tickets"),
("tickets.create", "Tickets erstellen", "Tickets anlegen", "tickets"),
("tickets.update", "Tickets bearbeiten", "Tickets aktualisieren", "tickets"),
("tickets.delete", "Tickets loeschen", "Tickets entfernen", "tickets"),
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
]
def upgrade() -> None:
op.create_table(
"roles",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False),
sa.Column("display_name", sa.String(length=120), nullable=False),
sa.Column("description", sa.String(length=500), server_default="", nullable=False),
sa.Column("is_system", sa.Boolean(), server_default=sa.true(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True)
op.create_table(
"permissions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("display_name", sa.String(length=160), nullable=False),
sa.Column("description", sa.String(length=500), server_default="", nullable=False),
sa.Column("module", sa.String(length=80), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_permissions_module"), "permissions", ["module"], unique=False)
op.create_index(op.f("ix_permissions_name"), "permissions", ["name"], unique=True)
op.create_table(
"role_permissions",
sa.Column("role_id", sa.Integer(), nullable=False),
sa.Column("permission_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["permission_id"], ["permissions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("role_id", "permission_id"),
)
roles_table = sa.table(
"roles",
sa.column("name", sa.String),
sa.column("display_name", sa.String),
sa.column("description", sa.String),
sa.column("is_system", sa.Boolean),
)
permissions_table = sa.table(
"permissions",
sa.column("name", sa.String),
sa.column("display_name", sa.String),
sa.column("description", sa.String),
sa.column("module", sa.String),
)
op.bulk_insert(
roles_table,
[
{
"name": name,
"display_name": display_name,
"description": description,
"is_system": True,
}
for name, display_name, description in ROLES
],
)
op.bulk_insert(
permissions_table,
[
{
"name": name,
"display_name": display_name,
"description": description,
"module": module,
}
for name, display_name, description, module in PERMISSIONS
],
)
op.execute(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
CROSS JOIN permissions
WHERE roles.name = 'administrator'
"""
)
op.execute(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.name IN (
'dashboard.read',
'users.read',
'customers.read',
'projects.read',
'tickets.read'
)
WHERE roles.name = 'management'
"""
)
op.execute(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.name IN (
'dashboard.read',
'customers.read',
'customers.create',
'customers.update',
'projects.read',
'tickets.read'
)
WHERE roles.name = 'sales'
"""
)
op.execute(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.name IN (
'dashboard.read',
'projects.read',
'tickets.read',
'tickets.create',
'tickets.update'
)
WHERE roles.name = 'technician'
"""
)
op.execute(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.name IN (
'dashboard.read',
'users.read',
'customers.read',
'tickets.read',
'tickets.create',
'tickets.update'
)
WHERE roles.name = 'support'
"""
)
op.execute(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.name IN (
'dashboard.read'
)
WHERE roles.name IN ('warehouse', 'guest')
"""
)
op.add_column("users", sa.Column("role_id", sa.Integer(), nullable=True))
op.create_index(op.f("ix_users_role_id"), "users", ["role_id"], unique=False)
op.create_foreign_key("fk_users_role_id_roles", "users", "roles", ["role_id"], ["id"])
op.execute(
"""
UPDATE users
SET role_id = roles.id
FROM roles
WHERE roles.name = CASE
WHEN users.role IN ('admin', 'administrator') THEN 'administrator'
WHEN users.role IN ('manager', 'management') THEN 'management'
WHEN users.role = 'sales' THEN 'sales'
WHEN users.role IN ('technician', 'tech') THEN 'technician'
WHEN users.role = 'warehouse' THEN 'warehouse'
WHEN users.role = 'guest' THEN 'guest'
ELSE 'support'
END
"""
)
op.execute(
"""
UPDATE users
SET role_id = (SELECT id FROM roles WHERE name = 'administrator')
WHERE id = (
SELECT id
FROM users
ORDER BY id
LIMIT 1
)
AND NOT EXISTS (
SELECT 1
FROM users
JOIN roles ON users.role_id = roles.id
WHERE roles.name = 'administrator'
)
"""
)
op.alter_column("users", "role_id", existing_type=sa.Integer(), nullable=False)
def downgrade() -> None:
op.drop_constraint("fk_users_role_id_roles", "users", type_="foreignkey")
op.drop_index(op.f("ix_users_role_id"), table_name="users")
op.drop_column("users", "role_id")
op.drop_table("role_permissions")
op.drop_index(op.f("ix_permissions_name"), table_name="permissions")
op.drop_index(op.f("ix_permissions_module"), table_name="permissions")
op.drop_table("permissions")
op.drop_index(op.f("ix_roles_name"), table_name="roles")
op.drop_table("roles")

View file

@ -6,8 +6,11 @@ from app.core.security import (
get_access_token_expire_seconds,
verify_password,
)
from app.core.rbac import get_current_active_user, get_user_permission_names
from app.db.database import get_db
from app.models.user import User
from app.repositories.user_repository import UserRepository
from app.schemas.rbac import CurrentUserResponse
from app.schemas.user import LoginRequest, LoginResponse
router = APIRouter(
@ -21,7 +24,7 @@ def login(
login: LoginRequest,
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == login.username).first()
user = UserRepository.get_by_username(db, login.username)
if not user:
raise HTTPException(
@ -43,3 +46,14 @@ def login(
"expires_in": get_access_token_expire_seconds(),
"user": user,
}
@router.get("/me", response_model=CurrentUserResponse)
def me(current_user: User = Depends(get_current_active_user)):
return {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"role": current_user.primary_role.name,
"permissions": sorted(get_user_permission_names(current_user)),
}

View file

@ -0,0 +1,27 @@
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.user import User
from app.repositories.rbac_repository import RbacRepository
from app.schemas.rbac import PermissionResponse
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/permissions",
tags=["Permissions"],
)
@router.get("", response_model=list[PermissionResponse])
@router.get("/", response_model=list[PermissionResponse], include_in_schema=False)
def get_permissions(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.read")),
):
logger.info("permissions.list", extra={"actor_user_id": current_user.id})
return RbacRepository.get_permissions(db)

View file

@ -0,0 +1,153 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.user import User
from app.repositories.rbac_repository import RbacRepository
from app.schemas.rbac import RoleCreate, RolePermissionUpdate, RoleResponse, RoleUpdate
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/roles",
tags=["Roles"],
)
def get_role_or_404(db: Session, role_id: int):
role = RbacRepository.get_role_by_id(db, role_id)
if role is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Rolle nicht gefunden",
)
return role
def ensure_unique_role_name(db: Session, name: str, role_id: int | None = None) -> None:
role = RbacRepository.get_role_by_name(db, name)
if role is not None and role.id != role_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Rollenname ist bereits vergeben",
)
def ensure_permission_ids_exist(db: Session, permission_ids: list[int]) -> None:
permissions = RbacRepository.get_permissions_by_ids(db, permission_ids)
if len(permissions) != len(set(permission_ids)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mindestens eine Berechtigung existiert nicht",
)
@router.get("", response_model=list[RoleResponse])
@router.get("/", response_model=list[RoleResponse], include_in_schema=False)
def get_roles(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.read")),
):
logger.info("roles.list", extra={"actor_user_id": current_user.id})
return RbacRepository.get_roles(db)
@router.get("/{role_id}", response_model=RoleResponse)
def get_role(
role_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.read")),
):
logger.info(
"roles.detail",
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
)
return get_role_or_404(db, role_id)
@router.post("", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/",
response_model=RoleResponse,
status_code=status.HTTP_201_CREATED,
include_in_schema=False,
)
def create_role(
role: RoleCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.create")),
):
ensure_unique_role_name(db, role.name)
ensure_permission_ids_exist(db, role.permission_ids)
logger.info("roles.create", extra={"actor_user_id": current_user.id})
return RbacRepository.create_role(db, role)
@router.put("/{role_id}", response_model=RoleResponse)
def update_role(
role_id: int,
role: RoleUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.update")),
):
db_role = get_role_or_404(db, role_id)
ensure_unique_role_name(db, role.name, role_id)
ensure_permission_ids_exist(db, role.permission_ids)
logger.info(
"roles.update",
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
)
return RbacRepository.update_role(db, db_role, role)
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_role(
role_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.delete")),
):
db_role = get_role_or_404(db, role_id)
if db_role.is_system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Systemrollen dürfen nicht gelöscht werden",
)
assigned_user_id = db.scalar(select(User.id).where(User.role_id == role_id).limit(1))
if assigned_user_id is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Rolle ist Benutzern zugewiesen und kann nicht gelöscht werden",
)
logger.info(
"roles.delete",
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
)
RbacRepository.delete_role(db, db_role)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.put("/{role_id}/permissions", response_model=RoleResponse)
def update_role_permissions(
role_id: int,
permission_update: RolePermissionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.assign")),
):
db_role = get_role_or_404(db, role_id)
ensure_permission_ids_exist(db, permission_update.permission_ids)
logger.info(
"roles.permissions_update",
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
)
return RbacRepository.update_role_permissions(db, db_role, permission_update)

View file

@ -1,12 +1,12 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.security import verify_access_token
from app.core.rbac import get_user_permission_names, require_permission
from app.db.database import get_db
from app.models.user import User
from app.repositories.rbac_repository import RbacRepository
from app.repositories.user_repository import UserRepository
from app.schemas.user import UserCreate, UserPasswordUpdate, UserResponse, UserUpdate
@ -17,45 +17,6 @@ router = APIRouter(
tags=["Users"],
)
bearer_scheme = HTTPBearer(auto_error=False)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> User:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
username = verify_access_token(credentials.credentials)
user = UserRepository.get_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_role(*allowed_roles: str):
def dependency(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Keine Berechtigung",
)
return current_user
return dependency
def get_user_or_404(db: Session, user_id: int) -> User:
db_user = UserRepository.get_by_id(db, user_id)
@ -80,11 +41,36 @@ def raise_conflict(field: str) -> None:
)
def require_actor_permission(current_user: User, permission: str) -> None:
if permission not in get_user_permission_names(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Keine Berechtigung",
)
def apply_role(db: Session, user: UserCreate | UserUpdate) -> None:
role = None
if user.role_id is not None:
role = RbacRepository.get_role_by_id(db, user.role_id)
else:
role = RbacRepository.get_role_by_name(db, user.role)
if role is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Rolle nicht gefunden",
)
user.role_id = role.id
user.role = role.name
@router.get("", response_model=list[UserResponse])
@router.get("/", response_model=list[UserResponse], include_in_schema=False)
def get_users(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_permission("users.read")),
):
logger.info("users.list", extra={"actor_user_id": current_user.id})
return UserRepository.get_all(db)
@ -94,7 +80,7 @@ def get_users(
def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_permission("users.read")),
):
logger.info(
"users.detail",
@ -113,8 +99,10 @@ def get_user(
def create_user(
user: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_permission("users.create")),
):
require_actor_permission(current_user, "roles.assign")
apply_role(db, user)
conflict = UserRepository.find_conflict(
db,
username=user.username,
@ -133,9 +121,12 @@ def update_user(
user_id: int,
user: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_permission("users.update")),
):
db_user = get_user_or_404(db, user_id)
apply_role(db, user)
if user.role_id != db_user.role_id:
require_actor_permission(current_user, "roles.assign")
conflict = UserRepository.find_conflict(
db,
username=user.username,
@ -158,7 +149,7 @@ def update_user_password(
user_id: int,
password_update: UserPasswordUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_permission("users.password.update")),
):
db_user = get_user_or_404(db, user_id)
@ -173,7 +164,7 @@ def update_user_password(
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User = Depends(require_permission("users.delete")),
):
if current_user.id == user_id:
raise HTTPException(

View file

@ -0,0 +1,94 @@
from collections.abc import Iterable
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.security import verify_access_token
from app.db.database import get_db
from app.models.user import User
from app.repositories.user_repository import UserRepository
bearer_scheme = HTTPBearer(auto_error=False)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> User:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
username = verify_access_token(credentials.credentials)
user = UserRepository.get_by_username(db, username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
def get_user_permission_names(user: User) -> set[str]:
if user.primary_role is None:
return set()
return {permission.name for permission in user.primary_role.permissions}
def require_permission(permission: str):
def dependency(current_user: User = Depends(get_current_active_user)) -> User:
if permission not in get_user_permission_names(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Keine Berechtigung",
)
return current_user
return dependency
def require_any_permission(permissions: Iterable[str]):
permission_set = set(permissions)
def dependency(current_user: User = Depends(get_current_active_user)) -> User:
if get_user_permission_names(current_user).isdisjoint(permission_set):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Keine Berechtigung",
)
return current_user
return dependency
def require_all_permissions(permissions: Iterable[str]):
permission_set = set(permissions)
def dependency(current_user: User = Depends(get_current_active_user)) -> User:
if not permission_set.issubset(get_user_permission_names(current_user)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Keine Berechtigung",
)
return current_user
return dependency

View file

@ -20,6 +20,7 @@ class Base(DeclarativeBase):
pass
# <<< HIER IMPORTIEREN >>>
import app.models.rbac
import app.models.user

View file

@ -9,8 +9,12 @@ from starlette import status
from starlette.requests import Request
from app.api.auth import router as auth_router
from app.api.permissions import router as permissions_router
from app.api.roles import router as roles_router
from app.api.users import router as users_router
from app.db.database import SessionLocal
from app.db.health import check_database
from app.rbac.seed import seed_rbac
app = FastAPI(
title="Hermes API",
@ -20,10 +24,21 @@ app = FastAPI(
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(roles_router)
app.include_router(permissions_router)
logger = logging.getLogger(__name__)
@app.on_event("startup")
def startup_seed_rbac():
db = SessionLocal()
try:
seed_rbac(db)
finally:
db.close()
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.warning(

View file

@ -0,0 +1,58 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Table, Column, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("permission_id", ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
)
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(80), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(120))
description: Mapped[str] = mapped_column(String(500), default="", server_default="")
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
permissions: Mapped[list["Permission"]] = relationship(
secondary=role_permissions,
back_populates="roles",
lazy="selectin",
)
class Permission(Base):
__tablename__ = "permissions"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(160))
description: Mapped[str] = mapped_column(String(500), default="", server_default="")
module: Mapped[str] = mapped_column(String(80), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
roles: Mapped[list[Role]] = relationship(
secondary=role_permissions,
back_populates="permissions",
lazy="selectin",
)

View file

@ -2,8 +2,10 @@ from datetime import datetime
from sqlalchemy import Boolean
from sqlalchemy import DateTime
from sqlalchemy import ForeignKey
from sqlalchemy import String
from sqlalchemy import func
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
@ -48,6 +50,13 @@ class User(Base):
server_default="user"
)
role_id: Mapped[int] = mapped_column(
ForeignKey("roles.id"),
index=True,
)
primary_role = relationship("Role", lazy="joined")
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,

View file

@ -0,0 +1,112 @@
STANDARD_ROLES = [
{
"name": "administrator",
"display_name": "Administrator",
"description": "Vollzugriff auf Olympus CRM",
"is_system": True,
},
{
"name": "management",
"display_name": "Management",
"description": "Lesender Zugriff auf operative Kernmodule",
"is_system": True,
},
{
"name": "sales",
"display_name": "Vertrieb",
"description": "Zugriff auf Kunden und Projekte",
"is_system": True,
},
{
"name": "technician",
"display_name": "Technik",
"description": "Zugriff auf Tickets und Projekte",
"is_system": True,
},
{
"name": "support",
"display_name": "Support",
"description": "Operativer Support-Zugriff",
"is_system": True,
},
{
"name": "warehouse",
"display_name": "Lager",
"description": "Zugriff auf lagernahe Prozesse",
"is_system": True,
},
{
"name": "guest",
"display_name": "Gast",
"description": "Minimaler Zugriff",
"is_system": True,
},
]
STANDARD_PERMISSIONS = [
("users.read", "Benutzer lesen", "Benutzer anzeigen", "users"),
("users.create", "Benutzer erstellen", "Benutzer anlegen", "users"),
("users.update", "Benutzer bearbeiten", "Benutzer aktualisieren", "users"),
("users.delete", "Benutzer loeschen", "Benutzer entfernen", "users"),
("users.password.update", "Passwort aendern", "Benutzerpasswort aendern", "users"),
("roles.read", "Rollen lesen", "Rollen anzeigen", "roles"),
("roles.create", "Rollen erstellen", "Rollen anlegen", "roles"),
("roles.update", "Rollen bearbeiten", "Rollen aktualisieren", "roles"),
("roles.delete", "Rollen loeschen", "Rollen entfernen", "roles"),
("roles.assign", "Rollen zuweisen", "Berechtigungen Rollen zuweisen", "roles"),
("customers.read", "Kunden lesen", "Kunden anzeigen", "customers"),
("customers.create", "Kunden erstellen", "Kunden anlegen", "customers"),
("customers.update", "Kunden bearbeiten", "Kunden aktualisieren", "customers"),
("customers.delete", "Kunden loeschen", "Kunden entfernen", "customers"),
("projects.read", "Projekte lesen", "Projekte anzeigen", "projects"),
("projects.create", "Projekte erstellen", "Projekte anlegen", "projects"),
("projects.update", "Projekte bearbeiten", "Projekte aktualisieren", "projects"),
("projects.delete", "Projekte loeschen", "Projekte entfernen", "projects"),
("tickets.read", "Tickets lesen", "Tickets anzeigen", "tickets"),
("tickets.create", "Tickets erstellen", "Tickets anlegen", "tickets"),
("tickets.update", "Tickets bearbeiten", "Tickets aktualisieren", "tickets"),
("tickets.delete", "Tickets loeschen", "Tickets entfernen", "tickets"),
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
]
ROLE_PERMISSION_NAMES = {
"administrator": "*",
"management": {
"dashboard.read",
"users.read",
"customers.read",
"projects.read",
"tickets.read",
},
"sales": {
"dashboard.read",
"customers.read",
"customers.create",
"customers.update",
"projects.read",
"tickets.read",
},
"technician": {
"dashboard.read",
"projects.read",
"tickets.read",
"tickets.create",
"tickets.update",
},
"support": {
"dashboard.read",
"users.read",
"customers.read",
"tickets.read",
"tickets.create",
"tickets.update",
},
"warehouse": {
"dashboard.read",
},
"guest": {
"dashboard.read",
},
}

View file

@ -0,0 +1,63 @@
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.rbac import Permission, Role
from app.rbac.defaults import ROLE_PERMISSION_NAMES, STANDARD_PERMISSIONS, STANDARD_ROLES
logger = logging.getLogger(__name__)
def seed_rbac(db: Session) -> None:
roles_by_name = {
role.name: role
for role in db.scalars(select(Role)).all()
}
permissions_by_name = {
permission.name: permission
for permission in db.scalars(select(Permission)).all()
}
for role_data in STANDARD_ROLES:
role = roles_by_name.get(role_data["name"])
if role is None:
role = Role(**role_data)
db.add(role)
roles_by_name[role.name] = role
else:
role.display_name = role_data["display_name"]
role.description = role_data["description"]
role.is_system = role_data["is_system"]
for name, display_name, description, module in STANDARD_PERMISSIONS:
permission = permissions_by_name.get(name)
if permission is None:
permission = Permission(
name=name,
display_name=display_name,
description=description,
module=module,
)
db.add(permission)
permissions_by_name[name] = permission
else:
permission.display_name = display_name
permission.description = description
permission.module = module
db.flush()
all_permissions = set(permissions_by_name.values())
for role_name, permission_names in ROLE_PERMISSION_NAMES.items():
role = roles_by_name[role_name]
if permission_names == "*":
role.permissions = list(all_permissions)
else:
role.permissions = [
permissions_by_name[permission_name]
for permission_name in permission_names
]
db.commit()
logger.info("rbac.seeded")

View file

@ -0,0 +1,93 @@
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models.rbac import Permission, Role
from app.schemas.rbac import RoleCreate, RolePermissionUpdate, RoleUpdate
class RbacRepository:
@staticmethod
def get_roles(db: Session) -> list[Role]:
return list(
db.scalars(
select(Role)
.options(selectinload(Role.permissions))
.order_by(Role.name)
)
)
@staticmethod
def get_role_by_id(db: Session, role_id: int) -> Role | None:
return db.scalar(
select(Role)
.where(Role.id == role_id)
.options(selectinload(Role.permissions))
)
@staticmethod
def get_role_by_name(db: Session, name: str) -> Role | None:
return db.scalar(
select(Role)
.where(Role.name == name)
.options(selectinload(Role.permissions))
)
@staticmethod
def get_permissions(db: Session) -> list[Permission]:
return list(db.scalars(select(Permission).order_by(Permission.module, Permission.name)))
@staticmethod
def get_permissions_by_ids(db: Session, permission_ids: list[int]) -> list[Permission]:
if not permission_ids:
return []
return list(db.scalars(select(Permission).where(Permission.id.in_(permission_ids))))
@staticmethod
def create_role(db: Session, role: RoleCreate) -> Role:
db_role = Role(
name=role.name,
display_name=role.display_name,
description=role.description,
is_system=False,
)
db_role.permissions = RbacRepository.get_permissions_by_ids(db, role.permission_ids)
db.add(db_role)
db.commit()
db.refresh(db_role)
return RbacRepository.get_role_by_id(db, db_role.id) or db_role
@staticmethod
def update_role(db: Session, db_role: Role, role: RoleUpdate) -> Role:
db_role.name = role.name
db_role.display_name = role.display_name
db_role.description = role.description
db_role.permissions = RbacRepository.get_permissions_by_ids(db, role.permission_ids)
db.commit()
db.refresh(db_role)
return RbacRepository.get_role_by_id(db, db_role.id) or db_role
@staticmethod
def update_role_permissions(
db: Session,
db_role: Role,
permission_update: RolePermissionUpdate,
) -> Role:
db_role.permissions = RbacRepository.get_permissions_by_ids(
db,
permission_update.permission_ids,
)
db.commit()
db.refresh(db_role)
return RbacRepository.get_role_by_id(db, db_role.id) or db_role
@staticmethod
def delete_role(db: Session, db_role: Role) -> None:
db.delete(db_role)
db.commit()

View file

@ -1,7 +1,8 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload, selectinload
from app.core.security import hash_password
from app.models.rbac import Role
from app.models.user import User
from app.schemas.user import UserCreate, UserPasswordUpdate, UserUpdate
@ -9,15 +10,31 @@ from app.schemas.user import UserCreate, UserPasswordUpdate, UserUpdate
class UserRepository:
@staticmethod
def get_all(db: Session) -> list[User]:
return list(db.scalars(select(User).order_by(User.created_at.desc())))
return list(
db.scalars(
select(User)
.options(
joinedload(User.primary_role).selectinload(Role.permissions),
)
.order_by(User.created_at.desc())
)
)
@staticmethod
def get_by_id(db: Session, user_id: int) -> User | None:
return db.get(User, user_id)
return db.scalar(
select(User)
.where(User.id == user_id)
.options(joinedload(User.primary_role).selectinload(Role.permissions))
)
@staticmethod
def get_by_username(db: Session, username: str) -> User | None:
return db.scalar(select(User).where(User.username == username))
return db.scalar(
select(User)
.where(User.username == username)
.options(joinedload(User.primary_role).selectinload(Role.permissions))
)
@staticmethod
def find_conflict(
@ -52,6 +69,7 @@ class UserRepository:
username=user.username,
email=str(user.email),
role=user.role,
role_id=user.role_id,
is_active=user.is_active,
password_hash=hash_password(user.password),
)
@ -60,7 +78,7 @@ class UserRepository:
db.commit()
db.refresh(db_user)
return db_user
return UserRepository.get_by_id(db, db_user.id) or db_user
@staticmethod
def update(db: Session, db_user: User, user: UserUpdate) -> User:
@ -69,6 +87,8 @@ class UserRepository:
db_user.username = user.username
db_user.email = str(user.email)
db_user.role = user.role
if user.role_id is not None:
db_user.role_id = user.role_id
db_user.is_active = user.is_active
if user.password:
@ -77,7 +97,7 @@ class UserRepository:
db.commit()
db.refresh(db_user)
return db_user
return UserRepository.get_by_id(db, db_user.id) or db_user
@staticmethod
def update_password(

View file

@ -0,0 +1,74 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
RoleName = str
class PermissionResponse(BaseModel):
id: int
name: str
display_name: str
description: str
module: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class RoleBase(BaseModel):
name: str = Field(min_length=3, max_length=80)
display_name: str = Field(min_length=2, max_length=120)
description: str = Field(default="", max_length=500)
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
normalized = value.strip().lower()
allowed = normalized.replace("_", "").replace("-", "").replace(".", "")
if not allowed.isalnum():
raise ValueError(
"Rollenname darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten"
)
return normalized
@field_validator("display_name", "description", mode="before")
@classmethod
def normalize_text(cls, value: object) -> str:
if value is None:
return ""
return str(value).strip()
class RoleCreate(RoleBase):
permission_ids: list[int] = Field(default_factory=list)
class RoleUpdate(RoleBase):
permission_ids: list[int] = Field(default_factory=list)
class RolePermissionUpdate(BaseModel):
permission_ids: list[int] = Field(default_factory=list)
class RoleResponse(BaseModel):
id: int
name: str
display_name: str
description: str
is_system: bool
permissions: list[PermissionResponse]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class CurrentUserResponse(BaseModel):
id: int
username: str
email: str
role: str
permissions: list[str]

View file

@ -1,9 +1,9 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserRole = Literal["admin", "manager", "user"]
from app.schemas.rbac import RoleResponse
UserRole = str
class UserBase(BaseModel):
@ -11,7 +11,8 @@ class UserBase(BaseModel):
last_name: str = Field(default="", max_length=100)
username: str = Field(min_length=3, max_length=50)
email: EmailStr
role: UserRole = "user"
role: UserRole = "support"
role_id: int | None = None
is_active: bool = True
@field_validator("first_name", "last_name", "username", mode="before")
@ -65,6 +66,8 @@ class UserResponse(BaseModel):
username: str
email: EmailStr
role: UserRole
role_id: int
primary_role: RoleResponse
is_active: bool
created_at: datetime
updated_at: datetime

View file

@ -0,0 +1,7 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/auth/me");
}

View file

@ -0,0 +1,7 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/permissions");
}

View file

@ -0,0 +1,21 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
id: string;
}>;
};
export async function PUT(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/roles/${id}/permissions`);
}

View file

@ -0,0 +1,39 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
id: string;
}>;
};
async function proxyRoleRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/roles/${id}`);
}
export async function GET(request: NextRequest, context: Params) {
return proxyRoleRequest(request, context);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyRoleRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyRoleRequest(request, context);
}

View file

@ -0,0 +1,18 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/roles");
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/roles");
}

View file

@ -1,14 +1,6 @@
import { NextRequest } from "next/server";
import {
clearAuthCookie,
getAccessToken,
getHermesUrl,
hermesJsonResponse,
unauthorizedResponse,
upstreamConfigurationErrorResponse,
upstreamUnavailableResponse,
} from "@/lib/server/hermes";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
@ -18,48 +10,11 @@ type Params = {
};
async function proxyUserRequest(request: NextRequest, { params }: Params) {
const token = await getAccessToken();
if (!token) {
return unauthorizedResponse();
}
const hermesUrl = getHermesUrl();
if (!hermesUrl) {
return upstreamConfigurationErrorResponse();
}
const { id } = await params;
const action = request.nextUrl.searchParams.get("action");
const path = action === "password" ? `/users/${id}/password` : `/users/${id}`;
let hermesResponse: Response;
try {
hermesResponse = await fetch(`${hermesUrl}${path}`, {
method: request.method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: request.method === "GET" || request.method === "DELETE"
? undefined
: await request.text(),
cache: "no-store",
});
} catch {
return upstreamUnavailableResponse();
}
if (hermesResponse.status === 401) {
const response = await hermesJsonResponse(hermesResponse);
clearAuthCookie(response);
return response;
}
return hermesJsonResponse(hermesResponse);
return proxyHermesRequest(request, path);
}
export async function GET(request: NextRequest, context: Params) {

View file

@ -1,53 +1,10 @@
import { NextRequest } from "next/server";
import {
clearAuthCookie,
getAccessToken,
getHermesUrl,
hermesJsonResponse,
unauthorizedResponse,
upstreamConfigurationErrorResponse,
upstreamUnavailableResponse,
} from "@/lib/server/hermes";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
async function proxyUsersRequest(request: NextRequest) {
const token = await getAccessToken();
if (!token) {
return unauthorizedResponse();
}
const hermesUrl = getHermesUrl();
if (!hermesUrl) {
return upstreamConfigurationErrorResponse();
}
let hermesResponse: Response;
try {
hermesResponse = await fetch(`${hermesUrl}/users${request.nextUrl.search}`, {
method: request.method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: request.method === "GET" ? undefined : await request.text(),
cache: "no-store",
});
} catch {
return upstreamUnavailableResponse();
}
if (hermesResponse.status === 401) {
const response = await hermesJsonResponse(hermesResponse);
clearAuthCookie(response);
return response;
}
return hermesJsonResponse(hermesResponse);
return proxyHermesRequest(request, `/users${request.nextUrl.search}`);
}
export async function GET(request: NextRequest) {

View file

@ -0,0 +1,535 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { Edit, Eye, Shield, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import SearchInput from "@/components/common/SearchInput";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import { hasPermission } from "@/lib/permissions";
import type { CurrentUser, Permission, Role, RolePayload } from "@/types/rbac";
const pageSize = 10;
function getErrorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string } } }).response;
return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden";
}
return "Aktion konnte nicht abgeschlossen werden";
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(new Date(value));
}
function getSortableValue(role: Role, key: string) {
const value = role[key as keyof Role];
return typeof value === "string" ? value.toLowerCase() : value;
}
export default function RolesPage() {
const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [pending, setPending] = useState(false);
const [search, setSearch] = useState("");
const [sortKey, setSortKey] = useState("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(1);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [detailsRole, setDetailsRole] = useState<Role | null>(null);
const [deleteRole, setDeleteRole] = useState<Role | null>(null);
const [formOpen, setFormOpen] = useState(false);
const loadRoles = useCallback(async () => {
try {
const [rolesResponse, permissionsResponse, meResponse] = await Promise.all([
api.get<Role[]>("/roles"),
api.get<Permission[]>("/permissions"),
api.get<CurrentUser>("/me"),
]);
setRoles(rolesResponse.data);
setPermissions(permissionsResponse.data);
setCurrentUser(meResponse.data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadRoles();
});
}, [loadRoles]);
const filteredRoles = useMemo(() => {
const term = search.trim().toLowerCase();
return roles
.filter((role) => {
if (!term) {
return true;
}
return [
role.name,
role.display_name,
role.description,
].some((value) => value.toLowerCase().includes(term));
})
.sort((first, second) => {
const firstValue = getSortableValue(first, sortKey);
const secondValue = getSortableValue(second, sortKey);
if (firstValue === secondValue) {
return 0;
}
const result = firstValue > secondValue ? 1 : -1;
return sortDirection === "asc" ? result : -result;
});
}, [roles, search, sortDirection, sortKey]);
const pageCount = Math.max(1, Math.ceil(filteredRoles.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pageRoles = filteredRoles.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const columns: DataTableColumn<Role>[] = [
{
key: "display_name",
label: "Rolle",
sortable: true,
render: (role) => (
<div>
<p className="font-medium text-slate-950">{role.display_name}</p>
<p className="text-xs text-slate-500">{role.name}</p>
</div>
),
},
{
key: "description",
label: "Beschreibung",
sortable: true,
render: (role) => role.description || "-",
},
{
key: "is_system",
label: "Typ",
sortable: true,
render: (role) => (
<span className="inline-flex h-6 items-center rounded-full bg-slate-100 px-2 text-xs font-medium text-slate-700">
{role.is_system ? "Systemrolle" : "Individuell"}
</span>
),
},
{
key: "permissions",
label: "Berechtigungen",
render: (role) => `${role.permissions.length}`,
},
{
key: "created_at",
label: "Erstellt",
sortable: true,
render: (role) => formatDate(role.created_at),
},
{
key: "actions",
label: "Aktionen",
className: "px-4 py-3 text-right",
render: (role) => (
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setDetailsRole(role)} title="Details">
<Eye size={16} />
</Button>
{hasPermission(currentUser, "roles.update") && (
<Button type="button" variant="ghost" size="icon-sm" onClick={() => openEditDialog(role)} title="Bearbeiten">
<Edit size={16} />
</Button>
)}
{hasPermission(currentUser, "roles.delete") && !role.is_system && (
<Button type="button" variant="destructive" size="icon-sm" onClick={() => setDeleteRole(role)} title="Löschen">
<Trash2 size={16} />
</Button>
)}
</div>
),
},
];
function handleSort(key: string) {
if (sortKey === key) {
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setSortKey(key);
setSortDirection("asc");
}
function openCreateDialog() {
setSelectedRole(null);
setFormError("");
setFormOpen(true);
}
function openEditDialog(role: Role) {
setSelectedRole(role);
setFormError("");
setFormOpen(true);
}
async function saveRole(payload: RolePayload) {
setPending(true);
setFormError("");
try {
if (selectedRole) {
const response = await api.put<Role>(`/roles/${selectedRole.id}`, payload);
setRoles((current) => current.map((role) => (
role.id === selectedRole.id ? response.data : role
)));
} else {
const response = await api.post<Role>("/roles", payload);
setRoles((current) => [response.data, ...current]);
}
setFormOpen(false);
} catch (err) {
setFormError(getErrorMessage(err));
} finally {
setPending(false);
}
}
async function confirmDelete() {
if (!deleteRole) {
return;
}
setPending(true);
setDeleteError("");
try {
await api.delete(`/roles/${deleteRole.id}`);
setRoles((current) => current.filter((role) => role.id !== deleteRole.id));
setDeleteRole(null);
} catch (err) {
setDeleteError(getErrorMessage(err));
} finally {
setPending(false);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-950">Rollen & Rechte</h1>
<p className="mt-1 text-sm text-slate-500">
{filteredRoles.length} von {roles.length} Rollen
</p>
</div>
{hasPermission(currentUser, "roles.create") && (
<Button type="button" onClick={openCreateDialog}>
<Shield size={16} />
Neue Rolle
</Button>
)}
</div>
<div className="rounded-lg border bg-white p-4">
<SearchInput
value={search}
onChange={(value) => {
setSearch(value);
setPage(1);
}}
placeholder="Rollen suchen"
/>
</div>
<DataTable
columns={columns}
rows={pageRoles}
rowKey={(role) => role.id}
sortKey={sortKey}
sortDirection={sortDirection}
loading={loading}
error={error}
emptyTitle="Keine Rollen gefunden"
emptyDescription="Passe Suche oder Filter an oder erstelle eine neue Rolle."
onSort={handleSort}
/>
<div className="flex items-center justify-between text-sm text-slate-600">
<span>
Seite {currentPage} von {pageCount}
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={currentPage === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Zurück
</Button>
<Button
type="button"
variant="outline"
disabled={currentPage === pageCount}
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
>
Weiter
</Button>
</div>
</div>
<RoleFormDialog
key={selectedRole?.id ?? "new"}
open={formOpen}
role={selectedRole}
permissions={permissions}
pending={pending}
serverError={formError}
onOpenChange={setFormOpen}
onSubmit={saveRole}
/>
<RoleDetailDialog
role={detailsRole}
onOpenChange={(open) => {
if (!open) {
setDetailsRole(null);
}
}}
/>
<ConfirmDialog
open={Boolean(deleteRole)}
title="Rolle löschen"
description="Möchten Sie diese Rolle wirklich löschen?"
pending={pending}
onOpenChange={(open) => {
if (!open) {
setDeleteRole(null);
setDeleteError("");
}
}}
onConfirm={confirmDelete}
>
{deleteRole && (
<dl className="grid gap-2 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Name</dt>
<dd className="font-medium">{deleteRole.display_name}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Systemrolle</dt>
<dd className="font-medium">{deleteRole.is_system ? "Ja" : "Nein"}</dd>
</div>
{deleteError && <p className="text-red-600">{deleteError}</p>}
</dl>
)}
</ConfirmDialog>
</div>
);
}
function RoleFormDialog({
open,
role,
permissions,
pending,
serverError,
onOpenChange,
onSubmit,
}: {
open: boolean;
role: Role | null;
permissions: Permission[];
pending: boolean;
serverError?: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: RolePayload) => Promise<void>;
}) {
const initialPermissionIds = role?.permissions.map((permission) => permission.id) ?? [];
const [name, setName] = useState(role?.name ?? "");
const [displayName, setDisplayName] = useState(role?.display_name ?? "");
const [description, setDescription] = useState(role?.description ?? "");
const [permissionIds, setPermissionIds] = useState<number[]>(initialPermissionIds);
if (!open) {
return null;
}
const groupedPermissions = permissions.reduce<Record<string, Permission[]>>((groups, permission) => {
groups[permission.module] = groups[permission.module] ?? [];
groups[permission.module].push(permission);
return groups;
}, {});
const valid = name.trim().length >= 3 && displayName.trim().length >= 2;
function togglePermission(permissionId: number) {
setPermissionIds((current) => (
current.includes(permissionId)
? current.filter((id) => id !== permissionId)
: [...current, permissionId]
));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!valid) {
return;
}
await onSubmit({
name,
display_name: displayName,
description,
permission_ids: permissionIds,
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<form onSubmit={handleSubmit} className="space-y-5">
<DialogHeader>
<DialogTitle>{role ? "Rolle bearbeiten" : "Rolle erstellen"}</DialogTitle>
<DialogDescription>
Rollenstammdaten und Berechtigungen verwalten.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Technischer Name</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={role?.is_system} />
</div>
<div className="space-y-1.5">
<Label>Anzeigename</Label>
<Input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label>Beschreibung</Label>
<Input value={description} onChange={(event) => setDescription(event.target.value)} />
</div>
</div>
<div className="max-h-80 overflow-auto rounded-lg border">
{Object.entries(groupedPermissions).map(([module, modulePermissions]) => (
<div key={module} className="border-b p-4 last:border-b-0">
<h3 className="mb-3 text-sm font-semibold capitalize text-slate-900">{module}</h3>
<div className="grid gap-2 sm:grid-cols-2">
{modulePermissions.map((permission) => (
<label key={permission.id} className="flex items-start gap-2 rounded border p-2 text-sm">
<input
type="checkbox"
checked={permissionIds.includes(permission.id)}
onChange={() => togglePermission(permission.id)}
/>
<span>
<span className="block font-medium">{permission.display_name}</span>
<span className="block text-xs text-slate-500">{permission.name}</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
<DialogFooter>
<Button type="button" variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={!valid || pending}>
{pending ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function RoleDetailDialog({
role,
onOpenChange,
}: {
role: Role | null;
onOpenChange: (open: boolean) => void;
}) {
return (
<Dialog open={Boolean(role)} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
{role && (
<>
<DialogHeader>
<DialogTitle>{role.display_name}</DialogTitle>
<DialogDescription>{role.description || role.name}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<p className="text-slate-500">Technischer Name</p>
<p className="font-medium">{role.name}</p>
</div>
<div>
<p className="text-slate-500">Typ</p>
<p className="font-medium">{role.is_system ? "Systemrolle" : "Individuell"}</p>
</div>
</div>
<div>
<p className="mb-2 text-sm font-medium">Berechtigungen</p>
<div className="flex flex-wrap gap-2">
{role.permissions.map((permission) => (
<span key={permission.id} className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-700">
{permission.name}
</span>
))}
</div>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -20,6 +20,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import UserFormDialog from "@/components/users/UserFormDialog";
import { api } from "@/lib/api";
import { hasPermission } from "@/lib/permissions";
import type { CurrentUser, Role } from "@/types/rbac";
import type { User, UserPayload } from "@/types/user";
function getErrorMessage(error: unknown) {
@ -41,6 +43,8 @@ function formatDateTime(value: string) {
export default function UserDetailPage() {
const params = useParams<{ id: string }>();
const [user, setUser] = useState<User | null>(null);
const [roles, setRoles] = useState<Role[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
@ -52,8 +56,19 @@ export default function UserDetailPage() {
const loadUser = useCallback(async () => {
try {
const response = await api.get<User>(`/users/${params.id}`);
setUser(response.data);
const [userResponse, meResponse] = await Promise.all([
api.get<User>(`/users/${params.id}`),
api.get<CurrentUser>("/me"),
]);
setUser(userResponse.data);
setCurrentUser(meResponse.data);
if (hasPermission(meResponse.data, "roles.read")) {
const rolesResponse = await api.get<Role[]>("/roles");
setRoles(rolesResponse.data);
} else {
setRoles([userResponse.data.primary_role]);
}
} catch (err) {
setError(getErrorMessage(err));
} finally {
@ -147,14 +162,18 @@ export default function UserDetailPage() {
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setPasswordOpen(true)}>
<KeyRound size={16} />
Passwort ändern
</Button>
<Button type="button" onClick={() => setFormOpen(true)}>
<Pencil size={16} />
Bearbeiten
</Button>
{hasPermission(currentUser, "users.password.update") && (
<Button type="button" variant="outline" onClick={() => setPasswordOpen(true)}>
<KeyRound size={16} />
Passwort ändern
</Button>
)}
{hasPermission(currentUser, "users.update") && (
<Button type="button" onClick={() => setFormOpen(true)}>
<Pencil size={16} />
Bearbeiten
</Button>
)}
</div>
</div>
@ -165,7 +184,7 @@ export default function UserDetailPage() {
<Detail label="Nachname" value={user.last_name || "-"} />
<Detail label="Benutzername" value={user.username} />
<Detail label="E-Mail" value={user.email} />
<Detail label="Rolle" value={user.role} />
<Detail label="Rolle" value={user.primary_role.display_name} />
<div>
<dt className="text-sm text-slate-500">Status</dt>
<dd className="mt-1"><StatusBadge active={user.is_active} /></dd>
@ -180,6 +199,7 @@ export default function UserDetailPage() {
user={user}
pending={pending}
serverError={formError}
roles={roles}
onOpenChange={setFormOpen}
onSubmit={saveUser}
/>

View file

@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
import UserFormDialog from "@/components/users/UserFormDialog";
import UserTable from "@/components/users/UserTable";
import { api } from "@/lib/api";
import { hasAllPermissions, hasPermission } from "@/lib/permissions";
import type { CurrentUser, Role } from "@/types/rbac";
import type { User, UserPayload, UserRole } from "@/types/user";
const pageSize = 10;
@ -31,6 +33,8 @@ function getSortableValue(user: User, key: string) {
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
@ -48,8 +52,22 @@ export default function UsersPage() {
const loadUsers = useCallback(async () => {
try {
const response = await api.get<User[]>("/users");
setUsers(response.data);
const [usersResponse, meResponse] = await Promise.all([
api.get<User[]>("/users"),
api.get<CurrentUser>("/me"),
]);
setUsers(usersResponse.data);
setCurrentUser(meResponse.data);
if (hasPermission(meResponse.data, "roles.read")) {
const rolesResponse = await api.get<Role[]>("/roles");
setRoles(rolesResponse.data);
} else {
const userRoles = usersResponse.data.map((user) => user.primary_role);
setRoles(userRoles.filter((role, index, all) => (
all.findIndex((item) => item.id === role.id) === index
)));
}
} catch (err) {
setError(getErrorMessage(err));
} finally {
@ -77,10 +95,11 @@ export default function UsersPage() {
user.last_name,
user.username,
user.email,
user.role,
user.primary_role.display_name,
user.primary_role.name,
].some((value) => value.toLowerCase().includes(term));
})
.filter((user) => roleFilter === "all" || user.role === roleFilter)
.filter((user) => roleFilter === "all" || user.primary_role.name === roleFilter)
.filter((user) => {
if (statusFilter === "all") {
return true;
@ -182,10 +201,12 @@ export default function UsersPage() {
</p>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={16} />
Neuer Benutzer
</Button>
{hasAllPermissions(currentUser, ["users.create", "roles.assign"]) && (
<Button type="button" onClick={openCreateDialog}>
<Plus size={16} />
Neuer Benutzer
</Button>
)}
</div>
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
@ -207,9 +228,11 @@ export default function UsersPage() {
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="all">Alle Rollen</option>
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="user">Benutzer</option>
{roles.map((role) => (
<option key={role.id} value={role.name}>
{role.display_name}
</option>
))}
</select>
<select
@ -232,6 +255,8 @@ export default function UsersPage() {
error={error}
sortKey={sortKey}
sortDirection={sortDirection}
canUpdate={hasPermission(currentUser, "users.update")}
canDelete={hasPermission(currentUser, "users.delete")}
onSort={handleSort}
onEdit={openEditDialog}
onDelete={setDeleteUser}
@ -266,6 +291,7 @@ export default function UsersPage() {
user={selectedUser}
pending={pending}
serverError={formError}
roles={roles}
onOpenChange={setFormOpen}
onSubmit={saveUser}
/>
@ -295,7 +321,7 @@ export default function UsersPage() {
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Rolle</dt>
<dd className="font-medium">{deleteUser.role}</dd>
<dd className="font-medium">{deleteUser.primary_role.display_name}</dd>
</div>
{deleteError && <p className="text-red-600">{deleteError}</p>}
</dl>

View file

@ -1,14 +1,22 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
FileText,
LayoutDashboard,
Package,
Settings,
Shield,
ShoppingCart,
Users,
Wrench,
Package,
FileText,
Settings,
ShoppingCart,
} from "lucide-react";
import { api } from "@/lib/api";
import { hasPermission } from "@/lib/permissions";
import type { CurrentUser, PermissionName } from "@/types/rbac";
const menu = [
{
icon: LayoutDashboard,
@ -19,6 +27,13 @@ const menu = [
icon: Users,
name: "Benutzer",
href: "/users",
permission: "users.read",
},
{
icon: Shield,
name: "Rollen & Rechte",
href: "/roles",
permission: "roles.read",
},
{
icon: Users,
@ -50,21 +65,45 @@ const menu = [
name: "Einstellungen",
href: "/settings",
},
];
] satisfies Array<{
icon: typeof LayoutDashboard;
name: string;
href: string;
permission?: PermissionName;
}>;
export default function Sidebar() {
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const loadCurrentUser = useCallback(async () => {
try {
const response = await api.get<CurrentUser>("/me");
setCurrentUser(response.data);
} catch {
setCurrentUser(null);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadCurrentUser();
});
}, [loadCurrentUser]);
const visibleMenu = menu.filter((item) => (
!item.permission || hasPermission(currentUser, item.permission)
));
return (
<aside className="w-64 h-screen bg-slate-900 text-white flex flex-col">
<div className="p-6 border-b border-slate-800">
<h1 className="text-3xl font-bold">
🏛 Olympus
Olympus
</h1>
</div>
<nav className="flex-1 p-4 space-y-2">
{menu.map((item) => (
{visibleMenu.map((item) => (
<Link
key={item.name}
href={item.href}
@ -74,13 +113,11 @@ export default function Sidebar() {
<span>{item.name}</span>
</Link>
))}
</nav>
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
Olympus ERP v0.1
Olympus CRM v0.1
</div>
</aside>
);
}

View file

@ -0,0 +1,30 @@
"use client";
import type { ReactNode } from "react";
import { hasAllPermissions, hasAnyPermission, hasPermission } from "@/lib/permissions";
import type { CurrentUser, PermissionName } from "@/types/rbac";
type Props = {
user: CurrentUser | null;
permission?: PermissionName;
any?: PermissionName[];
all?: PermissionName[];
children: ReactNode;
};
export default function Can({ user, permission, any, all, children }: Props) {
if (permission && !hasPermission(user, permission)) {
return null;
}
if (any && !hasAnyPermission(user, any)) {
return null;
}
if (all && !hasAllPermissions(user, all)) {
return null;
}
return <>{children}</>;
}

View file

@ -14,20 +14,16 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Role } from "@/types/rbac";
import type { User, UserPayload, UserRole } from "@/types/user";
const roles: Array<{ value: UserRole; label: string }> = [
{ value: "admin", label: "Admin" },
{ value: "manager", label: "Manager" },
{ value: "user", label: "Benutzer" },
];
const emptyForm: UserPayload = {
first_name: "",
last_name: "",
username: "",
email: "",
role: "user",
role: "support",
role_id: undefined,
is_active: true,
password: "",
};
@ -37,6 +33,7 @@ type Props = {
user?: User | null;
pending?: boolean;
serverError?: string;
roles: Role[];
onOpenChange: (open: boolean) => void;
onSubmit: (payload: UserPayload) => Promise<void>;
};
@ -46,6 +43,7 @@ export default function UserFormDialog({
user,
pending = false,
serverError,
roles,
onOpenChange,
onSubmit,
}: Props) {
@ -57,10 +55,15 @@ export default function UserFormDialog({
username: user.username,
email: user.email,
role: user.role,
role_id: user.role_id,
is_active: user.is_active,
password: "",
}
: emptyForm;
: {
...emptyForm,
role: roles[0]?.name ?? "support",
role_id: roles[0]?.id,
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -72,6 +75,7 @@ export default function UserFormDialog({
isEditing={isEditing}
pending={pending}
serverError={serverError}
roles={roles}
onCancel={() => onOpenChange(false)}
onSubmit={onSubmit}
/>
@ -86,6 +90,7 @@ function UserForm({
isEditing,
pending,
serverError,
roles,
onCancel,
onSubmit,
}: {
@ -93,6 +98,7 @@ function UserForm({
isEditing: boolean;
pending: boolean;
serverError?: string;
roles: Role[];
onCancel: () => void;
onSubmit: (payload: UserPayload) => Promise<void>;
}) {
@ -179,13 +185,17 @@ function UserForm({
<Field label="Rolle">
<select
value={form.role}
onChange={(event) => updateField("role", event.target.value as UserRole)}
value={form.role_id ?? ""}
onChange={(event) => {
const role = roles.find((item) => item.id === Number(event.target.value));
updateField("role_id", role?.id);
updateField("role", (role?.name ?? "") as UserRole);
}}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
{roles.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>

View file

@ -14,6 +14,8 @@ type Props = {
error?: string;
sortKey: string;
sortDirection: "asc" | "desc";
canUpdate: boolean;
canDelete: boolean;
onSort: (key: string) => void;
onEdit: (user: User) => void;
onDelete: (user: User) => void;
@ -33,6 +35,8 @@ export default function UserTable({
error,
sortKey,
sortDirection,
canUpdate,
canDelete,
onSort,
onEdit,
onDelete,
@ -66,7 +70,7 @@ export default function UserTable({
key: "role",
label: "Rolle",
sortable: true,
render: (user) => user.role,
render: (user) => user.primary_role.display_name,
},
{
key: "is_active",
@ -93,24 +97,28 @@ export default function UserTable({
>
<Eye size={16} />
</Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
title="Bearbeiten"
onClick={() => onEdit(user)}
>
<Edit size={16} />
</Button>
<Button
type="button"
variant="destructive"
size="icon-sm"
title="Löschen"
onClick={() => onDelete(user)}
>
<Trash2 size={16} />
</Button>
{canUpdate && (
<Button
type="button"
variant="ghost"
size="icon-sm"
title="Bearbeiten"
onClick={() => onEdit(user)}
>
<Edit size={16} />
</Button>
)}
{canDelete && (
<Button
type="button"
variant="destructive"
size="icon-sm"
title="Löschen"
onClick={() => onDelete(user)}
>
<Trash2 size={16} />
</Button>
)}
</div>
),
},

View file

@ -0,0 +1,19 @@
import type { CurrentUser, PermissionName } from "@/types/rbac";
export function hasPermission(user: CurrentUser | null | undefined, permission: PermissionName) {
return Boolean(user?.permissions.includes(permission));
}
export function hasAnyPermission(
user: CurrentUser | null | undefined,
permissions: PermissionName[],
) {
return permissions.some((permission) => hasPermission(user, permission));
}
export function hasAllPermissions(
user: CurrentUser | null | undefined,
permissions: PermissionName[],
) {
return permissions.every((permission) => hasPermission(user, permission));
}

View file

@ -0,0 +1,55 @@
import { NextRequest } from "next/server";
import {
clearAuthCookie,
getAccessToken,
getHermesUrl,
hermesJsonResponse,
unauthorizedResponse,
upstreamConfigurationErrorResponse,
upstreamUnavailableResponse,
} from "@/lib/server/hermes";
export async function proxyHermesRequest(
request: NextRequest,
path: string,
) {
const token = await getAccessToken();
if (!token) {
return unauthorizedResponse();
}
const hermesUrl = getHermesUrl();
if (!hermesUrl) {
return upstreamConfigurationErrorResponse();
}
let hermesResponse: Response;
try {
hermesResponse = await fetch(`${hermesUrl}${path}`, {
method: request.method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: request.method === "GET" || request.method === "DELETE"
? undefined
: await request.text(),
cache: "no-store",
});
} catch {
return upstreamUnavailableResponse();
}
if (hermesResponse.status === 401) {
const response = await hermesJsonResponse(hermesResponse);
clearAuthCookie(response);
return response;
}
return hermesJsonResponse(hermesResponse);
}

View file

@ -20,5 +20,6 @@ export const config = {
"/login",
"/dashboard/:path*",
"/users/:path*",
"/roles/:path*",
],
};

View file

@ -0,0 +1,37 @@
export type PermissionName = string;
export interface Permission {
id: number;
name: PermissionName;
display_name: string;
description: string;
module: string;
created_at: string;
updated_at: string;
}
export interface Role {
id: number;
name: string;
display_name: string;
description: string;
is_system: boolean;
permissions: Permission[];
created_at: string;
updated_at: string;
}
export interface RolePayload {
name: string;
display_name: string;
description: string;
permission_ids: number[];
}
export interface CurrentUser {
id: number;
username: string;
email: string;
role: string;
permissions: PermissionName[];
}

View file

@ -1,4 +1,6 @@
export type UserRole = "admin" | "manager" | "user";
import type { Role } from "@/types/rbac";
export type UserRole = string;
export interface User {
id: number;
@ -7,6 +9,8 @@ export interface User {
username: string;
email: string;
role: UserRole;
role_id: number;
primary_role: Role;
is_active: boolean;
created_at: string;
updated_at: string;
@ -18,6 +22,7 @@ export type UserPayload = {
username: string;
email: string;
role: UserRole;
role_id?: number;
is_active: boolean;
password?: string;
};