diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..dfc23d3 --- /dev/null +++ b/ARCHITECTURE.md @@ -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 `. + +### 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 + +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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..20f012e --- /dev/null +++ b/CONTRIBUTING.md @@ -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/` +- Bugfixes: `fix/` +- Dokumentation: `docs/` + +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 +. +``` + +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. diff --git a/backend/hermes/alembic/versions/7b2c9f4a0d31_add_rbac.py b/backend/hermes/alembic/versions/7b2c9f4a0d31_add_rbac.py new file mode 100644 index 0000000..9e3be59 --- /dev/null +++ b/backend/hermes/alembic/versions/7b2c9f4a0d31_add_rbac.py @@ -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") diff --git a/backend/hermes/app/api/auth.py b/backend/hermes/app/api/auth.py index c3609ee..f325b87 100644 --- a/backend/hermes/app/api/auth.py +++ b/backend/hermes/app/api/auth.py @@ -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)), + } diff --git a/backend/hermes/app/api/permissions.py b/backend/hermes/app/api/permissions.py new file mode 100644 index 0000000..1f60e9b --- /dev/null +++ b/backend/hermes/app/api/permissions.py @@ -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) diff --git a/backend/hermes/app/api/roles.py b/backend/hermes/app/api/roles.py new file mode 100644 index 0000000..72ae4bc --- /dev/null +++ b/backend/hermes/app/api/roles.py @@ -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) diff --git a/backend/hermes/app/api/users.py b/backend/hermes/app/api/users.py index 5b834bc..e394cb7 100644 --- a/backend/hermes/app/api/users.py +++ b/backend/hermes/app/api/users.py @@ -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( diff --git a/backend/hermes/app/core/rbac.py b/backend/hermes/app/core/rbac.py new file mode 100644 index 0000000..2a7d000 --- /dev/null +++ b/backend/hermes/app/core/rbac.py @@ -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 diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index e34f90a..59350fa 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -20,6 +20,7 @@ class Base(DeclarativeBase): pass # <<< HIER IMPORTIEREN >>> +import app.models.rbac import app.models.user @@ -28,4 +29,4 @@ def get_db(): try: yield db finally: - db.close() \ No newline at end of file + db.close() diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index bc7ee85..3556490 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -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( diff --git a/backend/hermes/app/models/rbac.py b/backend/hermes/app/models/rbac.py new file mode 100644 index 0000000..7693fc9 --- /dev/null +++ b/backend/hermes/app/models/rbac.py @@ -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", + ) diff --git a/backend/hermes/app/models/user.py b/backend/hermes/app/models/user.py index eb0868f..28d491a 100644 --- a/backend/hermes/app/models/user.py +++ b/backend/hermes/app/models/user.py @@ -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, diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py new file mode 100644 index 0000000..0c7b9fa --- /dev/null +++ b/backend/hermes/app/rbac/defaults.py @@ -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", + }, +} diff --git a/backend/hermes/app/rbac/seed.py b/backend/hermes/app/rbac/seed.py new file mode 100644 index 0000000..3ec97ca --- /dev/null +++ b/backend/hermes/app/rbac/seed.py @@ -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") diff --git a/backend/hermes/app/repositories/rbac_repository.py b/backend/hermes/app/repositories/rbac_repository.py new file mode 100644 index 0000000..afe7ca1 --- /dev/null +++ b/backend/hermes/app/repositories/rbac_repository.py @@ -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() diff --git a/backend/hermes/app/repositories/user_repository.py b/backend/hermes/app/repositories/user_repository.py index 4877b78..bca7ffa 100644 --- a/backend/hermes/app/repositories/user_repository.py +++ b/backend/hermes/app/repositories/user_repository.py @@ -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( diff --git a/backend/hermes/app/schemas/rbac.py b/backend/hermes/app/schemas/rbac.py new file mode 100644 index 0000000..fae3cf7 --- /dev/null +++ b/backend/hermes/app/schemas/rbac.py @@ -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] diff --git a/backend/hermes/app/schemas/user.py b/backend/hermes/app/schemas/user.py index d622eaa..e40c042 100644 --- a/backend/hermes/app/schemas/user.py +++ b/backend/hermes/app/schemas/user.py @@ -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 diff --git a/frontend/athena/app/api/me/route.ts b/frontend/athena/app/api/me/route.ts new file mode 100644 index 0000000..3bbdc87 --- /dev/null +++ b/frontend/athena/app/api/me/route.ts @@ -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"); +} diff --git a/frontend/athena/app/api/permissions/route.ts b/frontend/athena/app/api/permissions/route.ts new file mode 100644 index 0000000..0e42f97 --- /dev/null +++ b/frontend/athena/app/api/permissions/route.ts @@ -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"); +} diff --git a/frontend/athena/app/api/roles/[id]/permissions/route.ts b/frontend/athena/app/api/roles/[id]/permissions/route.ts new file mode 100644 index 0000000..fd5a001 --- /dev/null +++ b/frontend/athena/app/api/roles/[id]/permissions/route.ts @@ -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`); +} diff --git a/frontend/athena/app/api/roles/[id]/route.ts b/frontend/athena/app/api/roles/[id]/route.ts new file mode 100644 index 0000000..cedc377 --- /dev/null +++ b/frontend/athena/app/api/roles/[id]/route.ts @@ -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); +} diff --git a/frontend/athena/app/api/roles/route.ts b/frontend/athena/app/api/roles/route.ts new file mode 100644 index 0000000..0853b44 --- /dev/null +++ b/frontend/athena/app/api/roles/route.ts @@ -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"); +} diff --git a/frontend/athena/app/api/users/[id]/route.ts b/frontend/athena/app/api/users/[id]/route.ts index 4a26b7b..a68382e 100644 --- a/frontend/athena/app/api/users/[id]/route.ts +++ b/frontend/athena/app/api/users/[id]/route.ts @@ -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) { diff --git a/frontend/athena/app/api/users/route.ts b/frontend/athena/app/api/users/route.ts index ab8fcac..9d78154 100644 --- a/frontend/athena/app/api/users/route.ts +++ b/frontend/athena/app/api/users/route.ts @@ -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) { diff --git a/frontend/athena/app/roles/page.tsx b/frontend/athena/app/roles/page.tsx new file mode 100644 index 0000000..96787de --- /dev/null +++ b/frontend/athena/app/roles/page.tsx @@ -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([]); + const [permissions, setPermissions] = useState([]); + const [currentUser, setCurrentUser] = useState(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(null); + const [detailsRole, setDetailsRole] = useState(null); + const [deleteRole, setDeleteRole] = useState(null); + const [formOpen, setFormOpen] = useState(false); + + const loadRoles = useCallback(async () => { + try { + const [rolesResponse, permissionsResponse, meResponse] = await Promise.all([ + api.get("/roles"), + api.get("/permissions"), + api.get("/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[] = [ + { + key: "display_name", + label: "Rolle", + sortable: true, + render: (role) => ( +
+

{role.display_name}

+

{role.name}

+
+ ), + }, + { + key: "description", + label: "Beschreibung", + sortable: true, + render: (role) => role.description || "-", + }, + { + key: "is_system", + label: "Typ", + sortable: true, + render: (role) => ( + + {role.is_system ? "Systemrolle" : "Individuell"} + + ), + }, + { + 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) => ( +
+ + {hasPermission(currentUser, "roles.update") && ( + + )} + {hasPermission(currentUser, "roles.delete") && !role.is_system && ( + + )} +
+ ), + }, + ]; + + 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(`/roles/${selectedRole.id}`, payload); + setRoles((current) => current.map((role) => ( + role.id === selectedRole.id ? response.data : role + ))); + } else { + const response = await api.post("/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 ( +
+
+
+

Rollen & Rechte

+

+ {filteredRoles.length} von {roles.length} Rollen +

+
+ + {hasPermission(currentUser, "roles.create") && ( + + )} +
+ +
+ { + setSearch(value); + setPage(1); + }} + placeholder="Rollen suchen" + /> +
+ + 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} + /> + +
+ + Seite {currentPage} von {pageCount} + +
+ + +
+
+ + + + { + if (!open) { + setDetailsRole(null); + } + }} + /> + + { + if (!open) { + setDeleteRole(null); + setDeleteError(""); + } + }} + onConfirm={confirmDelete} + > + {deleteRole && ( +
+
+
Name
+
{deleteRole.display_name}
+
+
+
Systemrolle
+
{deleteRole.is_system ? "Ja" : "Nein"}
+
+ {deleteError &&

{deleteError}

} +
+ )} +
+
+ ); +} + +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; +}) { + 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(initialPermissionIds); + + if (!open) { + return null; + } + + const groupedPermissions = permissions.reduce>((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) { + event.preventDefault(); + + if (!valid) { + return; + } + + await onSubmit({ + name, + display_name: displayName, + description, + permission_ids: permissionIds, + }); + } + + return ( + + +
+ + {role ? "Rolle bearbeiten" : "Rolle erstellen"} + + Rollenstammdaten und Berechtigungen verwalten. + + + +
+
+ + setName(event.target.value)} disabled={role?.is_system} /> +
+
+ + setDisplayName(event.target.value)} /> +
+
+ + setDescription(event.target.value)} /> +
+
+ +
+ {Object.entries(groupedPermissions).map(([module, modulePermissions]) => ( +
+

{module}

+
+ {modulePermissions.map((permission) => ( + + ))} +
+
+ ))} +
+ + {serverError &&

{serverError}

} + + + + + +
+
+
+ ); +} + +function RoleDetailDialog({ + role, + onOpenChange, +}: { + role: Role | null; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + {role && ( + <> + + {role.display_name} + {role.description || role.name} + +
+
+
+

Technischer Name

+

{role.name}

+
+
+

Typ

+

{role.is_system ? "Systemrolle" : "Individuell"}

+
+
+
+

Berechtigungen

+
+ {role.permissions.map((permission) => ( + + {permission.name} + + ))} +
+
+
+ + )} +
+
+ ); +} diff --git a/frontend/athena/app/users/[id]/page.tsx b/frontend/athena/app/users/[id]/page.tsx index f891db6..a5c1c5e 100644 --- a/frontend/athena/app/users/[id]/page.tsx +++ b/frontend/athena/app/users/[id]/page.tsx @@ -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(null); + const [roles, setRoles] = useState([]); + const [currentUser, setCurrentUser] = useState(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(`/users/${params.id}`); - setUser(response.data); + const [userResponse, meResponse] = await Promise.all([ + api.get(`/users/${params.id}`), + api.get("/me"), + ]); + setUser(userResponse.data); + setCurrentUser(meResponse.data); + + if (hasPermission(meResponse.data, "roles.read")) { + const rolesResponse = await api.get("/roles"); + setRoles(rolesResponse.data); + } else { + setRoles([userResponse.data.primary_role]); + } } catch (err) { setError(getErrorMessage(err)); } finally { @@ -147,14 +162,18 @@ export default function UserDetailPage() {
- - + {hasPermission(currentUser, "users.password.update") && ( + + )} + {hasPermission(currentUser, "users.update") && ( + + )}
@@ -165,7 +184,7 @@ export default function UserDetailPage() { - +
Status
@@ -180,6 +199,7 @@ export default function UserDetailPage() { user={user} pending={pending} serverError={formError} + roles={roles} onOpenChange={setFormOpen} onSubmit={saveUser} /> diff --git a/frontend/athena/app/users/page.tsx b/frontend/athena/app/users/page.tsx index b2b75f3..f66a662 100644 --- a/frontend/athena/app/users/page.tsx +++ b/frontend/athena/app/users/page.tsx @@ -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([]); + const [roles, setRoles] = useState([]); + const [currentUser, setCurrentUser] = useState(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("/users"); - setUsers(response.data); + const [usersResponse, meResponse] = await Promise.all([ + api.get("/users"), + api.get("/me"), + ]); + setUsers(usersResponse.data); + setCurrentUser(meResponse.data); + + if (hasPermission(meResponse.data, "roles.read")) { + const rolesResponse = await api.get("/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() {

- + {hasAllPermissions(currentUser, ["users.create", "roles.assign"]) && ( + + )}
@@ -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" > - - - + {roles.map((role) => ( + + ))} 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) => ( - ))} diff --git a/frontend/athena/components/users/UserTable.tsx b/frontend/athena/components/users/UserTable.tsx index 21e636c..22f6757 100644 --- a/frontend/athena/components/users/UserTable.tsx +++ b/frontend/athena/components/users/UserTable.tsx @@ -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({ > - - + {canUpdate && ( + + )} + {canDelete && ( + + )}
), }, diff --git a/frontend/athena/lib/permissions.ts b/frontend/athena/lib/permissions.ts new file mode 100644 index 0000000..38404c9 --- /dev/null +++ b/frontend/athena/lib/permissions.ts @@ -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)); +} diff --git a/frontend/athena/lib/server/hermes-proxy.ts b/frontend/athena/lib/server/hermes-proxy.ts new file mode 100644 index 0000000..7bbc303 --- /dev/null +++ b/frontend/athena/lib/server/hermes-proxy.ts @@ -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); +} diff --git a/frontend/athena/proxy.ts b/frontend/athena/proxy.ts index 18f7e1a..4e9dc49 100644 --- a/frontend/athena/proxy.ts +++ b/frontend/athena/proxy.ts @@ -20,5 +20,6 @@ export const config = { "/login", "/dashboard/:path*", "/users/:path*", + "/roles/:path*", ], }; diff --git a/frontend/athena/types/rbac.ts b/frontend/athena/types/rbac.ts new file mode 100644 index 0000000..1336f29 --- /dev/null +++ b/frontend/athena/types/rbac.ts @@ -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[]; +} diff --git a/frontend/athena/types/user.ts b/frontend/athena/types/user.ts index 3c5f568..e92300b 100644 --- a/frontend/athena/types/user.ts +++ b/frontend/athena/types/user.ts @@ -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; };