generated from bastian/boilerplate
Initial commit
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
apps/backend/public
|
||||||
|
coverage
|
||||||
|
**/coverage
|
||||||
|
.angular
|
||||||
|
**/.angular
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
npm-debug.log*
|
||||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
37
.env.example
Normal file
37
.env.example
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
APP_BASE_URL=http://localhost:3000
|
||||||
|
FRONTEND_BASE_URL=http://localhost:4200
|
||||||
|
TRUST_PROXY=false
|
||||||
|
|
||||||
|
DATABASE_HOST=mysql.example.internal
|
||||||
|
DATABASE_PORT=3306
|
||||||
|
DATABASE_NAME=business_app
|
||||||
|
DATABASE_USER=business_app
|
||||||
|
DATABASE_PASSWORD=change-me
|
||||||
|
DATABASE_SSL=false
|
||||||
|
|
||||||
|
OIDC_ISSUER=https://idp.example.com/realms/internal
|
||||||
|
OIDC_CLIENT_ID=business-app
|
||||||
|
OIDC_CLIENT_SECRET=change-me
|
||||||
|
OIDC_SCOPES=openid profile email
|
||||||
|
OIDC_LOGOUT_URL=
|
||||||
|
OIDC_ALLOWED_ALGORITHMS=RS256
|
||||||
|
OIDC_HTTP_TIMEOUT_MS=5000
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME=app_session
|
||||||
|
SESSION_IDLE_TIMEOUT_SECONDS=28800
|
||||||
|
SESSION_ABSOLUTE_TIMEOUT_SECONDS=604800
|
||||||
|
SESSION_SECRET=replace-with-at-least-32-random-bytes
|
||||||
|
SESSION_ENCRYPTION_KEY=replace-with-32-byte-base64url-key
|
||||||
|
|
||||||
|
CORS_ORIGINS=http://localhost:4200,http://localhost:3000
|
||||||
|
CSRF_HEADER_NAME=X-CSRF-Token
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
|
SWAGGER_ENABLED=true
|
||||||
|
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=300
|
||||||
|
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS=60
|
||||||
|
RATE_LIMIT_SENSITIVE_MAX_REQUESTS=10
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
apps/backend/public/
|
||||||
|
coverage/
|
||||||
|
.angular/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
npm-debug.log*
|
||||||
|
*.local
|
||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
coverage
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
apps/frontend/.angular
|
||||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
27
AGENTS.md
Normal file
27
AGENTS.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Arbeitsregeln fuer Codex
|
||||||
|
|
||||||
|
- Das Repository ist ein npm-Workspace-Monorepo mit `apps/frontend`, `apps/backend` und `packages/api-client`; keine Nx-Einfuehrung.
|
||||||
|
- TypeScript bleibt strikt. Kein `any`, keine unnoetigen Non-Null-Assertions.
|
||||||
|
- Backend-Controller verwenden niemals TypeORM-Repositories direkt, sondern Services.
|
||||||
|
- Services kapseln Fachlogik; Datenbankzugriffe laufen ueber Repository-Klassen oder klar benannte Persistence-Services.
|
||||||
|
- Keine UI-Library einsetzen. Angular bleibt mobile-first, mit eigenem HTML und SCSS.
|
||||||
|
- Neue UI muss die Design Tokens aus `apps/frontend/src/styles/_tokens.scss` verwenden.
|
||||||
|
- Keine direkten Hex-Farben in Feature-Komponenten; Farben werden semantisch ueber CSS Custom Properties genutzt.
|
||||||
|
- Bestehende UI-Komponenten unter `apps/frontend/src/app/shared/ui` wiederverwenden, bevor neue Abstraktionen entstehen.
|
||||||
|
- Interaktive UI muss per Tastatur bedienbar sein; Fokuszustaende duerfen nicht entfernt werden.
|
||||||
|
- Informationen duerfen nicht ausschliesslich ueber Farbe vermittelt werden.
|
||||||
|
- Kein `::ng-deep`, keine unkontrollierten `!important`-Regeln und keine unnoetigen Utility-Klassen.
|
||||||
|
- OIDC-Tokens bleiben ausschliesslich im Backend. Der Browser erhaelt nur Session-Cookie und CSRF-Token.
|
||||||
|
- Sessions liegen serverseitig in MySQL. Session-Cookies enthalten keine Tokens oder sensiblen Daten.
|
||||||
|
- Permissions sind im Code definiert. Benutzer haben keine direkten Permissions, sondern Rollen.
|
||||||
|
- Admin-Aktionen duerfen den letzten aktiven Administrator nicht entfernen; entsprechende Benutzer- und Rollen-Aenderungen muessen transaktional abgesichert bleiben.
|
||||||
|
- Admin-Controller verwenden `@RequirePermissions(...)` und delegieren an Services; Benutzer, Rollen und Sessions werden nicht direkt aus Controllern ueber Repositories veraendert.
|
||||||
|
- Benachrichtigungen sind benutzerbezogene Daten. Zugriffe muessen serverseitig ueber den aktuellen Session-Benutzer eingeschraenkt werden; keine normalen Endpunkte mit frei uebergebener `userId`.
|
||||||
|
- Benachrichtigungen werden ueber `NotificationsService` erzeugt, nicht direkt ueber TypeORM-Repositories in Controllern oder fremden Modulen.
|
||||||
|
- Das Backend ist fuer Authentifizierung, Autorisierung und CSRF verbindlich; Angular nutzt Permissions nur zur Darstellung.
|
||||||
|
- Migrationen werden niemals automatisch beim normalen App-Start ausgefuehrt. Der Start prueft nur auf fehlende Migrationen.
|
||||||
|
- Secrets, Session-IDs und Tokens duerfen nicht geloggt oder ins Repository aufgenommen werden.
|
||||||
|
- Tests muessen Verhalten pruefen; keine trivialen Tests, die nur Existenz testen.
|
||||||
|
- Keine Auth- oder CSRF-Umgehung fuer Entwicklung oder Tests in Production-Code einbauen.
|
||||||
|
- Keine WebSockets, Queues, Redis, E-Mail-, SMS- oder Push-Versand fuer In-App-Benachrichtigungen einfuehren, solange dies nicht explizit architektonisch entschieden wurde.
|
||||||
|
- Vor Abschluss muessen `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm test`, `npm run build` und `docker build .` bestehen.
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:24.18.0-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/frontend/package.json apps/frontend/package.json
|
||||||
|
COPY apps/backend/package.json apps/backend/package.json
|
||||||
|
COPY packages/api-client/package.json packages/api-client/package.json
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
RUN mkdir -p apps/backend/dist/public \
|
||||||
|
&& cp -R apps/frontend/dist/frontend/browser/* apps/backend/dist/public/ \
|
||||||
|
&& npm prune --omit=dev --workspaces --include-workspace-root
|
||||||
|
|
||||||
|
FROM node:24.18.0-alpine AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
|
||||||
|
COPY --from=build --chown=app:app /app/package.json /app/package-lock.json ./
|
||||||
|
COPY --from=build --chown=app:app /app/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=app:app /app/apps/backend/package.json ./apps/backend/package.json
|
||||||
|
COPY --from=build --chown=app:app /app/apps/backend/dist ./apps/backend/dist
|
||||||
|
|
||||||
|
USER app
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
CMD ["node", "apps/backend/dist/main.js"]
|
||||||
122
README.md
Normal file
122
README.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Angular/NestJS Business Boilerplate
|
||||||
|
|
||||||
|
Production-ready npm-Workspace-Monorepo fuer interne Business-Anwendungen mit
|
||||||
|
Angular 22, NestJS 11, Node.js 24.18 LTS, TypeORM, MySQL 8, OIDC, serverseitigen
|
||||||
|
Sessions und Vitest.
|
||||||
|
|
||||||
|
Dieses Repository ist als Startpunkt fuer eigene Projekte gedacht. Es bringt die
|
||||||
|
technischen Grundentscheidungen mit, die in internen Business-Anwendungen haeufig
|
||||||
|
spaet und teuer nachgezogen werden: Backend-for-Frontend Authentifizierung,
|
||||||
|
rollenbasierte Autorisierung, CSRF-Schutz, serverseitige Sessions, Migrationen,
|
||||||
|
Docker-Auslieferung, Healthchecks und eine einfache Angular-Verwaltungsoberflaeche.
|
||||||
|
|
||||||
|
## Was enthalten ist
|
||||||
|
|
||||||
|
- npm Workspaces ohne Nx: `apps/frontend`, `apps/backend`, `packages/api-client`
|
||||||
|
- Angular Standalone Components, mobile-first SCSS und keine UI-Library
|
||||||
|
- NestJS API mit modularen Features, Services, Repositories, Guards und DTOs
|
||||||
|
- OIDC Authorization Code Flow mit PKCE; Tokens bleiben ausschliesslich im Backend
|
||||||
|
- HttpOnly Session-Cookie plus CSRF-Cookie/Header fuer schreibende Requests
|
||||||
|
- konfigurierbares In-Memory Rate Limiting pro IP mit strengeren sensiblen Endpunkten
|
||||||
|
- MySQL-8-Persistenz mit TypeORM, Migrationen und Startpruefung auf fehlende Migrationen
|
||||||
|
- Code-definierte Permissions, Rollenverwaltung, Benutzerverwaltung, Audit-Log und Sessions
|
||||||
|
- In-App-Benachrichtigungen mit Polling, Badge, eigener Seite und Admin-Erzeugung
|
||||||
|
- Generierter API-Client fuer das Angular-Frontend
|
||||||
|
- Dockerfile und Compose-Beispiel fuer eine einzelne auslieferbare App
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
cp .env.example .env
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular laeuft lokal auf `http://localhost:4200` und proxyt `/api` an NestJS auf
|
||||||
|
`http://localhost:3000`. Eine MySQL-8-Datenbank und ein OIDC Provider muessen
|
||||||
|
konfiguriert sein; Compose startet bewusst keine lokale Datenbank.
|
||||||
|
|
||||||
|
## Wichtige Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Frontend und Backend parallel starten
|
||||||
|
npm run lint # ESLint fuer alle Workspaces
|
||||||
|
npm run format:check # Prettier-Pruefung
|
||||||
|
npm run typecheck # TypeScript-Pruefung aller Workspaces
|
||||||
|
npm test # Backend- und Frontend-Tests
|
||||||
|
npm run build # API-Client, Backend, Frontend und Bundle bauen
|
||||||
|
npm run migration:status # offene TypeORM-Migrationen anzeigen
|
||||||
|
npm run migration:run # Migrationen ausfuehren
|
||||||
|
npm run docker:build # Docker-Image bauen
|
||||||
|
```
|
||||||
|
|
||||||
|
Vor einem Merge oder Release muessen `npm run lint`, `npm run format:check`,
|
||||||
|
`npm run typecheck`, `npm test`, `npm run build` und `docker build .` erfolgreich
|
||||||
|
laufen.
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
- [Getting Started](docs/getting-started.md): lokaler Start, Konfiguration und erster Login
|
||||||
|
- [Als Projektvorlage verwenden](docs/using-as-template.md): Umbenennen, Entfernen von Demo-Code und erste fachliche Erweiterung
|
||||||
|
- [Architektur](docs/architecture.md): Monorepo, Backend, Frontend, API-Client und Modulgrenzen
|
||||||
|
- [Entwicklung](docs/development.md): Workflows fuer Features, Migrationen, Tests und API-Client
|
||||||
|
- [Security-Modell](docs/security.md): OIDC, Sessions, CSRF, Rollen, Permissions und Logging
|
||||||
|
- [Designsystem](docs/design-system.md): Tokens, UI-Komponenten, responsive Regeln und Accessibility
|
||||||
|
- [Adminbereich](docs/admin.md): Benutzer, Rollen, Sessions, Audit und letzter-Admin-Schutz
|
||||||
|
- [Benachrichtigungen](docs/notifications.md): Datenmodell, API, Permissions, Polling und Erweiterung
|
||||||
|
- [Deployment und Betrieb](docs/deployment.md): Build, Docker, Migrationen, Runtime-Konfiguration und Healthchecks
|
||||||
|
- [Konfiguration](docs/configuration.md): Umgebungsvariablen und Produktionshinweise
|
||||||
|
- [Nginx-Beispiel](docs/nginx-example.conf): Reverse Proxy mit HTTPS-Terminierung
|
||||||
|
|
||||||
|
## Repository-Struktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/
|
||||||
|
backend/ NestJS API, Auth, Sessions, Rollen, Fachmodule, Migrationen
|
||||||
|
frontend/ Angular SPA, Shell, Feature Pages, Guards, Interceptor
|
||||||
|
packages/
|
||||||
|
api-client/ generierter Angular API-Client
|
||||||
|
scripts/ Build- und Generator-Hilfsskripte
|
||||||
|
docs/ Betriebs-, Architektur- und Starter-Dokumentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auslieferungsmodell
|
||||||
|
|
||||||
|
Der Produktionsbuild erzeugt eine einzelne NestJS-Anwendung. Das Angular-Frontend
|
||||||
|
wird nach `apps/backend/dist/public` kopiert und vom Backend unter `/`
|
||||||
|
ausgeliefert. Die API liegt unter `/api`, Swagger optional unter `/api/docs` und
|
||||||
|
Healthchecks unter `/health/live` sowie `/health/ready`.
|
||||||
|
|
||||||
|
Ein typisches Release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
docker build -t registry.example.com/business-app:<tag> .
|
||||||
|
docker push registry.example.com/business-app:<tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf der Zielumgebung werden Migrationen einmalig mit demselben Image ausgefuehrt,
|
||||||
|
danach wird der App-Container gestartet. Details stehen in
|
||||||
|
[Deployment und Betrieb](docs/deployment.md).
|
||||||
|
|
||||||
|
## Sicherheitsgrundsaetze
|
||||||
|
|
||||||
|
- OIDC-Tokens werden nur serverseitig gespeichert und verschluesselt.
|
||||||
|
- Der Browser erhaelt keine Access-, Refresh- oder ID-Tokens.
|
||||||
|
- Session-Cookies enthalten nur eine signierte Session-ID.
|
||||||
|
- Schreibende Requests brauchen ein gueltiges CSRF-Token.
|
||||||
|
- Permissions sind im Code definiert; Benutzer erhalten Rechte nur ueber Rollen.
|
||||||
|
- Migrationen laufen nie automatisch beim normalen App-Start.
|
||||||
|
- Secrets, Tokens, Cookies und Session-IDs duerfen nicht geloggt werden.
|
||||||
|
|
||||||
|
## Lizenzierung fuer eigene Projekte
|
||||||
|
|
||||||
|
Das Root-`package.json` ist aktuell `private` und `UNLICENSED`. Wenn dieses
|
||||||
|
Boilerplate als Open-Source-Startpunkt veroeffentlicht werden soll, muessen vor
|
||||||
|
der Veroeffentlichung eine passende Lizenzdatei, Paketnamen, Repository-Links und
|
||||||
|
Projektmetadaten bewusst gesetzt werden.
|
||||||
4
apps/backend/.prettierrc
Normal file
4
apps/backend/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
40
apps/backend/README.md
Normal file
40
apps/backend/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Backend Workspace
|
||||||
|
|
||||||
|
NestJS Backend fuer das Boilerplate. Der Workspace wird normalerweise ueber die
|
||||||
|
Root-Scripts gesteuert.
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --workspace apps/backend run start:dev
|
||||||
|
npm --workspace apps/backend run build
|
||||||
|
npm --workspace apps/backend run typecheck
|
||||||
|
npm --workspace apps/backend run test
|
||||||
|
npm --workspace apps/backend run migration:status
|
||||||
|
npm --workspace apps/backend run migration:run
|
||||||
|
npm --workspace apps/backend run migration:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Root-Aliase fuer die wichtigsten Befehle stehen in `../../package.json`.
|
||||||
|
|
||||||
|
## Struktur
|
||||||
|
|
||||||
|
- `src/main.ts`: Bootstrap, Security Header, CORS, Swagger, Static Assets
|
||||||
|
- `src/app.module.ts`: globale Guards, Filter, Rate Limiting und Feature-Module
|
||||||
|
- `src/auth`: OIDC Login, Callback, Public Decorator und Guards
|
||||||
|
- `src/sessions`: serverseitige Sessions und CSRF
|
||||||
|
- `src/roles`: Rollen und Code-definierte Permissions
|
||||||
|
- `src/users`: lokale Benutzer und Einstellungen
|
||||||
|
- `src/items`: Beispiel-Fachmodul
|
||||||
|
- `src/database`: TypeORM-Konfiguration, Entities, Migrationen und Healthcheck
|
||||||
|
- `src/common`: Fehlerformat, Validierung, Request ID und Hilfsdienste
|
||||||
|
|
||||||
|
## Entwicklungsregeln
|
||||||
|
|
||||||
|
Controller verwenden keine TypeORM-Repositories direkt. Fachlogik gehoert in
|
||||||
|
Services, Datenbankzugriff in Repository-Klassen oder klar benannte
|
||||||
|
Persistence-Services. Neue Endpunkte muessen mit Permissions geschuetzt werden,
|
||||||
|
sofern sie nicht explizit public sind.
|
||||||
|
|
||||||
|
Migrationen werden nicht automatisch beim normalen App-Start ausgefuehrt. Nutze
|
||||||
|
`npm run migration:run` aus dem Root oder den Workspace-Befehl oben.
|
||||||
35
apps/backend/eslint.config.mjs
Normal file
35
apps/backend/eslint.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
9
apps/backend/nest-cli.json
Normal file
9
apps/backend/nest-cli.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"tsConfigPath": "tsconfig.build.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/backend/package.json
Normal file
43
apps/backend/package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@boilerplate/backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:prod": "node dist/main.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run --config vitest.config.ts",
|
||||||
|
"migration:status": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:show",
|
||||||
|
"migration:run": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:run",
|
||||||
|
"migration:generate": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:generate src/database/migrations/GeneratedMigration"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "11.1.28",
|
||||||
|
"@nestjs/config": "4.0.4",
|
||||||
|
"@nestjs/core": "11.1.28",
|
||||||
|
"@nestjs/platform-express": "11.1.28",
|
||||||
|
"@nestjs/swagger": "11.2.3",
|
||||||
|
"@nestjs/throttler": "6.4.0",
|
||||||
|
"@nestjs/typeorm": "11.0.0",
|
||||||
|
"class-transformer": "0.5.1",
|
||||||
|
"class-validator": "0.14.2",
|
||||||
|
"cookie-parser": "1.4.7",
|
||||||
|
"dotenv": "17.4.2",
|
||||||
|
"helmet": "8.1.0",
|
||||||
|
"jose": "6.1.2",
|
||||||
|
"mysql2": "3.15.3",
|
||||||
|
"openid-client": "6.8.1",
|
||||||
|
"pino": "10.1.0",
|
||||||
|
"pino-http": "11.0.0",
|
||||||
|
"pino-pretty": "13.1.2",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"typeorm": "0.3.27",
|
||||||
|
"zod": "4.1.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typeorm-ts-node-commonjs": "0.3.20"
|
||||||
|
}
|
||||||
|
}
|
||||||
101
apps/backend/src/app.module.ts
Normal file
101
apps/backend/src/app.module.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
|
import { APP_FILTER, APP_GUARD, Reflector } from '@nestjs/core';
|
||||||
|
import {
|
||||||
|
ThrottlerGuard,
|
||||||
|
ThrottlerModule,
|
||||||
|
type ThrottlerGenerateKeyFunction,
|
||||||
|
type ThrottlerGetTrackerFunction,
|
||||||
|
} from '@nestjs/throttler';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { CsrfGuard } from './auth/guards/csrf.guard';
|
||||||
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
|
import { AppConfigModule } from './config/config.module';
|
||||||
|
import { AppConfigService } from './config/config.service';
|
||||||
|
import { ApiExceptionFilter } from './common/errors/api-exception.filter';
|
||||||
|
import { RequestIdMiddleware } from './common/request-context/request-id.middleware';
|
||||||
|
import { SENSITIVE_RATE_LIMIT_KEY } from './common/rate-limit/sensitive-rate-limit.decorator';
|
||||||
|
import { typeOrmOptionsFactory } from './database/typeorm-options';
|
||||||
|
import { DatabaseModule } from './database/database.module';
|
||||||
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { ItemsModule } from './items/items.module';
|
||||||
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
|
import { RolesModule } from './roles/roles.module';
|
||||||
|
import { SessionsModule } from './sessions/sessions.module';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
|
||||||
|
const getIpTracker: ThrottlerGetTrackerFunction = (req) => {
|
||||||
|
const ip = typeof req['ip'] === 'string' ? req['ip'] : undefined;
|
||||||
|
const socket = req['socket'] as { remoteAddress?: unknown } | undefined;
|
||||||
|
const remoteAddress =
|
||||||
|
typeof socket?.remoteAddress === 'string'
|
||||||
|
? socket.remoteAddress
|
||||||
|
: undefined;
|
||||||
|
return ip ?? remoteAddress ?? 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateGlobalKey: ThrottlerGenerateKeyFunction = (
|
||||||
|
_context,
|
||||||
|
tracker,
|
||||||
|
throttlerName,
|
||||||
|
) => createHash('sha256').update(`${throttlerName}:${tracker}`).digest('hex');
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AppConfigModule,
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [AppConfigModule],
|
||||||
|
inject: [AppConfigService],
|
||||||
|
useFactory: typeOrmOptionsFactory,
|
||||||
|
}),
|
||||||
|
DatabaseModule,
|
||||||
|
DashboardModule,
|
||||||
|
ThrottlerModule.forRootAsync({
|
||||||
|
imports: [AppConfigModule],
|
||||||
|
inject: [AppConfigService, Reflector],
|
||||||
|
useFactory: (config: AppConfigService, reflector: Reflector) => ({
|
||||||
|
getTracker: getIpTracker,
|
||||||
|
generateKey: generateGlobalKey,
|
||||||
|
throttlers: [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
ttl: config.rateLimit.global.windowSeconds * 1000,
|
||||||
|
limit: config.rateLimit.global.maxRequests,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sensitive',
|
||||||
|
ttl: config.rateLimit.sensitive.windowSeconds * 1000,
|
||||||
|
limit: config.rateLimit.sensitive.maxRequests,
|
||||||
|
skipIf: (context) =>
|
||||||
|
!reflector.getAllAndOverride<boolean>(SENSITIVE_RATE_LIMIT_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
RolesModule,
|
||||||
|
SessionsModule,
|
||||||
|
NotificationsModule,
|
||||||
|
AuditModule,
|
||||||
|
ItemsModule,
|
||||||
|
HealthModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_FILTER, useClass: ApiExceptionFilter },
|
||||||
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: CsrfGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/backend/src/audit/audit.controller.ts
Normal file
15
apps/backend/src/audit/audit.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { Permission } from '../roles/permissions';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Controller('audit-log')
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private readonly audit: AuditService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.AuditRead)
|
||||||
|
list(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||||
|
return this.audit.list(Number(page ?? 1), Number(pageSize ?? 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/backend/src/audit/audit.module.ts
Normal file
14
apps/backend/src/audit/audit.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditLogEntity } from './entities/audit-log.entity';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
|
import { AuditRepository } from './repositories/audit.repository';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AuditLogEntity])],
|
||||||
|
controllers: [AuditController],
|
||||||
|
providers: [AuditRepository, AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
31
apps/backend/src/audit/audit.service.ts
Normal file
31
apps/backend/src/audit/audit.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { getRequestId } from '../common/request-context/request-context';
|
||||||
|
import { AuditAction, AuditLogEntity } from './entities/audit-log.entity';
|
||||||
|
import { AuditRepository } from './repositories/audit.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(private readonly audit: AuditRepository) {}
|
||||||
|
|
||||||
|
async record(
|
||||||
|
actorUserId: string | null,
|
||||||
|
action: AuditAction,
|
||||||
|
targetType: string,
|
||||||
|
targetId: string,
|
||||||
|
metadata: Record<string, string | number | boolean | null> | null = null,
|
||||||
|
): Promise<void> {
|
||||||
|
const entry = new AuditLogEntity();
|
||||||
|
entry.actorUserId = actorUserId;
|
||||||
|
entry.action = action;
|
||||||
|
entry.targetType = targetType;
|
||||||
|
entry.targetId = targetId;
|
||||||
|
entry.metadata = metadata;
|
||||||
|
entry.requestId = getRequestId();
|
||||||
|
await this.audit.save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(page = 1, pageSize = 20) {
|
||||||
|
const [items, total] = await this.audit.list(page, pageSize);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/backend/src/audit/entities/audit-log.entity.ts
Normal file
49
apps/backend/src/audit/entities/audit-log.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export enum AuditAction {
|
||||||
|
UserActivated = 'USER_ACTIVATED',
|
||||||
|
UserDeactivated = 'USER_DEACTIVATED',
|
||||||
|
UserRoleAssigned = 'USER_ROLE_ASSIGNED',
|
||||||
|
UserRoleRemoved = 'USER_ROLE_REMOVED',
|
||||||
|
RoleCreated = 'ROLE_CREATED',
|
||||||
|
RoleUpdated = 'ROLE_UPDATED',
|
||||||
|
RoleDeleted = 'ROLE_DELETED',
|
||||||
|
RolePermissionsUpdated = 'ROLE_PERMISSIONS_UPDATED',
|
||||||
|
SessionRevoked = 'SESSION_REVOKED',
|
||||||
|
AllUserSessionsRevoked = 'ALL_USER_SESSIONS_REVOKED',
|
||||||
|
NotificationCreated = 'NOTIFICATION_CREATED',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('audit_logs')
|
||||||
|
export class AuditLogEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index('idx_audit_created_at')
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'actor_user_id', type: 'char', length: 36, nullable: true })
|
||||||
|
actorUserId!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80 })
|
||||||
|
action!: AuditAction;
|
||||||
|
|
||||||
|
@Column({ name: 'target_type', type: 'varchar', length: 80 })
|
||||||
|
targetType!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'target_id', type: 'varchar', length: 120 })
|
||||||
|
targetId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
metadata!: Record<string, string | number | boolean | null> | null;
|
||||||
|
|
||||||
|
@Column({ name: 'request_id', type: 'varchar', length: 128 })
|
||||||
|
requestId!: string;
|
||||||
|
}
|
||||||
24
apps/backend/src/audit/repositories/audit.repository.ts
Normal file
24
apps/backend/src/audit/repositories/audit.repository.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLogEntity } from '../entities/audit-log.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AuditLogEntity)
|
||||||
|
private readonly repo: Repository<AuditLogEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
save(entry: AuditLogEntity): Promise<AuditLogEntity> {
|
||||||
|
return this.repo.save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(page: number, pageSize: number): Promise<[AuditLogEntity[], number]> {
|
||||||
|
return this.repo.findAndCount({
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/backend/src/auth/auth.controller.ts
Normal file
69
apps/backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Controller, Get, Query, Redirect, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import type { AuthenticatedRequest } from './authenticated-request';
|
||||||
|
import { Public } from './guards/public.decorator';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(
|
||||||
|
private readonly auth: AuthService,
|
||||||
|
private readonly config: AppConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('login')
|
||||||
|
@Public()
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
@Redirect()
|
||||||
|
async login() {
|
||||||
|
return { url: await this.auth.createLoginUrl() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('callback')
|
||||||
|
@Public()
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
async callback(
|
||||||
|
@Query('code') code: string,
|
||||||
|
@Query('state') state: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { session, csrfToken } = await this.auth.completeLogin(
|
||||||
|
code,
|
||||||
|
state,
|
||||||
|
req.get('user-agent'),
|
||||||
|
req.ip,
|
||||||
|
);
|
||||||
|
res.cookie(this.config.session.cookieName, session.id, {
|
||||||
|
httpOnly: true,
|
||||||
|
signed: true,
|
||||||
|
secure: this.config.isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
expires: session.absoluteExpiresAt,
|
||||||
|
});
|
||||||
|
res.cookie('csrf_token', csrfToken, {
|
||||||
|
httpOnly: false,
|
||||||
|
secure: this.config.isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
expires: session.absoluteExpiresAt,
|
||||||
|
});
|
||||||
|
res.redirect(this.config.frontendBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('logout')
|
||||||
|
@Public()
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
async logout(@Req() req: AuthenticatedRequest, @Res() res: Response) {
|
||||||
|
const sessionId = req.signedCookies?.[this.config.session.cookieName] as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const logoutUrl = await this.auth.logout(sessionId);
|
||||||
|
res.clearCookie(this.config.session.cookieName, { path: '/' });
|
||||||
|
res.clearCookie('csrf_token', { path: '/' });
|
||||||
|
res.redirect(logoutUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/backend/src/auth/auth.module.ts
Normal file
27
apps/backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ExternalHttpClient } from '../common/http/external-http-client';
|
||||||
|
import { RolesModule } from '../roles/roles.module';
|
||||||
|
import { SessionsModule } from '../sessions/sessions.module';
|
||||||
|
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
OidcLoginStateEntity,
|
||||||
|
UserEntity,
|
||||||
|
UserSettingsEntity,
|
||||||
|
]),
|
||||||
|
RolesModule,
|
||||||
|
SessionsModule,
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, ExternalHttpClient, UsersRepository],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
50
apps/backend/src/auth/auth.service.spec.ts
Normal file
50
apps/backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { DataSource, Repository } from 'typeorm';
|
||||||
|
import type { ExternalHttpClient } from '../common/http/external-http-client';
|
||||||
|
import type { AppConfigService } from '../config/config.service';
|
||||||
|
import type { RolesService } from '../roles/roles.service';
|
||||||
|
import type { SessionsService } from '../sessions/sessions.service';
|
||||||
|
import type { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
|
||||||
|
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
|
||||||
|
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>
|
||||||
|
Promise.resolve('id-token'),
|
||||||
|
);
|
||||||
|
const service = new AuthService(
|
||||||
|
{
|
||||||
|
frontendBaseUrl: 'https://app.example.test',
|
||||||
|
appBaseUrl: 'https://app.example.test',
|
||||||
|
oidc: {
|
||||||
|
issuer: 'https://idp.example.test',
|
||||||
|
clientId: 'business-app',
|
||||||
|
clientSecret: 'secret',
|
||||||
|
scopes: 'openid profile email',
|
||||||
|
allowedAlgorithms: ['RS256'],
|
||||||
|
httpTimeoutMs: 5000,
|
||||||
|
logoutUrl: 'https://idp.example.test/logout',
|
||||||
|
},
|
||||||
|
} as AppConfigService,
|
||||||
|
{} as ExternalHttpClient,
|
||||||
|
{} as RolesService,
|
||||||
|
{} as UsersRepository,
|
||||||
|
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
|
||||||
|
{} as DataSource,
|
||||||
|
{} as Repository<OidcLoginStateEntity>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = new URL(await service.logout('session-1'));
|
||||||
|
|
||||||
|
expect(revoke).toHaveBeenCalledWith('session-1');
|
||||||
|
expect(getIdTokenForLogout).toHaveBeenCalledWith('session-1');
|
||||||
|
expect(url.origin + url.pathname).toBe('https://idp.example.test/logout');
|
||||||
|
expect(url.searchParams.get('client_id')).toBe('business-app');
|
||||||
|
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
|
||||||
|
'https://app.example.test',
|
||||||
|
);
|
||||||
|
expect(url.searchParams.get('id_token_hint')).toBe('id-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
328
apps/backend/src/auth/auth.service.ts
Normal file
328
apps/backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, EntityManager, LessThan, Repository } from 'typeorm';
|
||||||
|
import { ExternalHttpClient } from '../common/http/external-http-client';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
import { RolesService } from '../roles/roles.service';
|
||||||
|
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import { SessionsService } from '../sessions/sessions.service';
|
||||||
|
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||||
|
import type {
|
||||||
|
OidcDiscovery,
|
||||||
|
OidcTokenResponse,
|
||||||
|
OidcUserInfo,
|
||||||
|
} from './oidc.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly config: AppConfigService,
|
||||||
|
private readonly http: ExternalHttpClient,
|
||||||
|
private readonly roles: RolesService,
|
||||||
|
private readonly users: UsersRepository,
|
||||||
|
private readonly sessions: SessionsService,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
@InjectRepository(OidcLoginStateEntity)
|
||||||
|
private readonly loginStates: Repository<OidcLoginStateEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createLoginUrl(): Promise<string> {
|
||||||
|
const discovery = await this.discovery();
|
||||||
|
const state = randomBytes(32).toString('hex');
|
||||||
|
const nonce = randomBytes(32).toString('base64url');
|
||||||
|
const codeVerifier = randomBytes(48).toString('base64url');
|
||||||
|
const challenge = createHash('sha256')
|
||||||
|
.update(codeVerifier)
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
await this.loginStates.delete({ expiresAt: LessThan(new Date()) });
|
||||||
|
const loginState = new OidcLoginStateEntity();
|
||||||
|
loginState.state = state;
|
||||||
|
loginState.codeVerifier = codeVerifier;
|
||||||
|
loginState.nonce = nonce;
|
||||||
|
loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||||
|
await this.loginStates.save(loginState);
|
||||||
|
|
||||||
|
const url = new URL(discovery.authorization_endpoint);
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('client_id', this.config.oidc.clientId);
|
||||||
|
url.searchParams.set('redirect_uri', this.callbackUrl);
|
||||||
|
url.searchParams.set('scope', this.config.oidc.scopes);
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('nonce', nonce);
|
||||||
|
url.searchParams.set('code_challenge', challenge);
|
||||||
|
url.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeLogin(
|
||||||
|
code: string,
|
||||||
|
state: string,
|
||||||
|
userAgent: string | undefined,
|
||||||
|
ip: string | undefined,
|
||||||
|
) {
|
||||||
|
const loginState = await this.loginStates.findOneBy({ state });
|
||||||
|
if (!loginState || loginState.expiresAt <= new Date()) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'Die Anmeldung ist abgelaufen.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.loginStates.delete({ state });
|
||||||
|
|
||||||
|
const discovery = await this.discovery();
|
||||||
|
const tokens = await this.exchangeCode(
|
||||||
|
discovery,
|
||||||
|
code,
|
||||||
|
loginState.codeVerifier,
|
||||||
|
);
|
||||||
|
const profile = await this.verifyAndLoadProfile(
|
||||||
|
discovery,
|
||||||
|
tokens,
|
||||||
|
loginState.nonce,
|
||||||
|
);
|
||||||
|
const user = await this.upsertLocalUser(discovery.issuer, profile);
|
||||||
|
if (!user.active) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.UserDisabled,
|
||||||
|
'Dieser Benutzer ist deaktiviert.',
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sessions.createSession(
|
||||||
|
user,
|
||||||
|
{
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
|
||||||
|
idToken: tokens.id_token,
|
||||||
|
accessTokenExpiresAt: new Date(
|
||||||
|
Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
userAgent,
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(sessionId: string | undefined): Promise<string> {
|
||||||
|
let idToken: string | undefined;
|
||||||
|
if (sessionId) {
|
||||||
|
idToken = await this.readLogoutIdToken(sessionId);
|
||||||
|
await this.sessions.revoke(sessionId);
|
||||||
|
}
|
||||||
|
return this.createLogoutUrl(idToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertLocalUser(
|
||||||
|
issuer: string,
|
||||||
|
profile: OidcUserInfo,
|
||||||
|
): Promise<UserEntity> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.query("SELECT GET_LOCK('business_app_first_admin', 10)");
|
||||||
|
try {
|
||||||
|
const { admin, user: userRole } =
|
||||||
|
await this.roles.ensureSystemRoles(manager);
|
||||||
|
let user = await manager.getRepository(UserEntity).findOne({
|
||||||
|
where: { issuer, subject: profile.sub },
|
||||||
|
relations: { roles: true, settings: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
user = new UserEntity();
|
||||||
|
user.issuer = issuer;
|
||||||
|
user.subject = profile.sub;
|
||||||
|
user.active = true;
|
||||||
|
user.roles = [userRole];
|
||||||
|
const userCount = await manager.getRepository(UserEntity).count();
|
||||||
|
if (userCount === 0) {
|
||||||
|
user.roles = [userRole, admin];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.name = profile.name ?? profile.email ?? profile.sub;
|
||||||
|
user.email = profile.email ?? null;
|
||||||
|
user.lastLoginAt = new Date();
|
||||||
|
const savedUser = await manager.getRepository(UserEntity).save(user);
|
||||||
|
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
|
||||||
|
return savedUser;
|
||||||
|
} finally {
|
||||||
|
await manager.query("SELECT RELEASE_LOCK('business_app_first_admin')");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureUserSettings(
|
||||||
|
manager: EntityManager,
|
||||||
|
user: UserEntity,
|
||||||
|
): Promise<UserSettingsEntity> {
|
||||||
|
const settingsRepository = manager.getRepository(UserSettingsEntity);
|
||||||
|
const existingSettings = await settingsRepository.findOne({
|
||||||
|
where: { user: { id: user.id } },
|
||||||
|
});
|
||||||
|
if (existingSettings) {
|
||||||
|
return existingSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = new UserSettingsEntity();
|
||||||
|
settings.user = user;
|
||||||
|
return settingsRepository.save(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async discovery(): Promise<OidcDiscovery> {
|
||||||
|
const discoveryUrl = new URL(
|
||||||
|
'/.well-known/openid-configuration',
|
||||||
|
this.config.oidc.issuer,
|
||||||
|
);
|
||||||
|
const discovery = await this.http.requestJson<OidcDiscovery>(
|
||||||
|
discoveryUrl.toString(),
|
||||||
|
);
|
||||||
|
if (discovery.issuer !== this.config.oidc.issuer) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'OIDC-Issuer ist ungueltig.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return discovery;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readLogoutIdToken(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
return await this.sessions.getIdTokenForLogout(sessionId);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createLogoutUrl(idToken: string | undefined): Promise<string> {
|
||||||
|
const endpoint = await this.resolveLogoutEndpoint();
|
||||||
|
if (!endpoint) {
|
||||||
|
return this.config.frontendBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(endpoint);
|
||||||
|
url.searchParams.set('client_id', this.config.oidc.clientId);
|
||||||
|
url.searchParams.set(
|
||||||
|
'post_logout_redirect_uri',
|
||||||
|
this.config.frontendBaseUrl,
|
||||||
|
);
|
||||||
|
if (idToken) {
|
||||||
|
url.searchParams.set('id_token_hint', idToken);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveLogoutEndpoint(): Promise<string | undefined> {
|
||||||
|
if (this.config.oidc.logoutUrl) {
|
||||||
|
return this.config.oidc.logoutUrl;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (await this.discovery()).end_session_endpoint;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exchangeCode(
|
||||||
|
discovery: OidcDiscovery,
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
): Promise<OidcTokenResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: this.callbackUrl,
|
||||||
|
client_id: this.config.oidc.clientId,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
const basic = Buffer.from(
|
||||||
|
`${this.config.oidc.clientId}:${this.config.oidc.clientSecret}`,
|
||||||
|
'utf8',
|
||||||
|
).toString('base64');
|
||||||
|
return this.http.requestJson<OidcTokenResponse>(discovery.token_endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${basic}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyAndLoadProfile(
|
||||||
|
discovery: OidcDiscovery,
|
||||||
|
tokens: OidcTokenResponse,
|
||||||
|
nonce: string,
|
||||||
|
): Promise<OidcUserInfo> {
|
||||||
|
const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } =
|
||||||
|
await import('jose');
|
||||||
|
const protectedHeader = decodeProtectedHeader(tokens.id_token);
|
||||||
|
if (!this.isAllowedOidcAlgorithm(protectedHeader.alg)) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'OIDC-Signaturalgorithmus ist ungueltig.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { payload } = await jwtVerify(
|
||||||
|
tokens.id_token,
|
||||||
|
createRemoteJWKSet(new URL(discovery.jwks_uri)),
|
||||||
|
{
|
||||||
|
issuer: discovery.issuer,
|
||||||
|
audience: this.config.oidc.clientId,
|
||||||
|
algorithms: this.config.oidc.allowedAlgorithms,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (payload['nonce'] !== nonce || typeof payload.sub !== 'string') {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'OIDC-Token ist ungueltig.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!discovery.userinfo_endpoint) {
|
||||||
|
const fallback: OidcUserInfo = { sub: payload.sub };
|
||||||
|
if (typeof payload['name'] === 'string') {
|
||||||
|
fallback.name = payload['name'];
|
||||||
|
}
|
||||||
|
if (typeof payload['email'] === 'string') {
|
||||||
|
fallback.email = payload['email'];
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const userInfo = await this.http.requestJson<OidcUserInfo>(
|
||||||
|
discovery.userinfo_endpoint,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (userInfo.sub !== payload.sub) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'OIDC-UserInfo ist ungueltig.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean {
|
||||||
|
return (
|
||||||
|
typeof algorithm === 'string' &&
|
||||||
|
algorithm.toLowerCase() !== 'none' &&
|
||||||
|
this.config.oidc.allowedAlgorithms.includes(algorithm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get callbackUrl(): string {
|
||||||
|
return new URL('/api/auth/callback', this.config.appBaseUrl).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/backend/src/auth/authenticated-request.ts
Normal file
12
apps/backend/src/auth/authenticated-request.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
import type { Permission } from '../roles/permissions';
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
}
|
||||||
19
apps/backend/src/auth/entities/oidc-login-state.entity.ts
Normal file
19
apps/backend/src/auth/entities/oidc-login-state.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('oidc_login_states')
|
||||||
|
export class OidcLoginStateEntity {
|
||||||
|
@PrimaryColumn({ type: 'char', length: 64 })
|
||||||
|
state!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'code_verifier', type: 'varchar', length: 160 })
|
||||||
|
codeVerifier!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 160 })
|
||||||
|
nonce!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'expires_at', type: 'datetime', precision: 3 })
|
||||||
|
expiresAt!: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
49
apps/backend/src/auth/guards/csrf.guard.ts
Normal file
49
apps/backend/src/auth/guards/csrf.guard.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ApiError } from '../../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../../common/errors/error-codes';
|
||||||
|
import { AppConfigService } from '../../config/config.service';
|
||||||
|
import { SessionsService } from '../../sessions/sessions.service';
|
||||||
|
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||||
|
import type { AuthenticatedRequest } from '../authenticated-request';
|
||||||
|
|
||||||
|
const unsafeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CsrfGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly sessions: SessionsService,
|
||||||
|
private readonly config: AppConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
if (!unsafeMethods.has(request.method)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const sessionId = request.signedCookies?.[
|
||||||
|
this.config.session.cookieName
|
||||||
|
] as string | undefined;
|
||||||
|
const csrfToken = request.header(this.config.csrfHeaderName);
|
||||||
|
if (
|
||||||
|
!sessionId ||
|
||||||
|
!csrfToken ||
|
||||||
|
!(await this.sessions.verifyCsrfToken(sessionId, csrfToken))
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.CsrfInvalid,
|
||||||
|
'Das Sicherheits-Token ist ungueltig.',
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/backend/src/auth/guards/permissions.guard.ts
Normal file
68
apps/backend/src/auth/guards/permissions.guard.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ApiError } from '../../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../../common/errors/error-codes';
|
||||||
|
import { AppConfigService } from '../../config/config.service';
|
||||||
|
import { SessionsService } from '../../sessions/sessions.service';
|
||||||
|
import type { Permission } from '../../roles/permissions';
|
||||||
|
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||||
|
import { REQUIRED_PERMISSIONS_KEY } from './require-permissions.decorator';
|
||||||
|
import type { AuthenticatedRequest } from '../authenticated-request';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionsGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly sessions: SessionsService,
|
||||||
|
private readonly config: AppConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
const required = this.reflector.getAllAndOverride<Permission[]>(
|
||||||
|
REQUIRED_PERMISSIONS_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (isPublic || !required?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
const sessionId = request.signedCookies?.[
|
||||||
|
this.config.session.cookieName
|
||||||
|
] as string | undefined;
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'Bitte melden Sie sich an.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.sessions.resolveSession(
|
||||||
|
sessionId,
|
||||||
|
request.ip,
|
||||||
|
request.get('user-agent'),
|
||||||
|
);
|
||||||
|
request.user = {
|
||||||
|
id: session.user.id,
|
||||||
|
sessionId,
|
||||||
|
permissions: session.permissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowed = required.every((permission) =>
|
||||||
|
session.permissions.includes(permission),
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.PermissionDenied,
|
||||||
|
'Keine Berechtigung fuer diese Aktion.',
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/backend/src/auth/guards/public.decorator.ts
Normal file
4
apps/backend/src/auth/guards/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import type { Permission } from '../../roles/permissions';
|
||||||
|
|
||||||
|
export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions';
|
||||||
|
export const RequirePermissions = (...permissions: Permission[]) =>
|
||||||
|
SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);
|
||||||
23
apps/backend/src/auth/oidc.types.ts
Normal file
23
apps/backend/src/auth/oidc.types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface OidcDiscovery {
|
||||||
|
issuer: string;
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
userinfo_endpoint?: string;
|
||||||
|
jwks_uri: string;
|
||||||
|
revocation_endpoint?: string;
|
||||||
|
end_session_endpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OidcTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
id_token: string;
|
||||||
|
expires_in?: number;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OidcUserInfo {
|
||||||
|
sub: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
28
apps/backend/src/common/dto/pagination.dto.ts
Normal file
28
apps/backend/src/common/dto/pagination.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class PaginationQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize = 20;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageDto<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
27
apps/backend/src/common/errors/api-error.ts
Normal file
27
apps/backend/src/common/errors/api-error.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import type { HttpStatus } from '@nestjs/common';
|
||||||
|
import type { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
|
export interface ValidationErrorDetail {
|
||||||
|
field: string;
|
||||||
|
messages: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
status: number;
|
||||||
|
code: ErrorCode;
|
||||||
|
message: string;
|
||||||
|
requestId: string;
|
||||||
|
validation?: ValidationErrorDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly code: ErrorCode,
|
||||||
|
message: string,
|
||||||
|
status: HttpStatus,
|
||||||
|
public readonly validation?: ValidationErrorDetail[],
|
||||||
|
) {
|
||||||
|
super(message, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/backend/src/common/errors/api-exception.filter.ts
Normal file
66
apps/backend/src/common/errors/api-exception.filter.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { QueryFailedError } from 'typeorm';
|
||||||
|
import { getRequestId } from '../request-context/request-context';
|
||||||
|
import { ApiError, type ApiErrorBody } from './api-error';
|
||||||
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class ApiExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(ApiExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const requestId = getRequestId();
|
||||||
|
|
||||||
|
let status: number = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
let code = ErrorCode.InternalError;
|
||||||
|
let message = 'Ein unerwarteter Fehler ist aufgetreten.';
|
||||||
|
let validation: ApiErrorBody['validation'];
|
||||||
|
|
||||||
|
if (exception instanceof ApiError) {
|
||||||
|
status = exception.getStatus();
|
||||||
|
code = exception.code;
|
||||||
|
message = exception.message;
|
||||||
|
validation = exception.validation;
|
||||||
|
} else if (exception instanceof HttpException) {
|
||||||
|
status = exception.getStatus();
|
||||||
|
code =
|
||||||
|
status === 401
|
||||||
|
? ErrorCode.Unauthorized
|
||||||
|
: status === 429
|
||||||
|
? ErrorCode.RateLimitExceeded
|
||||||
|
: ErrorCode.InternalError;
|
||||||
|
message =
|
||||||
|
status === 429
|
||||||
|
? 'Zu viele Anfragen. Bitte versuchen Sie es spaeter erneut.'
|
||||||
|
: status >= 500
|
||||||
|
? message
|
||||||
|
: 'Die Anfrage konnte nicht verarbeitet werden.';
|
||||||
|
} else if (exception instanceof QueryFailedError) {
|
||||||
|
status = HttpStatus.CONFLICT;
|
||||||
|
code = ErrorCode.Conflict;
|
||||||
|
message = 'Die Aenderung steht im Konflikt mit bestehenden Daten.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
this.logger.error({ requestId, exception }, 'Unhandled API error');
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
requestId,
|
||||||
|
...(validation ? { validation } : {}),
|
||||||
|
} satisfies ApiErrorBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/backend/src/common/errors/error-codes.ts
Normal file
29
apps/backend/src/common/errors/error-codes.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export enum ErrorCode {
|
||||||
|
PermissionDenied = 'PERMISSION_DENIED',
|
||||||
|
Unauthorized = 'UNAUTHORIZED',
|
||||||
|
ValidationFailed = 'VALIDATION_FAILED',
|
||||||
|
NotFound = 'NOT_FOUND',
|
||||||
|
Conflict = 'CONFLICT',
|
||||||
|
CsrfInvalid = 'CSRF_INVALID',
|
||||||
|
UserDisabled = 'USER_DISABLED',
|
||||||
|
LastAdminRequired = 'LAST_ACTIVE_ADMIN_REQUIRED',
|
||||||
|
MigrationMissing = 'MIGRATION_MISSING',
|
||||||
|
RateLimitExceeded = 'RATE_LIMIT_EXCEEDED',
|
||||||
|
NotificationNotFound = 'NOTIFICATION_NOT_FOUND',
|
||||||
|
NotificationAccessDenied = 'NOTIFICATION_ACCESS_DENIED',
|
||||||
|
NotificationTypeInvalid = 'NOTIFICATION_TYPE_INVALID',
|
||||||
|
NotificationLinkInvalid = 'NOTIFICATION_LINK_INVALID',
|
||||||
|
NotificationMetadataTooLarge = 'NOTIFICATION_METADATA_TOO_LARGE',
|
||||||
|
UserNotFound = 'USER_NOT_FOUND',
|
||||||
|
UserAlreadyActive = 'USER_ALREADY_ACTIVE',
|
||||||
|
UserAlreadyInactive = 'USER_ALREADY_INACTIVE',
|
||||||
|
RoleNotFound = 'ROLE_NOT_FOUND',
|
||||||
|
RoleAlreadyAssigned = 'ROLE_ALREADY_ASSIGNED',
|
||||||
|
RoleNotAssigned = 'ROLE_NOT_ASSIGNED',
|
||||||
|
RoleNameAlreadyExists = 'ROLE_NAME_ALREADY_EXISTS',
|
||||||
|
RoleStillAssigned = 'ROLE_STILL_ASSIGNED',
|
||||||
|
SystemRoleProtected = 'SYSTEM_ROLE_PROTECTED',
|
||||||
|
UnknownPermission = 'UNKNOWN_PERMISSION',
|
||||||
|
SessionNotFound = 'SESSION_NOT_FOUND',
|
||||||
|
InternalError = 'INTERNAL_ERROR',
|
||||||
|
}
|
||||||
55
apps/backend/src/common/http/external-http-client.ts
Normal file
55
apps/backend/src/common/http/external-http-client.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { AppConfigService } from '../../config/config.service';
|
||||||
|
import { getRequestId } from '../request-context/request-context';
|
||||||
|
|
||||||
|
export interface ExternalHttpOptions {
|
||||||
|
method?: 'GET' | 'POST';
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: URLSearchParams | string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExternalHttpClient {
|
||||||
|
private readonly logger = new Logger(ExternalHttpClient.name);
|
||||||
|
|
||||||
|
constructor(private readonly config: AppConfigService) {}
|
||||||
|
|
||||||
|
async requestJson<T>(
|
||||||
|
url: string,
|
||||||
|
options: ExternalHttpOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
options.timeoutMs ?? this.config.oidc.httpTimeoutMs,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const init: RequestInit = {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Request-ID': getRequestId(),
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
init.body = options.body;
|
||||||
|
}
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ url, status: response.status },
|
||||||
|
'External HTTP request failed',
|
||||||
|
);
|
||||||
|
throw new Error(`External HTTP request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const SENSITIVE_RATE_LIMIT_KEY = Symbol('SENSITIVE_RATE_LIMIT');
|
||||||
|
|
||||||
|
export const SensitiveRateLimit = () =>
|
||||||
|
SetMetadata(SENSITIVE_RATE_LIMIT_KEY, true);
|
||||||
12
apps/backend/src/common/request-context/request-context.ts
Normal file
12
apps/backend/src/common/request-context/request-context.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
|
||||||
|
export interface RequestContext {
|
||||||
|
requestId: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestContextStorage = new AsyncLocalStorage<RequestContext>();
|
||||||
|
|
||||||
|
export function getRequestId(): string {
|
||||||
|
return requestContextStorage.getStore()?.requestId ?? 'unknown';
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { requestContextStorage } from './request-context';
|
||||||
|
|
||||||
|
export const requestIdHeader = 'x-request-id';
|
||||||
|
|
||||||
|
export class RequestIdMiddleware {
|
||||||
|
use(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const incoming = req.header(requestIdHeader);
|
||||||
|
const requestId =
|
||||||
|
incoming && incoming.length <= 128 ? incoming : randomUUID();
|
||||||
|
res.setHeader('X-Request-ID', requestId);
|
||||||
|
requestContextStorage.run({ requestId }, () => next());
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/backend/src/common/security/validation.pipe.ts
Normal file
42
apps/backend/src/common/security/validation.pipe.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import type { ValidationError } from '@nestjs/common';
|
||||||
|
import { ErrorCode } from '../errors/error-codes';
|
||||||
|
import { ApiError, type ValidationErrorDetail } from '../errors/api-error';
|
||||||
|
|
||||||
|
function flattenValidation(
|
||||||
|
errors: ValidationError[],
|
||||||
|
parent = '',
|
||||||
|
): ValidationErrorDetail[] {
|
||||||
|
return errors.flatMap((error) => {
|
||||||
|
const field = parent ? `${parent}.${error.property}` : error.property;
|
||||||
|
const own = error.constraints
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
field,
|
||||||
|
messages: Object.values(error.constraints).map(
|
||||||
|
() => 'Ungueltiger Wert.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
return [...own, ...flattenValidation(error.children ?? [], field)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createValidationPipe(): ValidationPipe {
|
||||||
|
return new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: false },
|
||||||
|
exceptionFactory: (errors) => {
|
||||||
|
const validation = flattenValidation(errors);
|
||||||
|
return new ApiError(
|
||||||
|
ErrorCode.ValidationFailed,
|
||||||
|
'Bitte pruefen Sie die markierten Felder.',
|
||||||
|
400,
|
||||||
|
validation,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
20
apps/backend/src/config/config.module.ts
Normal file
20
apps/backend/src/config/config.module.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { envFilePaths, loadConfigFromEnv } from './env';
|
||||||
|
import { AppConfigService } from './config.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
cache: true,
|
||||||
|
envFilePath: envFilePaths,
|
||||||
|
expandVariables: false,
|
||||||
|
isGlobal: true,
|
||||||
|
validate: loadConfigFromEnv,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [AppConfigService],
|
||||||
|
exports: [AppConfigService],
|
||||||
|
})
|
||||||
|
export class AppConfigModule {}
|
||||||
104
apps/backend/src/config/config.service.spec.ts
Normal file
104
apps/backend/src/config/config.service.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { loadConfigFromEnv } from './env';
|
||||||
|
|
||||||
|
const validEnv = {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
PORT: '3000',
|
||||||
|
APP_BASE_URL: 'http://localhost:3000',
|
||||||
|
DATABASE_HOST: 'localhost',
|
||||||
|
DATABASE_PORT: '3306',
|
||||||
|
DATABASE_NAME: 'business_app_test',
|
||||||
|
DATABASE_USER: 'test',
|
||||||
|
DATABASE_PASSWORD: 'test',
|
||||||
|
OIDC_ISSUER: 'https://idp.example.test',
|
||||||
|
OIDC_CLIENT_ID: 'client',
|
||||||
|
OIDC_CLIENT_SECRET: '12345678901234567890123456789012',
|
||||||
|
OIDC_ALLOWED_ALGORITHMS: 'RS256',
|
||||||
|
SESSION_SECRET: '12345678901234567890123456789012',
|
||||||
|
SESSION_ENCRYPTION_KEY: 'abcdefghijklmnopqrstuvwxyz123456',
|
||||||
|
CORS_ORIGINS: 'http://localhost:4200',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('loadConfigFromEnv', () => {
|
||||||
|
it('validates required configuration centrally', () => {
|
||||||
|
const config = loadConfigFromEnv(validEnv);
|
||||||
|
|
||||||
|
expect(config.database.name).toBe('business_app_test');
|
||||||
|
expect(config.frontendBaseUrl).toBe('http://localhost:3000');
|
||||||
|
expect(config.oidc.allowedAlgorithms).toEqual(['RS256']);
|
||||||
|
expect(config.rateLimit.global).toEqual({
|
||||||
|
windowSeconds: 60,
|
||||||
|
maxRequests: 300,
|
||||||
|
});
|
||||||
|
expect(config.rateLimit.sensitive).toEqual({
|
||||||
|
windowSeconds: 60,
|
||||||
|
maxRequests: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows all rate limit buckets to be configured independently', () => {
|
||||||
|
const config = loadConfigFromEnv({
|
||||||
|
...validEnv,
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS: '30',
|
||||||
|
RATE_LIMIT_MAX_REQUESTS: '200',
|
||||||
|
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: '90',
|
||||||
|
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: '5',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.rateLimit).toEqual({
|
||||||
|
global: { windowSeconds: 30, maxRequests: 200 },
|
||||||
|
sensitive: { windowSeconds: 90, maxRequests: 5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a separate frontend base URL when configured', () => {
|
||||||
|
const config = loadConfigFromEnv({
|
||||||
|
...validEnv,
|
||||||
|
FRONTEND_BASE_URL: 'http://localhost:4200',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.frontendBaseUrl).toBe('http://localhost:4200');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an optional OIDC logout URL', () => {
|
||||||
|
const config = loadConfigFromEnv({
|
||||||
|
...validEnv,
|
||||||
|
OIDC_LOGOUT_URL: 'https://idp.example.test/logout',
|
||||||
|
});
|
||||||
|
const withoutLogout = loadConfigFromEnv({
|
||||||
|
...validEnv,
|
||||||
|
OIDC_LOGOUT_URL: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.oidc.logoutUrl).toBe('https://idp.example.test/logout');
|
||||||
|
expect(withoutLogout.oidc.logoutUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe production secret placeholders', () => {
|
||||||
|
expect(() =>
|
||||||
|
loadConfigFromEnv({
|
||||||
|
...validEnv,
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
OIDC_CLIENT_SECRET: 'change-me-change-me-change-me-change-me',
|
||||||
|
}),
|
||||||
|
).toThrow(/Production-Secrets/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts comma or whitespace separated OIDC signing algorithms', () => {
|
||||||
|
const config = loadConfigFromEnv({
|
||||||
|
...validEnv,
|
||||||
|
OIDC_ALLOWED_ALGORITHMS: 'RS256 PS256,ES256',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.oidc.allowedAlgorithms).toEqual(['RS256', 'PS256', 'ES256']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects alg none in OIDC signing algorithms', () => {
|
||||||
|
expect(() =>
|
||||||
|
loadConfigFromEnv({
|
||||||
|
...validEnv,
|
||||||
|
OIDC_ALLOWED_ALGORITHMS: 'RS256,none',
|
||||||
|
}),
|
||||||
|
).toThrow(/OIDC_ALLOWED_ALGORITHMS/);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
apps/backend/src/config/config.service.ts
Normal file
102
apps/backend/src/config/config.service.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService as NestConfigService } from '@nestjs/config';
|
||||||
|
import type { AppConfig, NodeEnv } from './config.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppConfigService {
|
||||||
|
private readonly config: AppConfig;
|
||||||
|
|
||||||
|
constructor(private readonly nestConfig: NestConfigService<AppConfig, true>) {
|
||||||
|
this.config = {
|
||||||
|
nodeEnv: this.nestConfig.getOrThrow('nodeEnv', { infer: true }),
|
||||||
|
port: this.nestConfig.getOrThrow('port', { infer: true }),
|
||||||
|
appBaseUrl: this.nestConfig.getOrThrow('appBaseUrl', { infer: true }),
|
||||||
|
frontendBaseUrl: this.nestConfig.getOrThrow('frontendBaseUrl', {
|
||||||
|
infer: true,
|
||||||
|
}),
|
||||||
|
trustProxy: this.nestConfig.getOrThrow('trustProxy', { infer: true }),
|
||||||
|
database: this.nestConfig.getOrThrow('database', { infer: true }),
|
||||||
|
oidc: this.nestConfig.getOrThrow('oidc', { infer: true }),
|
||||||
|
session: this.nestConfig.getOrThrow('session', { infer: true }),
|
||||||
|
corsOrigins: this.nestConfig.getOrThrow('corsOrigins', { infer: true }),
|
||||||
|
csrfHeaderName: this.nestConfig.getOrThrow('csrfHeaderName', {
|
||||||
|
infer: true,
|
||||||
|
}),
|
||||||
|
logLevel: this.nestConfig.getOrThrow('logLevel', { infer: true }),
|
||||||
|
swaggerEnabled: this.nestConfig.getOrThrow('swaggerEnabled', {
|
||||||
|
infer: true,
|
||||||
|
}),
|
||||||
|
rateLimit: this.nestConfig.getOrThrow('rateLimit', { infer: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get nodeEnv(): NodeEnv {
|
||||||
|
return this.config.nodeEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isProduction(): boolean {
|
||||||
|
return this.config.nodeEnv === 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
get port(): number {
|
||||||
|
return this.config.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
get appBaseUrl(): string {
|
||||||
|
return this.config.appBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get frontendBaseUrl(): string {
|
||||||
|
return this.config.frontendBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get trustProxy(): boolean {
|
||||||
|
return this.config.trustProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
get database(): AppConfig['database'] {
|
||||||
|
return this.config.database;
|
||||||
|
}
|
||||||
|
|
||||||
|
get oidc(): AppConfig['oidc'] {
|
||||||
|
return this.config.oidc;
|
||||||
|
}
|
||||||
|
|
||||||
|
get session(): AppConfig['session'] {
|
||||||
|
return this.config.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
get corsOrigins(): string[] {
|
||||||
|
return this.config.corsOrigins;
|
||||||
|
}
|
||||||
|
|
||||||
|
get csrfHeaderName(): string {
|
||||||
|
return this.config.csrfHeaderName;
|
||||||
|
}
|
||||||
|
|
||||||
|
get logLevel(): string {
|
||||||
|
return this.config.logLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
get swaggerEnabled(): boolean {
|
||||||
|
return this.config.swaggerEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rateLimit(): AppConfig['rateLimit'] {
|
||||||
|
return this.config.rateLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use rateLimit.global.windowSeconds.
|
||||||
|
*/
|
||||||
|
get rateLimitWindowSeconds(): number {
|
||||||
|
return this.config.rateLimit.global.windowSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use rateLimit.global.maxRequests.
|
||||||
|
*/
|
||||||
|
get rateLimitMaxRequests(): number {
|
||||||
|
return this.config.rateLimit.global.maxRequests;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/backend/src/config/config.types.ts
Normal file
46
apps/backend/src/config/config.types.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export type NodeEnv = 'development' | 'test' | 'production';
|
||||||
|
|
||||||
|
export interface RateLimitRuleConfig {
|
||||||
|
windowSeconds: number;
|
||||||
|
maxRequests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
nodeEnv: NodeEnv;
|
||||||
|
port: number;
|
||||||
|
appBaseUrl: string;
|
||||||
|
frontendBaseUrl: string;
|
||||||
|
trustProxy: boolean;
|
||||||
|
database: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
name: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
ssl: boolean;
|
||||||
|
};
|
||||||
|
oidc: {
|
||||||
|
issuer: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scopes: string;
|
||||||
|
allowedAlgorithms: string[];
|
||||||
|
httpTimeoutMs: number;
|
||||||
|
logoutUrl?: string;
|
||||||
|
};
|
||||||
|
session: {
|
||||||
|
cookieName: string;
|
||||||
|
idleTimeoutSeconds: number;
|
||||||
|
absoluteTimeoutSeconds: number;
|
||||||
|
secret: string;
|
||||||
|
encryptionKey: string;
|
||||||
|
};
|
||||||
|
corsOrigins: string[];
|
||||||
|
csrfHeaderName: string;
|
||||||
|
logLevel: string;
|
||||||
|
swaggerEnabled: boolean;
|
||||||
|
rateLimit: {
|
||||||
|
global: RateLimitRuleConfig;
|
||||||
|
sensitive: RateLimitRuleConfig;
|
||||||
|
};
|
||||||
|
}
|
||||||
157
apps/backend/src/config/env.ts
Normal file
157
apps/backend/src/config/env.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { config as loadDotenv } from 'dotenv';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AppConfig } from './config.types';
|
||||||
|
|
||||||
|
export const envFilePaths = ['.env', '../../.env'];
|
||||||
|
|
||||||
|
const booleanFromString = z
|
||||||
|
.string()
|
||||||
|
.transform((value) => value.toLowerCase())
|
||||||
|
.pipe(z.enum(['true', 'false']))
|
||||||
|
.transform((value) => value === 'true');
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z
|
||||||
|
.enum(['development', 'test', 'production'])
|
||||||
|
.default('development'),
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
|
APP_BASE_URL: z.url(),
|
||||||
|
FRONTEND_BASE_URL: z.url().optional(),
|
||||||
|
TRUST_PROXY: booleanFromString.default(false),
|
||||||
|
DATABASE_HOST: z.string().min(1),
|
||||||
|
DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
|
||||||
|
DATABASE_NAME: z.string().min(1),
|
||||||
|
DATABASE_USER: z.string().min(1),
|
||||||
|
DATABASE_PASSWORD: z.string().min(1),
|
||||||
|
DATABASE_SSL: booleanFromString.default(false),
|
||||||
|
OIDC_ISSUER: z.url(),
|
||||||
|
OIDC_CLIENT_ID: z.string().min(1),
|
||||||
|
OIDC_CLIENT_SECRET: z.string().min(1),
|
||||||
|
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
|
||||||
|
OIDC_LOGOUT_URL: z.preprocess(
|
||||||
|
(value) => (value === '' ? undefined : value),
|
||||||
|
z.url().optional(),
|
||||||
|
),
|
||||||
|
OIDC_ALLOWED_ALGORITHMS: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.transform((value) =>
|
||||||
|
value
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(algorithms) =>
|
||||||
|
algorithms.length > 0 &&
|
||||||
|
algorithms.every((algorithm) => algorithm.toLowerCase() !== 'none'),
|
||||||
|
'OIDC_ALLOWED_ALGORITHMS darf none nicht erlauben.',
|
||||||
|
),
|
||||||
|
OIDC_HTTP_TIMEOUT_MS: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1000)
|
||||||
|
.max(30000)
|
||||||
|
.default(5000),
|
||||||
|
SESSION_COOKIE_NAME: z.string().min(1).default('app_session'),
|
||||||
|
SESSION_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(300).default(28800),
|
||||||
|
SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(3600)
|
||||||
|
.default(604800),
|
||||||
|
SESSION_SECRET: z.string().min(32),
|
||||||
|
SESSION_ENCRYPTION_KEY: z.string().min(32),
|
||||||
|
CORS_ORIGINS: z.string().transform((value) =>
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
|
||||||
|
LOG_LEVEL: z.string().min(1).default('info'),
|
||||||
|
SWAGGER_ENABLED: booleanFromString.default(false),
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),
|
||||||
|
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300),
|
||||||
|
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.default(60),
|
||||||
|
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce.number().int().min(1).default(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
|
||||||
|
const parsed = envSchema.safeParse(env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const issues = parsed.error.issues.map(
|
||||||
|
(issue) => `${issue.path.join('.')}: ${issue.message}`,
|
||||||
|
);
|
||||||
|
throw new Error(`Ungueltige Konfiguration:\n${issues.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = parsed.data;
|
||||||
|
if (value.NODE_ENV === 'production') {
|
||||||
|
const insecure = [
|
||||||
|
value.SESSION_SECRET.includes('change'),
|
||||||
|
value.SESSION_ENCRYPTION_KEY.includes('change'),
|
||||||
|
value.OIDC_CLIENT_SECRET.includes('change'),
|
||||||
|
];
|
||||||
|
if (insecure.some(Boolean)) {
|
||||||
|
throw new Error(
|
||||||
|
'Production-Secrets muessen explizit und sicher gesetzt werden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeEnv: value.NODE_ENV,
|
||||||
|
port: value.PORT,
|
||||||
|
appBaseUrl: value.APP_BASE_URL,
|
||||||
|
frontendBaseUrl: value.FRONTEND_BASE_URL ?? value.APP_BASE_URL,
|
||||||
|
trustProxy: value.TRUST_PROXY,
|
||||||
|
database: {
|
||||||
|
host: value.DATABASE_HOST,
|
||||||
|
port: value.DATABASE_PORT,
|
||||||
|
name: value.DATABASE_NAME,
|
||||||
|
user: value.DATABASE_USER,
|
||||||
|
password: value.DATABASE_PASSWORD,
|
||||||
|
ssl: value.DATABASE_SSL,
|
||||||
|
},
|
||||||
|
oidc: {
|
||||||
|
issuer: value.OIDC_ISSUER,
|
||||||
|
clientId: value.OIDC_CLIENT_ID,
|
||||||
|
clientSecret: value.OIDC_CLIENT_SECRET,
|
||||||
|
scopes: value.OIDC_SCOPES,
|
||||||
|
allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS,
|
||||||
|
httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS,
|
||||||
|
...(value.OIDC_LOGOUT_URL ? { logoutUrl: value.OIDC_LOGOUT_URL } : {}),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
cookieName: value.SESSION_COOKIE_NAME,
|
||||||
|
idleTimeoutSeconds: value.SESSION_IDLE_TIMEOUT_SECONDS,
|
||||||
|
absoluteTimeoutSeconds: value.SESSION_ABSOLUTE_TIMEOUT_SECONDS,
|
||||||
|
secret: value.SESSION_SECRET,
|
||||||
|
encryptionKey: value.SESSION_ENCRYPTION_KEY,
|
||||||
|
},
|
||||||
|
corsOrigins: value.CORS_ORIGINS,
|
||||||
|
csrfHeaderName: value.CSRF_HEADER_NAME,
|
||||||
|
logLevel: value.LOG_LEVEL,
|
||||||
|
swaggerEnabled: value.SWAGGER_ENABLED,
|
||||||
|
rateLimit: {
|
||||||
|
global: {
|
||||||
|
windowSeconds: value.RATE_LIMIT_WINDOW_SECONDS,
|
||||||
|
maxRequests: value.RATE_LIMIT_MAX_REQUESTS,
|
||||||
|
},
|
||||||
|
sensitive: {
|
||||||
|
windowSeconds: value.RATE_LIMIT_SENSITIVE_WINDOW_SECONDS,
|
||||||
|
maxRequests: value.RATE_LIMIT_SENSITIVE_MAX_REQUESTS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfigForCli(): AppConfig {
|
||||||
|
loadDotenv({ path: envFilePaths, override: false, quiet: true });
|
||||||
|
return loadConfigFromEnv(process.env);
|
||||||
|
}
|
||||||
15
apps/backend/src/dashboard/dashboard.controller.ts
Normal file
15
apps/backend/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { Permission } from '../roles/permissions';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
|
@Controller('dashboard')
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(private readonly dashboard: DashboardService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.ItemsRead)
|
||||||
|
summary() {
|
||||||
|
return this.dashboard.summary();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/backend/src/dashboard/dashboard.module.ts
Normal file
9
apps/backend/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DashboardController } from './dashboard.controller';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [DashboardService],
|
||||||
|
})
|
||||||
|
export class DashboardModule {}
|
||||||
32
apps/backend/src/dashboard/dashboard.service.ts
Normal file
32
apps/backend/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { IsNull } from 'typeorm';
|
||||||
|
import { ItemEntity } from '../items/entities/item.entity';
|
||||||
|
import { RoleEntity } from '../roles/entities/role.entity';
|
||||||
|
import { SessionEntity } from '../sessions/entities/session.entity';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async summary(): Promise<{
|
||||||
|
userCount: number;
|
||||||
|
activeSessions: number;
|
||||||
|
roleCount: number;
|
||||||
|
itemCount: number;
|
||||||
|
}> {
|
||||||
|
const [userCount, activeSessions, roleCount, itemCount] = await Promise.all(
|
||||||
|
[
|
||||||
|
this.dataSource.getRepository(UserEntity).count(),
|
||||||
|
this.dataSource
|
||||||
|
.getRepository(SessionEntity)
|
||||||
|
.count({ where: { revokedAt: IsNull() } }),
|
||||||
|
this.dataSource.getRepository(RoleEntity).count(),
|
||||||
|
this.dataSource.getRepository(ItemEntity).count(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { userCount, activeSessions, roleCount, itemCount };
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/backend/src/database/database.module.ts
Normal file
8
apps/backend/src/database/database.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MigrationHealthService } from './migration-health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [MigrationHealthService],
|
||||||
|
exports: [MigrationHealthService],
|
||||||
|
})
|
||||||
|
export class DatabaseModule {}
|
||||||
21
apps/backend/src/database/entities.ts
Normal file
21
apps/backend/src/database/entities.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { AuditLogEntity } from '../audit/entities/audit-log.entity';
|
||||||
|
import { OidcLoginStateEntity } from '../auth/entities/oidc-login-state.entity';
|
||||||
|
import { ItemEntity } from '../items/entities/item.entity';
|
||||||
|
import { NotificationEntity } from '../notifications/entities/notification.entity';
|
||||||
|
import { RoleEntity } from '../roles/entities/role.entity';
|
||||||
|
import { PermissionEntity } from '../roles/entities/permission.entity';
|
||||||
|
import { SessionEntity } from '../sessions/entities/session.entity';
|
||||||
|
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
|
||||||
|
export const entities = [
|
||||||
|
AuditLogEntity,
|
||||||
|
OidcLoginStateEntity,
|
||||||
|
ItemEntity,
|
||||||
|
NotificationEntity,
|
||||||
|
RoleEntity,
|
||||||
|
PermissionEntity,
|
||||||
|
SessionEntity,
|
||||||
|
UserSettingsEntity,
|
||||||
|
UserEntity,
|
||||||
|
];
|
||||||
41
apps/backend/src/database/migration-health.service.ts
Normal file
41
apps/backend/src/database/migration-health.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MigrationHealthService {
|
||||||
|
private initialized = false;
|
||||||
|
private missingMigrations = false;
|
||||||
|
|
||||||
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async assertNoPendingMigrations(): Promise<void> {
|
||||||
|
const pending = await this.dataSource.showMigrations();
|
||||||
|
this.initialized = true;
|
||||||
|
this.missingMigrations = pending;
|
||||||
|
if (pending) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.MigrationMissing,
|
||||||
|
'Es fehlen Datenbankmigrationen. Fuehren Sie npm run migration:run aus.',
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isReady(): boolean {
|
||||||
|
return (
|
||||||
|
this.initialized &&
|
||||||
|
!this.missingMigrations &&
|
||||||
|
this.dataSource.isInitialized
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<void> {
|
||||||
|
if (!this.isReady()) {
|
||||||
|
throw new ServiceUnavailableException();
|
||||||
|
}
|
||||||
|
await this.dataSource.query('SELECT 1');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class InitialSchema1720000000000 implements MigrationInterface {
|
||||||
|
name = 'InitialSchema1720000000000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE permissions (
|
||||||
|
id varchar(80) NOT NULL,
|
||||||
|
description varchar(160) NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE roles (
|
||||||
|
id char(36) NOT NULL,
|
||||||
|
name varchar(80) NOT NULL,
|
||||||
|
description varchar(255) NOT NULL DEFAULT '',
|
||||||
|
protected tinyint NOT NULL DEFAULT 0,
|
||||||
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
UNIQUE KEY uq_roles_name (name),
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE role_permissions (
|
||||||
|
role_id char(36) NOT NULL,
|
||||||
|
permission_id varchar(80) NOT NULL,
|
||||||
|
PRIMARY KEY (role_id, permission_id),
|
||||||
|
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id char(36) NOT NULL,
|
||||||
|
issuer varchar(255) NOT NULL,
|
||||||
|
subject varchar(255) NOT NULL,
|
||||||
|
name varchar(255) NOT NULL,
|
||||||
|
email varchar(320) NULL,
|
||||||
|
active tinyint NOT NULL DEFAULT 1,
|
||||||
|
last_login_at datetime(3) NULL,
|
||||||
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
UNIQUE KEY uq_users_issuer_subject (issuer, subject),
|
||||||
|
KEY idx_users_issuer (issuer),
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE user_roles (
|
||||||
|
user_id char(36) NOT NULL,
|
||||||
|
role_id char(36) NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, role_id),
|
||||||
|
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE user_settings (
|
||||||
|
id char(36) NOT NULL,
|
||||||
|
user_id char(36) NOT NULL,
|
||||||
|
table_page_size int NOT NULL DEFAULT 20,
|
||||||
|
sidebar_expanded tinyint NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE KEY uq_user_settings_user (user_id),
|
||||||
|
CONSTRAINT fk_user_settings_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id char(64) NOT NULL,
|
||||||
|
user_id char(36) NOT NULL,
|
||||||
|
access_token_encrypted text NOT NULL,
|
||||||
|
refresh_token_encrypted text NULL,
|
||||||
|
id_token_encrypted text NULL,
|
||||||
|
csrf_token_hash char(64) NOT NULL,
|
||||||
|
access_token_expires_at datetime(3) NOT NULL,
|
||||||
|
expires_at datetime(3) NOT NULL,
|
||||||
|
absolute_expires_at datetime(3) NOT NULL,
|
||||||
|
last_activity_at datetime(3) NOT NULL,
|
||||||
|
user_agent varchar(512) NULL,
|
||||||
|
last_ip varchar(80) NULL,
|
||||||
|
revoked_at datetime(3) NULL,
|
||||||
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
KEY idx_sessions_user_id (user_id),
|
||||||
|
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE oidc_login_states (
|
||||||
|
state char(64) NOT NULL,
|
||||||
|
code_verifier varchar(160) NOT NULL,
|
||||||
|
nonce varchar(160) NOT NULL,
|
||||||
|
expires_at datetime(3) NOT NULL,
|
||||||
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (state)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id char(36) NOT NULL,
|
||||||
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
actor_user_id char(36) NULL,
|
||||||
|
action varchar(80) NOT NULL,
|
||||||
|
target_type varchar(80) NOT NULL,
|
||||||
|
target_id varchar(120) NOT NULL,
|
||||||
|
metadata json NULL,
|
||||||
|
request_id varchar(128) NOT NULL,
|
||||||
|
KEY idx_audit_created_at (created_at),
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE items (
|
||||||
|
id char(36) NOT NULL,
|
||||||
|
name varchar(160) NOT NULL,
|
||||||
|
description text NULL,
|
||||||
|
status varchar(30) NOT NULL DEFAULT 'draft',
|
||||||
|
version int NOT NULL DEFAULT 1,
|
||||||
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
deleted_at datetime(3) NULL,
|
||||||
|
KEY idx_items_name (name),
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TABLE items');
|
||||||
|
await queryRunner.query('DROP TABLE audit_logs');
|
||||||
|
await queryRunner.query('DROP TABLE oidc_login_states');
|
||||||
|
await queryRunner.query('DROP TABLE sessions');
|
||||||
|
await queryRunner.query('DROP TABLE user_settings');
|
||||||
|
await queryRunner.query('DROP TABLE user_roles');
|
||||||
|
await queryRunner.query('DROP TABLE users');
|
||||||
|
await queryRunner.query('DROP TABLE role_permissions');
|
||||||
|
await queryRunner.query('DROP TABLE roles');
|
||||||
|
await queryRunner.query('DROP TABLE permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddNotifications1720000001000 implements MigrationInterface {
|
||||||
|
name = 'AddNotifications1720000001000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id char(36) NOT NULL,
|
||||||
|
user_id char(36) NOT NULL,
|
||||||
|
type varchar(80) NOT NULL,
|
||||||
|
title varchar(150) NOT NULL,
|
||||||
|
message varchar(1000) NOT NULL,
|
||||||
|
link varchar(500) NULL,
|
||||||
|
metadata json NULL,
|
||||||
|
read_at datetime(3) NULL,
|
||||||
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
deleted_at datetime(3) NULL,
|
||||||
|
KEY idx_notifications_user_created_at (user_id, created_at),
|
||||||
|
KEY idx_notifications_user_read_at (user_id, read_at),
|
||||||
|
KEY idx_notifications_user_deleted_at (user_id, deleted_at),
|
||||||
|
CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TABLE notifications');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddRoleDescription1720000002000 implements MigrationInterface {
|
||||||
|
name = 'AddRoleDescription1720000002000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE roles
|
||||||
|
ADD description varchar(255) NOT NULL DEFAULT ''
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE roles DROP COLUMN description');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/backend/src/database/run-migrations.ts
Normal file
13
apps/backend/src/database/run-migrations.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import dataSource from './typeorm-cli.datasource';
|
||||||
|
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
await dataSource.initialize();
|
||||||
|
try {
|
||||||
|
await dataSource.runMigrations({ transaction: 'all' });
|
||||||
|
} finally {
|
||||||
|
await dataSource.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run();
|
||||||
29
apps/backend/src/database/typeorm-cli.datasource.ts
Normal file
29
apps/backend/src/database/typeorm-cli.datasource.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { loadConfigForCli } from '../config/env';
|
||||||
|
import { entities } from './entities';
|
||||||
|
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
|
||||||
|
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
|
||||||
|
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription';
|
||||||
|
|
||||||
|
const config = loadConfigForCli();
|
||||||
|
|
||||||
|
export default new DataSource({
|
||||||
|
type: 'mysql',
|
||||||
|
host: config.database.host,
|
||||||
|
port: config.database.port,
|
||||||
|
username: config.database.user,
|
||||||
|
password: config.database.password,
|
||||||
|
database: config.database.name,
|
||||||
|
charset: 'utf8mb4_unicode_ci',
|
||||||
|
timezone: 'Z',
|
||||||
|
ssl: config.database.ssl ? { rejectUnauthorized: true } : undefined,
|
||||||
|
synchronize: false,
|
||||||
|
migrationsRun: false,
|
||||||
|
entities,
|
||||||
|
migrations: [
|
||||||
|
InitialSchema1720000000000,
|
||||||
|
AddNotifications1720000001000,
|
||||||
|
AddRoleDescription1720000002000,
|
||||||
|
],
|
||||||
|
});
|
||||||
30
apps/backend/src/database/typeorm-options.ts
Normal file
30
apps/backend/src/database/typeorm-options.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
import type { AppConfigService } from '../config/config.service';
|
||||||
|
import { entities } from './entities';
|
||||||
|
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
|
||||||
|
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
|
||||||
|
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription';
|
||||||
|
|
||||||
|
export function typeOrmOptionsFactory(
|
||||||
|
config: AppConfigService,
|
||||||
|
): TypeOrmModuleOptions {
|
||||||
|
return {
|
||||||
|
type: 'mysql',
|
||||||
|
host: config.database.host,
|
||||||
|
port: config.database.port,
|
||||||
|
username: config.database.user,
|
||||||
|
password: config.database.password,
|
||||||
|
database: config.database.name,
|
||||||
|
charset: 'utf8mb4_unicode_ci',
|
||||||
|
timezone: 'Z',
|
||||||
|
ssl: config.database.ssl ? { rejectUnauthorized: true } : undefined,
|
||||||
|
synchronize: false,
|
||||||
|
migrationsRun: false,
|
||||||
|
entities,
|
||||||
|
migrations: [
|
||||||
|
InitialSchema1720000000000,
|
||||||
|
AddNotifications1720000001000,
|
||||||
|
AddRoleDescription1720000002000,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
21
apps/backend/src/health/health.controller.ts
Normal file
21
apps/backend/src/health/health.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { Public } from '../auth/guards/public.decorator';
|
||||||
|
import { MigrationHealthService } from '../database/migration-health.service';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(private readonly migrations: MigrationHealthService) {}
|
||||||
|
|
||||||
|
@Get('live')
|
||||||
|
@Public()
|
||||||
|
live() {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ready')
|
||||||
|
@Public()
|
||||||
|
async ready() {
|
||||||
|
await this.migrations.ping();
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/backend/src/health/health.module.ts
Normal file
9
apps/backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatabaseModule } from '../database/database.module';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
58
apps/backend/src/items/dto/item.dto.ts
Normal file
58
apps/backend/src/items/dto/item.dto.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ItemStatus } from '../entities/item.entity';
|
||||||
|
|
||||||
|
export class ItemListQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sort?: 'name' | 'status' | 'createdAt' | 'updatedAt';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
direction?: 'ASC' | 'DESC';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateItemDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 160)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 4000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsEnum(ItemStatus)
|
||||||
|
status!: ItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateItemDto extends CreateItemDto {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
version!: number;
|
||||||
|
}
|
||||||
49
apps/backend/src/items/entities/item.entity.ts
Normal file
49
apps/backend/src/items/entities/item.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
VersionColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export enum ItemStatus {
|
||||||
|
Draft = 'draft',
|
||||||
|
Active = 'active',
|
||||||
|
Archived = 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('items')
|
||||||
|
export class ItemEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index('idx_items_name')
|
||||||
|
@Column({ type: 'varchar', length: 160 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 30, default: ItemStatus.Draft })
|
||||||
|
status!: ItemStatus;
|
||||||
|
|
||||||
|
@VersionColumn({ type: 'int' })
|
||||||
|
version!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({
|
||||||
|
name: 'deleted_at',
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
deletedAt!: Date | null;
|
||||||
|
}
|
||||||
53
apps/backend/src/items/items.controller.ts
Normal file
53
apps/backend/src/items/items.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { Permission } from '../roles/permissions';
|
||||||
|
import { CreateItemDto, ItemListQueryDto, UpdateItemDto } from './dto/item.dto';
|
||||||
|
import { ItemsService } from './items.service';
|
||||||
|
|
||||||
|
@ApiTags('items')
|
||||||
|
@Controller('items')
|
||||||
|
export class ItemsController {
|
||||||
|
constructor(private readonly items: ItemsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.ItemsRead)
|
||||||
|
list(@Query() query: ItemListQueryDto) {
|
||||||
|
return this.items.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permission.ItemsRead)
|
||||||
|
get(@Param('id') id: string) {
|
||||||
|
return this.items.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permission.ItemsCreate)
|
||||||
|
create(@Req() req: AuthenticatedRequest, @Body() dto: CreateItemDto) {
|
||||||
|
return this.items.create(dto, req.user?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@RequirePermissions(Permission.ItemsUpdate)
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateItemDto) {
|
||||||
|
return this.items.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permission.ItemsDelete)
|
||||||
|
delete(@Param('id') id: string, @Query('version') version: number) {
|
||||||
|
return this.items.delete(id, Number(version));
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/backend/src/items/items.module.ts
Normal file
19
apps/backend/src/items/items.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import { ItemEntity } from './entities/item.entity';
|
||||||
|
import { ItemsController } from './items.controller';
|
||||||
|
import { ItemsService } from './items.service';
|
||||||
|
import { ItemsRepository } from './repositories/items.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ItemEntity, UserEntity]),
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
|
controllers: [ItemsController],
|
||||||
|
providers: [ItemsService, ItemsRepository, UsersRepository],
|
||||||
|
})
|
||||||
|
export class ItemsModule {}
|
||||||
118
apps/backend/src/items/items.service.ts
Normal file
118
apps/backend/src/items/items.service.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import type { PageDto } from '../common/dto/pagination.dto';
|
||||||
|
import { NotificationType } from '../notifications/notification-types';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import { ItemEntity } from './entities/item.entity';
|
||||||
|
import type {
|
||||||
|
CreateItemDto,
|
||||||
|
ItemListQueryDto,
|
||||||
|
UpdateItemDto,
|
||||||
|
} from './dto/item.dto';
|
||||||
|
import {
|
||||||
|
ItemsRepository,
|
||||||
|
type ItemSortField,
|
||||||
|
} from './repositories/items.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ItemsService {
|
||||||
|
private readonly logger = new Logger(ItemsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly items: ItemsRepository,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly users: UsersRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(query: ItemListQueryDto): Promise<PageDto<ItemEntity>> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const sort: ItemSortField = query.sort ?? 'updatedAt';
|
||||||
|
const direction = query.direction ?? 'DESC';
|
||||||
|
const [items, total] = await this.items.list(
|
||||||
|
query.search,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<ItemEntity> {
|
||||||
|
const item = await this.items.findById(id);
|
||||||
|
if (!item) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.NotFound,
|
||||||
|
'Der Eintrag wurde nicht gefunden.',
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateItemDto, actorUserId?: string): Promise<ItemEntity> {
|
||||||
|
const item = new ItemEntity();
|
||||||
|
item.name = dto.name;
|
||||||
|
item.description = dto.description ?? null;
|
||||||
|
item.status = dto.status;
|
||||||
|
const saved = await this.items.save(item);
|
||||||
|
await this.notifyFirstAdminAboutCreatedItem(saved, actorUserId);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateItemDto): Promise<ItemEntity> {
|
||||||
|
const item = await this.get(id);
|
||||||
|
if (item.version !== dto.version) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Conflict,
|
||||||
|
'Der Eintrag wurde zwischenzeitlich geaendert. Bitte laden Sie ihn neu.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
item.name = dto.name;
|
||||||
|
item.description = dto.description ?? null;
|
||||||
|
item.status = dto.status;
|
||||||
|
return this.items.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, version: number): Promise<void> {
|
||||||
|
const item = await this.get(id);
|
||||||
|
if (item.version !== version) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Conflict,
|
||||||
|
'Der Eintrag wurde zwischenzeitlich geaendert. Bitte laden Sie ihn neu.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.items.softDelete(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyFirstAdminAboutCreatedItem(
|
||||||
|
item: ItemEntity,
|
||||||
|
actorUserId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const admin = await this.users.findFirstActiveAdmin(actorUserId);
|
||||||
|
if (!admin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.notifications.createForUser({
|
||||||
|
userId: admin.id,
|
||||||
|
type: NotificationType.ItemCreated,
|
||||||
|
title: 'Neuer Eintrag',
|
||||||
|
message: `Der Eintrag "${item.name}" wurde erstellt.`,
|
||||||
|
link: `/items/${item.id}`,
|
||||||
|
metadata: { itemId: item.id },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
{ itemId: item.id, error },
|
||||||
|
'Failed to create item notification',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/backend/src/items/repositories/items.repository.ts
Normal file
45
apps/backend/src/items/repositories/items.repository.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ItemEntity } from '../entities/item.entity';
|
||||||
|
|
||||||
|
export type ItemSortField = 'name' | 'status' | 'createdAt' | 'updatedAt';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ItemsRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ItemEntity) private readonly repo: Repository<ItemEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(
|
||||||
|
search: string | undefined,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
sort: ItemSortField,
|
||||||
|
direction: 'ASC' | 'DESC',
|
||||||
|
): Promise<[ItemEntity[], number]> {
|
||||||
|
const qb = this.repo.createQueryBuilder('item');
|
||||||
|
if (search) {
|
||||||
|
qb.where('item.name LIKE :search OR item.description LIKE :search', {
|
||||||
|
search: `%${search}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return qb
|
||||||
|
.orderBy(`item.${sort}`, direction)
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string): Promise<ItemEntity | null> {
|
||||||
|
return this.repo.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(item: ItemEntity): Promise<ItemEntity> {
|
||||||
|
return this.repo.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(item: ItemEntity): Promise<void> {
|
||||||
|
await this.repo.softRemove(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/backend/src/items/tests/items.service.spec.ts
Normal file
63
apps/backend/src/items/tests/items.service.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ErrorCode } from '../../common/errors/error-codes';
|
||||||
|
import { ItemEntity, ItemStatus } from '../entities/item.entity';
|
||||||
|
import { ItemsService } from '../items.service';
|
||||||
|
import type { NotificationsService } from '../../notifications/notifications.service';
|
||||||
|
import type { UsersRepository } from '../../users/repositories/users.repository';
|
||||||
|
import type { ItemsRepository } from '../repositories/items.repository';
|
||||||
|
|
||||||
|
function item(version = 2): ItemEntity {
|
||||||
|
const entity = new ItemEntity();
|
||||||
|
entity.id = 'item-1';
|
||||||
|
entity.name = 'Alt';
|
||||||
|
entity.description = null;
|
||||||
|
entity.status = ItemStatus.Draft;
|
||||||
|
entity.version = version;
|
||||||
|
entity.createdAt = new Date();
|
||||||
|
entity.updatedAt = new Date();
|
||||||
|
entity.deletedAt = null;
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ItemsService', () => {
|
||||||
|
it('answers stale updates with HTTP 409 conflict semantics', async () => {
|
||||||
|
const repo: Pick<ItemsRepository, 'findById' | 'save'> = {
|
||||||
|
findById: () => Promise.resolve(item(3)),
|
||||||
|
save: (entity) => Promise.resolve(entity),
|
||||||
|
};
|
||||||
|
const service = new ItemsService(
|
||||||
|
repo as ItemsRepository,
|
||||||
|
{} as NotificationsService,
|
||||||
|
{} as UsersRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('item-1', {
|
||||||
|
name: 'Neu',
|
||||||
|
description: 'Beschreibung',
|
||||||
|
status: ItemStatus.Active,
|
||||||
|
version: 2,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: ErrorCode.Conflict, status: 409 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft deletes only when the submitted version is current', async () => {
|
||||||
|
let deleted = false;
|
||||||
|
const repo: Pick<ItemsRepository, 'findById' | 'softDelete'> = {
|
||||||
|
findById: () => Promise.resolve(item(4)),
|
||||||
|
softDelete: () => {
|
||||||
|
deleted = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = new ItemsService(
|
||||||
|
repo as ItemsRepository,
|
||||||
|
{} as NotificationsService,
|
||||||
|
{} as UsersRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.delete('item-1', 4);
|
||||||
|
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
99
apps/backend/src/main.ts
Normal file
99
apps/backend/src/main.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import pinoHttp from 'pino-http';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { createValidationPipe } from './common/security/validation.pipe';
|
||||||
|
import { AppConfigService } from './config/config.service';
|
||||||
|
import { MigrationHealthService } from './database/migration-health.service';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
bufferLogs: true,
|
||||||
|
});
|
||||||
|
const config = app.get(AppConfigService);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
pinoHttp({
|
||||||
|
level: config.logLevel,
|
||||||
|
...(config.isProduction ? {} : { transport: { target: 'pino-pretty' } }),
|
||||||
|
redact: {
|
||||||
|
paths: [
|
||||||
|
'req.headers.authorization',
|
||||||
|
'req.headers.cookie',
|
||||||
|
'req.headers["x-csrf-token"]',
|
||||||
|
'req.headers["x-session-id"]',
|
||||||
|
'res.headers["set-cookie"]',
|
||||||
|
'*.access_token',
|
||||||
|
'*.refresh_token',
|
||||||
|
'*.id_token',
|
||||||
|
],
|
||||||
|
censor: '[redacted]',
|
||||||
|
},
|
||||||
|
customProps: (req) => ({
|
||||||
|
requestId: req.headers['x-request-id'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(cookieParser(config.session.secret));
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
imgSrc: ["'self'", 'data:'],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.set('trust proxy', config.trustProxy);
|
||||||
|
app.enableCors({
|
||||||
|
origin: config.corsOrigins,
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', config.csrfHeaderName, 'X-Request-ID'],
|
||||||
|
});
|
||||||
|
app.setGlobalPrefix('api', {
|
||||||
|
exclude: ['health/live', 'health/ready'],
|
||||||
|
});
|
||||||
|
app.useGlobalPipes(createValidationPipe());
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
|
if (config.swaggerEnabled) {
|
||||||
|
const document = SwaggerModule.createDocument(
|
||||||
|
app,
|
||||||
|
new DocumentBuilder()
|
||||||
|
.setTitle('Business App API')
|
||||||
|
.setDescription('Interne Business-Anwendung')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
jsonDocumentUrl: '/api/docs-json',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.useStaticAssets(join(__dirname, '..', 'public'), {
|
||||||
|
index: false,
|
||||||
|
fallthrough: true,
|
||||||
|
});
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (req.url.startsWith('/api') || req.url.startsWith('/health')) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.sendFile(join(__dirname, '..', 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.get(MigrationHealthService).assertNoPendingMigrations();
|
||||||
|
await app.listen(config.port);
|
||||||
|
}
|
||||||
|
void bootstrap();
|
||||||
78
apps/backend/src/notifications/dto/notification.dto.ts
Normal file
78
apps/backend/src/notifications/dto/notification.dto.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Length,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { allNotificationTypes } from '../notification-types';
|
||||||
|
import type { NotificationMetadata } from '../entities/notification.entity';
|
||||||
|
|
||||||
|
export type NotificationStatusFilter = 'all' | 'read' | 'unread';
|
||||||
|
|
||||||
|
export class NotificationListQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize = 20;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['all', 'read', 'unread'])
|
||||||
|
status: NotificationStatusFilter = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateNotificationDto {
|
||||||
|
@IsUUID()
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(allNotificationTypes)
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 150)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 1000)
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 500)
|
||||||
|
link?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: NotificationMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationDto {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
link: string | null;
|
||||||
|
metadata: NotificationMetadata | null;
|
||||||
|
read: boolean;
|
||||||
|
readAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPageDto {
|
||||||
|
items: NotificationDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UserEntity } from '../../users/entities/user.entity';
|
||||||
|
import type { NotificationType } from '../notification-types';
|
||||||
|
|
||||||
|
export type NotificationMetadata = Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | null
|
||||||
|
>;
|
||||||
|
|
||||||
|
@Entity('notifications')
|
||||||
|
@Index('idx_notifications_user_created_at', ['userId', 'createdAt'])
|
||||||
|
@Index('idx_notifications_user_read_at', ['userId', 'readAt'])
|
||||||
|
@Index('idx_notifications_user_deleted_at', ['userId', 'deletedAt'])
|
||||||
|
export class NotificationEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user!: UserEntity;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', type: 'char', length: 36 })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80 })
|
||||||
|
type!: NotificationType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 150 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 1000 })
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||||
|
link!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
metadata!: NotificationMetadata | null;
|
||||||
|
|
||||||
|
@Column({ name: 'read_at', type: 'datetime', precision: 3, nullable: true })
|
||||||
|
readAt!: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({
|
||||||
|
name: 'deleted_at',
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
deletedAt!: Date | null;
|
||||||
|
}
|
||||||
15
apps/backend/src/notifications/notification-types.ts
Normal file
15
apps/backend/src/notifications/notification-types.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export const NotificationType = {
|
||||||
|
System: 'system',
|
||||||
|
ItemCreated: 'item.created',
|
||||||
|
ItemUpdated: 'item.updated',
|
||||||
|
UserRoleChanged: 'user.role-changed',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
(typeof NotificationType)[keyof typeof NotificationType];
|
||||||
|
|
||||||
|
export const allNotificationTypes = Object.values(NotificationType);
|
||||||
|
|
||||||
|
export function isNotificationType(value: string): value is NotificationType {
|
||||||
|
return allNotificationTypes.includes(value as NotificationType);
|
||||||
|
}
|
||||||
95
apps/backend/src/notifications/notifications.controller.ts
Normal file
95
apps/backend/src/notifications/notifications.controller.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||||
|
import { Permission } from '../roles/permissions';
|
||||||
|
import {
|
||||||
|
CreateNotificationDto,
|
||||||
|
NotificationListQueryDto,
|
||||||
|
} from './dto/notification.dto';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(private readonly notifications: NotificationsService) {}
|
||||||
|
|
||||||
|
@Get('notifications')
|
||||||
|
@RequirePermissions(Permission.NotificationsReadOwn)
|
||||||
|
list(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Query() query: NotificationListQueryDto,
|
||||||
|
) {
|
||||||
|
return this.notifications.getForCurrentUser(
|
||||||
|
this.requireUser(req).id,
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('notifications/unread-count')
|
||||||
|
@RequirePermissions(Permission.NotificationsReadOwn)
|
||||||
|
async unreadCount(@Req() req: AuthenticatedRequest) {
|
||||||
|
return {
|
||||||
|
count: await this.notifications.getUnreadCount(this.requireUser(req).id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('notifications/:id/read')
|
||||||
|
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||||
|
markAsRead(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.notifications.markAsRead(this.requireUser(req).id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('notifications/:id/unread')
|
||||||
|
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||||
|
markAsUnread(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.notifications.markAsUnread(this.requireUser(req).id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('notifications/read-all')
|
||||||
|
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||||
|
markAllAsRead(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.notifications.markAllAsRead(this.requireUser(req).id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('notifications/:id')
|
||||||
|
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||||
|
delete(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.notifications.softDelete(this.requireUser(req).id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/notifications')
|
||||||
|
@RequirePermissions(Permission.NotificationsManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
createAdmin(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Body() dto: CreateNotificationDto,
|
||||||
|
) {
|
||||||
|
return this.notifications.createAdminNotification(
|
||||||
|
this.requireUser(req).id,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireUser(req: AuthenticatedRequest) {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'Bitte melden Sie sich an.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return req.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/backend/src/notifications/notifications.module.ts
Normal file
20
apps/backend/src/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import { NotificationEntity } from './entities/notification.entity';
|
||||||
|
import { NotificationsController } from './notifications.controller';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { NotificationsRepository } from './repositories/notifications.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([NotificationEntity, UserEntity]),
|
||||||
|
AuditModule,
|
||||||
|
],
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [NotificationsRepository, NotificationsService, UsersRepository],
|
||||||
|
exports: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
287
apps/backend/src/notifications/notifications.service.ts
Normal file
287
apps/backend/src/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/entities/audit-log.entity';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import {
|
||||||
|
NotificationEntity,
|
||||||
|
type NotificationMetadata,
|
||||||
|
} from './entities/notification.entity';
|
||||||
|
import {
|
||||||
|
type NotificationDto,
|
||||||
|
type NotificationListQueryDto,
|
||||||
|
type NotificationPageDto,
|
||||||
|
type NotificationStatusFilter,
|
||||||
|
} from './dto/notification.dto';
|
||||||
|
import {
|
||||||
|
isNotificationType,
|
||||||
|
type NotificationType,
|
||||||
|
} from './notification-types';
|
||||||
|
import { NotificationsRepository } from './repositories/notifications.repository';
|
||||||
|
|
||||||
|
export interface CreateNotificationInput {
|
||||||
|
userId: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
link?: string | null;
|
||||||
|
metadata?: NotificationMetadata | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBulkCreate = 100;
|
||||||
|
const maxMetadataBytes = 4096;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsService {
|
||||||
|
private readonly logger = new Logger(NotificationsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly notifications: NotificationsRepository,
|
||||||
|
private readonly users: UsersRepository,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createForUser(
|
||||||
|
input: CreateNotificationInput,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<NotificationEntity> {
|
||||||
|
const user = await this.findActiveUser(input.userId, manager);
|
||||||
|
const notification = this.buildNotification(user.id, input);
|
||||||
|
return this.notifications.save(notification, manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createForUsers(
|
||||||
|
userIds: string[],
|
||||||
|
input: Omit<CreateNotificationInput, 'userId'>,
|
||||||
|
): Promise<NotificationEntity[]> {
|
||||||
|
const uniqueUserIds = Array.from(new Set(userIds));
|
||||||
|
if (uniqueUserIds.length > maxBulkCreate) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ count: uniqueUserIds.length },
|
||||||
|
'Large notification bulk create rejected',
|
||||||
|
);
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.ValidationFailed,
|
||||||
|
'Zu viele Zielbenutzer fuer eine Benachrichtigung.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const notifications: NotificationEntity[] = [];
|
||||||
|
for (const userId of uniqueUserIds) {
|
||||||
|
const user = await this.findActiveUser(userId, manager);
|
||||||
|
notifications.push(
|
||||||
|
this.buildNotification(user.id, { ...input, userId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.notifications.saveMany(notifications, manager);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getForCurrentUser(
|
||||||
|
userId: string,
|
||||||
|
query: NotificationListQueryDto,
|
||||||
|
): Promise<NotificationPageDto> {
|
||||||
|
const page = query.page;
|
||||||
|
const pageSize = Math.min(query.pageSize, 100);
|
||||||
|
const status: NotificationStatusFilter = query.status ?? 'all';
|
||||||
|
const [items, total] = await this.notifications.listForUser(
|
||||||
|
userId,
|
||||||
|
status,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
);
|
||||||
|
const unreadCount = await this.getUnreadCount(userId);
|
||||||
|
return {
|
||||||
|
items: items.map((item) => this.toDto(item)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
unreadCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(userId: string): Promise<number> {
|
||||||
|
return this.notifications.countUnreadForUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(userId: string, id: string): Promise<NotificationDto> {
|
||||||
|
const notification = await this.getOwnedNotification(userId, id);
|
||||||
|
notification.readAt ??= new Date();
|
||||||
|
return this.toDto(await this.notifications.save(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsUnread(userId: string, id: string): Promise<NotificationDto> {
|
||||||
|
const notification = await this.getOwnedNotification(userId, id);
|
||||||
|
notification.readAt = null;
|
||||||
|
return this.toDto(await this.notifications.save(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsRead(userId: string): Promise<{ updated: number }> {
|
||||||
|
return { updated: await this.notifications.markAllAsRead(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(userId: string, id: string): Promise<void> {
|
||||||
|
const notification = await this.getOwnedNotification(userId, id);
|
||||||
|
await this.notifications.softDelete(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdminNotification(
|
||||||
|
actorUserId: string,
|
||||||
|
input: CreateNotificationInput,
|
||||||
|
): Promise<NotificationDto> {
|
||||||
|
const notification = await this.createForUser(input);
|
||||||
|
await this.audit.record(
|
||||||
|
actorUserId,
|
||||||
|
AuditAction.NotificationCreated,
|
||||||
|
'notification',
|
||||||
|
notification.id,
|
||||||
|
{
|
||||||
|
targetUserId: input.userId,
|
||||||
|
notificationId: notification.id,
|
||||||
|
type: notification.type,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return this.toDto(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOwnedNotification(
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<NotificationEntity> {
|
||||||
|
const notification = await this.notifications.findActiveForUser(id, userId);
|
||||||
|
if (!notification) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.NotificationNotFound,
|
||||||
|
'Die Benachrichtigung wurde nicht gefunden.',
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findActiveUser(
|
||||||
|
userId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<UserEntity> {
|
||||||
|
const user = await this.users.findById(userId, manager);
|
||||||
|
if (!user || !user.active) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.UserNotFound,
|
||||||
|
'Der Zielbenutzer wurde nicht gefunden.',
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildNotification(
|
||||||
|
userId: string,
|
||||||
|
input: CreateNotificationInput,
|
||||||
|
): NotificationEntity {
|
||||||
|
const type = this.normalizeType(input.type);
|
||||||
|
const title = this.normalizePlainText(input.title, 150);
|
||||||
|
const message = this.normalizePlainText(input.message, 1000);
|
||||||
|
const link = this.normalizeLink(input.link ?? null);
|
||||||
|
const metadata = this.normalizeMetadata(input.metadata ?? null);
|
||||||
|
|
||||||
|
const notification = new NotificationEntity();
|
||||||
|
notification.userId = userId;
|
||||||
|
notification.type = type;
|
||||||
|
notification.title = title;
|
||||||
|
notification.message = message;
|
||||||
|
notification.link = link;
|
||||||
|
notification.metadata = metadata;
|
||||||
|
notification.readAt = null;
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeType(value: string): NotificationType {
|
||||||
|
if (!isNotificationType(value)) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.NotificationTypeInvalid,
|
||||||
|
'Der Benachrichtigungstyp ist ungueltig.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePlainText(value: string, maxLength: number): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (
|
||||||
|
trimmed.length < 1 ||
|
||||||
|
trimmed.length > maxLength ||
|
||||||
|
/<[^>]*>|[<>]/.test(trimmed)
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.ValidationFailed,
|
||||||
|
'Benachrichtigungen duerfen nur Plain Text enthalten.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLink(value: string | null): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (
|
||||||
|
trimmed.length > 500 ||
|
||||||
|
!trimmed.startsWith('/') ||
|
||||||
|
trimmed.startsWith('//') ||
|
||||||
|
trimmed.includes('\\') ||
|
||||||
|
lower.includes('javascript:') ||
|
||||||
|
/^[a-z][a-z0-9+.-]*:/i.test(trimmed)
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.NotificationLinkInvalid,
|
||||||
|
'Der Link muss eine interne Route sein.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMetadata(
|
||||||
|
value: NotificationMetadata | null,
|
||||||
|
): NotificationMetadata | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
if (
|
||||||
|
!serialized ||
|
||||||
|
Buffer.byteLength(serialized, 'utf8') > maxMetadataBytes
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.NotificationMetadataTooLarge,
|
||||||
|
'Die Metadaten sind zu gross.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(notification: NotificationEntity): NotificationDto {
|
||||||
|
return {
|
||||||
|
id: notification.id,
|
||||||
|
type: notification.type,
|
||||||
|
title: notification.title,
|
||||||
|
message: notification.message,
|
||||||
|
link: notification.link,
|
||||||
|
metadata: notification.metadata,
|
||||||
|
read: notification.readAt !== null,
|
||||||
|
readAt: notification.readAt?.toISOString() ?? null,
|
||||||
|
createdAt: notification.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { EntityManager, IsNull, Repository } from 'typeorm';
|
||||||
|
import { NotificationEntity } from '../entities/notification.entity';
|
||||||
|
import type { NotificationStatusFilter } from '../dto/notification.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(NotificationEntity)
|
||||||
|
private readonly repo: Repository<NotificationEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listForUser(
|
||||||
|
userId: string,
|
||||||
|
status: NotificationStatusFilter,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<[NotificationEntity[], number]> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('notification')
|
||||||
|
.where('notification.userId = :userId', { userId })
|
||||||
|
.andWhere('notification.deletedAt IS NULL');
|
||||||
|
if (status === 'read') {
|
||||||
|
qb.andWhere('notification.readAt IS NOT NULL');
|
||||||
|
}
|
||||||
|
if (status === 'unread') {
|
||||||
|
qb.andWhere('notification.readAt IS NULL');
|
||||||
|
}
|
||||||
|
return qb
|
||||||
|
.orderBy('notification.createdAt', 'DESC')
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
countUnreadForUser(userId: string): Promise<number> {
|
||||||
|
return this.repo.count({
|
||||||
|
where: { userId, readAt: IsNull(), deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findActiveForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<NotificationEntity | null> {
|
||||||
|
return this.repo.findOne({ where: { id, userId, deletedAt: IsNull() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(
|
||||||
|
notification: NotificationEntity,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<NotificationEntity> {
|
||||||
|
return (manager?.getRepository(NotificationEntity) ?? this.repo).save(
|
||||||
|
notification,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMany(
|
||||||
|
notifications: NotificationEntity[],
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<NotificationEntity[]> {
|
||||||
|
return (manager?.getRepository(NotificationEntity) ?? this.repo).save(
|
||||||
|
notifications,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsRead(userId: string, readAt = new Date()): Promise<number> {
|
||||||
|
const result = await this.repo.update(
|
||||||
|
{ userId, readAt: IsNull(), deletedAt: IsNull() },
|
||||||
|
{ readAt },
|
||||||
|
);
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(notification: NotificationEntity): Promise<void> {
|
||||||
|
await this.repo.softRemove(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AuditService } from '../../audit/audit.service';
|
||||||
|
import type { AuthenticatedUser } from '../../auth/authenticated-request';
|
||||||
|
import { ItemStatus } from '../../items/entities/item.entity';
|
||||||
|
import { ItemsService } from '../../items/items.service';
|
||||||
|
import { NotificationType } from '../notification-types';
|
||||||
|
import { UsersService } from '../../users/users.service';
|
||||||
|
import type { ItemsRepository } from '../../items/repositories/items.repository';
|
||||||
|
import type { NotificationsService } from '../notifications.service';
|
||||||
|
import type { RolesService } from '../../roles/roles.service';
|
||||||
|
import type { SessionsService } from '../../sessions/sessions.service';
|
||||||
|
import type { UsersRepository } from '../../users/repositories/users.repository';
|
||||||
|
import type { DataSource, EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
describe('Notification integrations', () => {
|
||||||
|
it('creates an item.created notification for the first active admin', async () => {
|
||||||
|
const created: unknown[] = [];
|
||||||
|
const items: Pick<ItemsRepository, 'save'> = {
|
||||||
|
save: (item) => {
|
||||||
|
item.id = 'item-1';
|
||||||
|
item.createdAt = new Date();
|
||||||
|
item.updatedAt = new Date();
|
||||||
|
item.deletedAt = null;
|
||||||
|
return Promise.resolve(item);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const notifications: Pick<NotificationsService, 'createForUser'> = {
|
||||||
|
createForUser: (input) => {
|
||||||
|
created.push(input);
|
||||||
|
return Promise.resolve(
|
||||||
|
{} as Awaited<ReturnType<NotificationsService['createForUser']>>,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const users: Pick<UsersRepository, 'findFirstActiveAdmin'> = {
|
||||||
|
findFirstActiveAdmin: () =>
|
||||||
|
Promise.resolve({ id: 'admin-1' } as Awaited<
|
||||||
|
ReturnType<UsersRepository['findFirstActiveAdmin']>
|
||||||
|
>),
|
||||||
|
};
|
||||||
|
const service = new ItemsService(
|
||||||
|
items as ItemsRepository,
|
||||||
|
notifications as NotificationsService,
|
||||||
|
users as UsersRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.create(
|
||||||
|
{ name: 'Beispiel', status: ItemStatus.Active },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(created).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'admin-1',
|
||||||
|
type: NotificationType.ItemCreated,
|
||||||
|
link: '/items/item-1',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a user.role-changed notification after role changes', async () => {
|
||||||
|
const created: unknown[] = [];
|
||||||
|
const user = {
|
||||||
|
id: 'user-1',
|
||||||
|
active: true,
|
||||||
|
roles: [{ id: 'role-user', name: 'user' }],
|
||||||
|
};
|
||||||
|
const users: Pick<UsersRepository, 'findByIdWithRoles' | 'save'> = {
|
||||||
|
findByIdWithRoles: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
user as Awaited<ReturnType<UsersRepository['findByIdWithRoles']>>,
|
||||||
|
),
|
||||||
|
save: (entry) => Promise.resolve(entry),
|
||||||
|
};
|
||||||
|
const roles: Pick<RolesService, 'getRole'> = {
|
||||||
|
getRole: (id) =>
|
||||||
|
Promise.resolve({
|
||||||
|
id,
|
||||||
|
name: id === 'role-editor' ? 'Editor' : 'user',
|
||||||
|
} as Awaited<ReturnType<RolesService['getRole']>>),
|
||||||
|
};
|
||||||
|
const notifications: Pick<NotificationsService, 'createForUser'> = {
|
||||||
|
createForUser: (input) => {
|
||||||
|
created.push(input);
|
||||||
|
return Promise.resolve(
|
||||||
|
{} as Awaited<ReturnType<NotificationsService['createForUser']>>,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const manager = {
|
||||||
|
query: () => Promise.resolve(),
|
||||||
|
} as unknown as EntityManager;
|
||||||
|
const dataSource = {
|
||||||
|
transaction: <T>(action: (manager: EntityManager) => Promise<T>) =>
|
||||||
|
action(manager),
|
||||||
|
} as DataSource;
|
||||||
|
const service = new UsersService(
|
||||||
|
users as UsersRepository,
|
||||||
|
roles as RolesService,
|
||||||
|
{} as SessionsService,
|
||||||
|
{ record: () => Promise.resolve() } as unknown as AuditService,
|
||||||
|
notifications as NotificationsService,
|
||||||
|
dataSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.setRoles(
|
||||||
|
{
|
||||||
|
id: 'admin-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
permissions: [],
|
||||||
|
} satisfies AuthenticatedUser,
|
||||||
|
'user-1',
|
||||||
|
['role-user', 'role-editor'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(created).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
type: NotificationType.UserRoleChanged,
|
||||||
|
message: 'Ihnen wurde die Rolle "Editor" zugewiesen.',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AuditService } from '../../audit/audit.service';
|
||||||
|
import { PermissionsGuard } from '../../auth/guards/permissions.guard';
|
||||||
|
import { ErrorCode } from '../../common/errors/error-codes';
|
||||||
|
import { NotificationEntity } from '../entities/notification.entity';
|
||||||
|
import { NotificationType } from '../notification-types';
|
||||||
|
import { NotificationsController } from '../notifications.controller';
|
||||||
|
import { NotificationsService } from '../notifications.service';
|
||||||
|
import type { AppConfigService } from '../../config/config.service';
|
||||||
|
import type { SessionsService } from '../../sessions/sessions.service';
|
||||||
|
import type { UsersRepository } from '../../users/repositories/users.repository';
|
||||||
|
import type { NotificationsRepository } from '../repositories/notifications.repository';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
const now = new Date('2026-07-16T08:00:00.000Z');
|
||||||
|
|
||||||
|
function notification(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
readAt: Date | null = null,
|
||||||
|
): NotificationEntity {
|
||||||
|
const entity = new NotificationEntity();
|
||||||
|
entity.id = id;
|
||||||
|
entity.userId = userId;
|
||||||
|
entity.type = NotificationType.System;
|
||||||
|
entity.title = 'Titel';
|
||||||
|
entity.message = 'Nachricht';
|
||||||
|
entity.link = '/';
|
||||||
|
entity.metadata = null;
|
||||||
|
entity.readAt = readAt;
|
||||||
|
entity.createdAt = now;
|
||||||
|
entity.deletedAt = null;
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceWithStore(store: NotificationEntity[]) {
|
||||||
|
const repo: Pick<
|
||||||
|
NotificationsRepository,
|
||||||
|
| 'listForUser'
|
||||||
|
| 'countUnreadForUser'
|
||||||
|
| 'findActiveForUser'
|
||||||
|
| 'save'
|
||||||
|
| 'saveMany'
|
||||||
|
| 'markAllAsRead'
|
||||||
|
| 'softDelete'
|
||||||
|
> = {
|
||||||
|
listForUser: (userId, status, page, pageSize) => {
|
||||||
|
const filtered = store.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.userId === userId &&
|
||||||
|
entry.deletedAt === null &&
|
||||||
|
(status === 'all' ||
|
||||||
|
(status === 'read' && entry.readAt !== null) ||
|
||||||
|
(status === 'unread' && entry.readAt === null)),
|
||||||
|
);
|
||||||
|
return Promise.resolve([
|
||||||
|
filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||||
|
filtered.length,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
countUnreadForUser: (userId) =>
|
||||||
|
Promise.resolve(
|
||||||
|
store.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.userId === userId &&
|
||||||
|
entry.readAt === null &&
|
||||||
|
entry.deletedAt === null,
|
||||||
|
).length,
|
||||||
|
),
|
||||||
|
findActiveForUser: (id, userId) =>
|
||||||
|
Promise.resolve(
|
||||||
|
store.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.id === id &&
|
||||||
|
entry.userId === userId &&
|
||||||
|
entry.deletedAt === null,
|
||||||
|
) ?? null,
|
||||||
|
),
|
||||||
|
save: (entry) => {
|
||||||
|
entry.createdAt ??= now;
|
||||||
|
const index = store.findIndex((candidate) => candidate.id === entry.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
store[index] = entry;
|
||||||
|
} else {
|
||||||
|
entry.id = entry.id || `notification-${store.length + 1}`;
|
||||||
|
store.push(entry);
|
||||||
|
}
|
||||||
|
return Promise.resolve(entry);
|
||||||
|
},
|
||||||
|
saveMany: (entries) => Promise.resolve(entries),
|
||||||
|
markAllAsRead: (userId) => {
|
||||||
|
let updated = 0;
|
||||||
|
for (const entry of store) {
|
||||||
|
if (
|
||||||
|
entry.userId === userId &&
|
||||||
|
entry.readAt === null &&
|
||||||
|
entry.deletedAt === null
|
||||||
|
) {
|
||||||
|
entry.readAt = now;
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(updated);
|
||||||
|
},
|
||||||
|
softDelete: (entry) => {
|
||||||
|
entry.deletedAt = now;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const users: Pick<UsersRepository, 'findById'> = {
|
||||||
|
findById: (id) =>
|
||||||
|
Promise.resolve({
|
||||||
|
id,
|
||||||
|
active: id !== 'disabled-user',
|
||||||
|
} as Awaited<ReturnType<UsersRepository['findById']>>),
|
||||||
|
};
|
||||||
|
const dataSource = {
|
||||||
|
transaction: async <T>(callback: (manager: never) => Promise<T>) =>
|
||||||
|
callback(undefined as never),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
const service = new NotificationsService(
|
||||||
|
repo as NotificationsRepository,
|
||||||
|
users as UsersRepository,
|
||||||
|
{} as AuditService,
|
||||||
|
dataSource,
|
||||||
|
);
|
||||||
|
return { service, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NotificationsService', () => {
|
||||||
|
it('returns only current user notifications with pagination and status filter', async () => {
|
||||||
|
const { service } = serviceWithStore([
|
||||||
|
notification('own-unread', 'user-1'),
|
||||||
|
notification('own-read', 'user-1', now),
|
||||||
|
notification('other', 'user-2'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getForCurrentUser('user-1', {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
status: 'unread',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items.map((entry) => entry.id)).toEqual(['own-unread']);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.unreadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reveal or mutate foreign notification ids', async () => {
|
||||||
|
const { service } = serviceWithStore([notification('foreign', 'user-2')]);
|
||||||
|
|
||||||
|
await expect(service.markAsRead('user-1', 'foreign')).rejects.toMatchObject(
|
||||||
|
{
|
||||||
|
code: ErrorCode.NotificationNotFound,
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks read and unread idempotently', async () => {
|
||||||
|
const { service } = serviceWithStore([notification('own', 'user-1')]);
|
||||||
|
|
||||||
|
await service.markAsRead('user-1', 'own');
|
||||||
|
const readAgain = await service.markAsRead('user-1', 'own');
|
||||||
|
expect(readAgain.read).toBe(true);
|
||||||
|
|
||||||
|
await service.markAsUnread('user-1', 'own');
|
||||||
|
const unreadAgain = await service.markAsUnread('user-1', 'own');
|
||||||
|
expect(unreadAgain.read).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks all unread notifications only for the current user', async () => {
|
||||||
|
const { service, store } = serviceWithStore([
|
||||||
|
notification('own', 'user-1'),
|
||||||
|
notification('other', 'user-2'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.markAllAsRead('user-1')).resolves.toEqual({
|
||||||
|
updated: 1,
|
||||||
|
});
|
||||||
|
expect(store.find((entry) => entry.id === 'own')?.readAt).toBe(now);
|
||||||
|
expect(store.find((entry) => entry.id === 'other')?.readAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft deletes notifications from normal queries', async () => {
|
||||||
|
const { service } = serviceWithStore([notification('own', 'user-1')]);
|
||||||
|
|
||||||
|
await service.softDelete('user-1', 'own');
|
||||||
|
const result = await service.getForCurrentUser('user-1', {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
status: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown types and unsafe links', async () => {
|
||||||
|
const { service } = serviceWithStore([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createForUser({
|
||||||
|
userId: 'user-1',
|
||||||
|
type: 'unknown',
|
||||||
|
title: 'Titel',
|
||||||
|
message: 'Nachricht',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: ErrorCode.NotificationTypeInvalid });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createForUser({
|
||||||
|
userId: 'user-1',
|
||||||
|
type: NotificationType.System,
|
||||||
|
title: 'Titel',
|
||||||
|
message: 'Nachricht',
|
||||||
|
link: 'https://example.com',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: ErrorCode.NotificationLinkInvalid });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects disabled target users', async () => {
|
||||||
|
const { service } = serviceWithStore([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createForUser({
|
||||||
|
userId: 'disabled-user',
|
||||||
|
type: NotificationType.System,
|
||||||
|
title: 'Titel',
|
||||||
|
message: 'Nachricht',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: ErrorCode.UserNotFound });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires notifications.manage for administrative creation', async () => {
|
||||||
|
const controller = new NotificationsController({} as NotificationsService);
|
||||||
|
const reflector = new Reflector();
|
||||||
|
const sessions: Pick<SessionsService, 'resolveSession'> = {
|
||||||
|
resolveSession: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
user: { id: 'user-1' },
|
||||||
|
permissions: [],
|
||||||
|
} as unknown as Awaited<ReturnType<SessionsService['resolveSession']>>),
|
||||||
|
};
|
||||||
|
const config = {
|
||||||
|
session: { cookieName: 'app_session' },
|
||||||
|
} as AppConfigService;
|
||||||
|
const guard = new PermissionsGuard(
|
||||||
|
reflector,
|
||||||
|
sessions as SessionsService,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const context = {
|
||||||
|
getHandler: () => controller.createAdmin,
|
||||||
|
getClass: () => NotificationsController,
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
signedCookies: { app_session: 'session-id' },
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
get: () => 'test-agent',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).rejects.toMatchObject({
|
||||||
|
code: ErrorCode.PermissionDenied,
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/backend/src/roles/admin-roles.controller.ts
Normal file
71
apps/backend/src/roles/admin-roles.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||||
|
import { Permission } from './permissions';
|
||||||
|
import { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
|
||||||
|
import { RolesService } from './roles.service';
|
||||||
|
|
||||||
|
@Controller('admin/roles')
|
||||||
|
export class AdminRolesController {
|
||||||
|
constructor(private readonly roles: RolesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.RolesRead)
|
||||||
|
list() {
|
||||||
|
return this.roles.adminList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permission.RolesRead)
|
||||||
|
get(@Param('id') id: string) {
|
||||||
|
return this.roles.adminGet(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permission.RolesManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
create(@Req() req: AuthenticatedRequest, @Body() dto: CreateRoleDto) {
|
||||||
|
return this.roles.create(dto, this.requireUser(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@RequirePermissions(Permission.RolesManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
update(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateRoleDto,
|
||||||
|
) {
|
||||||
|
return this.roles.update(id, dto, this.requireUser(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permission.RolesManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
delete(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.roles.delete(id, this.requireUser(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireUser(req: AuthenticatedRequest) {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'Bitte melden Sie sich an.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return req.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/backend/src/roles/dto/role.dto.ts
Normal file
19
apps/backend/src/roles/dto/role.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsArray, IsEnum, IsOptional, IsString, Length } from 'class-validator';
|
||||||
|
import { Permission } from '../permissions';
|
||||||
|
|
||||||
|
export class CreateRoleDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(2, 80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 255)
|
||||||
|
description = '';
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsEnum(Permission, { each: true })
|
||||||
|
permissions!: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateRoleDto extends CreateRoleDto {}
|
||||||
11
apps/backend/src/roles/entities/permission.entity.ts
Normal file
11
apps/backend/src/roles/entities/permission.entity.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||||
|
import { Permission } from '../permissions';
|
||||||
|
|
||||||
|
@Entity('permissions')
|
||||||
|
export class PermissionEntity {
|
||||||
|
@PrimaryColumn({ type: 'varchar', length: 80 })
|
||||||
|
id!: Permission;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 160 })
|
||||||
|
description!: string;
|
||||||
|
}
|
||||||
45
apps/backend/src/roles/entities/role.entity.ts
Normal file
45
apps/backend/src/roles/entities/role.entity.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { PermissionEntity } from './permission.entity';
|
||||||
|
import { UserEntity } from '../../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Entity('roles')
|
||||||
|
@Unique('uq_roles_name', ['name'])
|
||||||
|
export class RoleEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, default: '' })
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
protected!: boolean;
|
||||||
|
|
||||||
|
@ManyToMany(() => PermissionEntity, { eager: true })
|
||||||
|
@JoinTable({
|
||||||
|
name: 'role_permissions',
|
||||||
|
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||||
|
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
|
||||||
|
})
|
||||||
|
permissions!: PermissionEntity[];
|
||||||
|
|
||||||
|
@ManyToMany(() => UserEntity, (user) => user.roles)
|
||||||
|
users!: UserEntity[];
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
29
apps/backend/src/roles/permissions.ts
Normal file
29
apps/backend/src/roles/permissions.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export enum Permission {
|
||||||
|
ItemsRead = 'items.read',
|
||||||
|
ItemsCreate = 'items.create',
|
||||||
|
ItemsUpdate = 'items.update',
|
||||||
|
ItemsDelete = 'items.delete',
|
||||||
|
UsersRead = 'users.read',
|
||||||
|
UsersManage = 'users.manage',
|
||||||
|
RolesRead = 'roles.read',
|
||||||
|
RolesManage = 'roles.manage',
|
||||||
|
AuditRead = 'audit.read',
|
||||||
|
SessionsReadOwn = 'sessions.readOwn',
|
||||||
|
SessionsRevokeOwn = 'sessions.revokeOwn',
|
||||||
|
SessionsManage = 'sessions.manage',
|
||||||
|
NotificationsReadOwn = 'notifications.readOwn',
|
||||||
|
NotificationsUpdateOwn = 'notifications.updateOwn',
|
||||||
|
NotificationsManage = 'notifications.manage',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const allPermissions = Object.values(Permission);
|
||||||
|
|
||||||
|
export const administrativePermissions = [
|
||||||
|
Permission.UsersRead,
|
||||||
|
Permission.UsersManage,
|
||||||
|
Permission.RolesRead,
|
||||||
|
Permission.RolesManage,
|
||||||
|
Permission.AuditRead,
|
||||||
|
Permission.SessionsManage,
|
||||||
|
Permission.NotificationsManage,
|
||||||
|
] as const;
|
||||||
42
apps/backend/src/roles/repositories/roles.repository.ts
Normal file
42
apps/backend/src/roles/repositories/roles.repository.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
|
import { RoleEntity } from '../entities/role.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(RoleEntity) private readonly repo: Repository<RoleEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findByName(
|
||||||
|
name: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<RoleEntity | null> {
|
||||||
|
return (manager?.getRepository(RoleEntity) ?? this.repo).findOne({
|
||||||
|
where: { name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string, manager?: EntityManager): Promise<RoleEntity | null> {
|
||||||
|
return (manager?.getRepository(RoleEntity) ?? this.repo).findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { users: true, permissions: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): Promise<RoleEntity[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
relations: { users: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
save(role: RoleEntity, manager?: EntityManager): Promise<RoleEntity> {
|
||||||
|
return (manager?.getRepository(RoleEntity) ?? this.repo).save(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(role: RoleEntity, manager?: EntityManager): Promise<RoleEntity> {
|
||||||
|
return (manager?.getRepository(RoleEntity) ?? this.repo).remove(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/backend/src/roles/roles.controller.ts
Normal file
46
apps/backend/src/roles/roles.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||||
|
import { Permission } from './permissions';
|
||||||
|
import { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
|
||||||
|
import { RolesService } from './roles.service';
|
||||||
|
|
||||||
|
@Controller('roles')
|
||||||
|
export class RolesController {
|
||||||
|
constructor(private readonly roles: RolesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.RolesRead)
|
||||||
|
list() {
|
||||||
|
return this.roles.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permission.RolesManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
create(@Body() dto: CreateRoleDto) {
|
||||||
|
return this.roles.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@RequirePermissions(Permission.RolesManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
|
||||||
|
return this.roles.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permission.RolesManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
delete(@Param('id') id: string) {
|
||||||
|
return this.roles.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/backend/src/roles/roles.module.ts
Normal file
20
apps/backend/src/roles/roles.module.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { PermissionEntity } from './entities/permission.entity';
|
||||||
|
import { RoleEntity } from './entities/role.entity';
|
||||||
|
import { AdminRolesController } from './admin-roles.controller';
|
||||||
|
import { RolesController } from './roles.controller';
|
||||||
|
import { RolesRepository } from './repositories/roles.repository';
|
||||||
|
import { RolesService } from './roles.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([RoleEntity, PermissionEntity]),
|
||||||
|
AuditModule,
|
||||||
|
],
|
||||||
|
controllers: [RolesController, AdminRolesController],
|
||||||
|
providers: [RolesRepository, RolesService],
|
||||||
|
exports: [RolesRepository, RolesService],
|
||||||
|
})
|
||||||
|
export class RolesModule {}
|
||||||
311
apps/backend/src/roles/roles.service.ts
Normal file
311
apps/backend/src/roles/roles.service.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/entities/audit-log.entity';
|
||||||
|
import type { AuthenticatedUser } from '../auth/authenticated-request';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { PermissionEntity } from './entities/permission.entity';
|
||||||
|
import { RoleEntity } from './entities/role.entity';
|
||||||
|
import { allPermissions, Permission } from './permissions';
|
||||||
|
import type { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
|
||||||
|
import { RolesRepository } from './repositories/roles.repository';
|
||||||
|
|
||||||
|
const adminRoleName = 'admin';
|
||||||
|
const userRoleName = 'user';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesService {
|
||||||
|
constructor(
|
||||||
|
private readonly roles: RolesRepository,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(): Promise<RoleEntity[]> {
|
||||||
|
return this.roles.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminList() {
|
||||||
|
const roles = await this.roles.list();
|
||||||
|
return roles.map((role) => this.toAdminDto(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminGet(id: string) {
|
||||||
|
return this.toAdminDto(await this.getRole(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateRoleDto,
|
||||||
|
actor?: AuthenticatedUser,
|
||||||
|
): Promise<RoleEntity> {
|
||||||
|
const name = this.normalizeName(dto.name);
|
||||||
|
await this.assertRoleNameAvailable(name);
|
||||||
|
const role = new RoleEntity();
|
||||||
|
role.name = name;
|
||||||
|
role.description = dto.description?.trim() ?? '';
|
||||||
|
role.protected = false;
|
||||||
|
role.permissions = await this.loadPermissionEntities(dto.permissions);
|
||||||
|
const saved = await this.roles.save(role);
|
||||||
|
if (actor) {
|
||||||
|
await this.audit.record(
|
||||||
|
actor.id,
|
||||||
|
AuditAction.RoleCreated,
|
||||||
|
'role',
|
||||||
|
saved.id,
|
||||||
|
{
|
||||||
|
roleName: saved.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateRoleDto,
|
||||||
|
actor?: AuthenticatedUser,
|
||||||
|
): Promise<RoleEntity> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.query(
|
||||||
|
"SELECT GET_LOCK('business_app_admin_integrity', 10)",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const role = await this.getRole(id, manager);
|
||||||
|
const normalizedName = this.normalizeName(dto.name);
|
||||||
|
if (!role.protected && normalizedName !== role.name) {
|
||||||
|
await this.assertRoleNameAvailable(normalizedName, id, manager);
|
||||||
|
role.name = normalizedName;
|
||||||
|
}
|
||||||
|
if (role.name === adminRoleName) {
|
||||||
|
this.assertAdminPermissions(dto.permissions);
|
||||||
|
}
|
||||||
|
role.description = dto.description?.trim() ?? '';
|
||||||
|
role.permissions = await this.loadPermissionEntities(
|
||||||
|
role.name === adminRoleName ? allPermissions : dto.permissions,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
const saved = await this.roles.save(role, manager);
|
||||||
|
if (actor) {
|
||||||
|
await this.audit.record(
|
||||||
|
actor.id,
|
||||||
|
AuditAction.RoleUpdated,
|
||||||
|
'role',
|
||||||
|
saved.id,
|
||||||
|
{
|
||||||
|
roleName: saved.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await this.audit.record(
|
||||||
|
actor.id,
|
||||||
|
AuditAction.RolePermissionsUpdated,
|
||||||
|
'role',
|
||||||
|
saved.id,
|
||||||
|
{
|
||||||
|
permissions: saved.permissions
|
||||||
|
.map((permission) => permission.id)
|
||||||
|
.join(','),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
} finally {
|
||||||
|
await manager.query(
|
||||||
|
"SELECT RELEASE_LOCK('business_app_admin_integrity')",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, actor?: AuthenticatedUser): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.query(
|
||||||
|
"SELECT GET_LOCK('business_app_admin_integrity', 10)",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const role = await this.getRole(id, manager);
|
||||||
|
if (role.protected) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.SystemRoleProtected,
|
||||||
|
'Systemrollen koennen nicht geloescht werden.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (role.users.length > 0) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.RoleStillAssigned,
|
||||||
|
'Die Rolle ist noch Benutzern zugewiesen.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.roles.remove(role, manager);
|
||||||
|
if (actor) {
|
||||||
|
await this.audit.record(
|
||||||
|
actor.id,
|
||||||
|
AuditAction.RoleDeleted,
|
||||||
|
'role',
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
roleName: role.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await manager.query(
|
||||||
|
"SELECT RELEASE_LOCK('business_app_admin_integrity')",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEffectivePermissions(userId: string): Promise<Permission[]> {
|
||||||
|
const rows = await this.dataSource.query<{ permission: Permission }[]>(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT rp.permission_id AS permission
|
||||||
|
FROM user_roles ur
|
||||||
|
INNER JOIN role_permissions rp ON rp.role_id = ur.role_id
|
||||||
|
WHERE ur.user_id = ?
|
||||||
|
`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return rows.map((row) => row.permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureSystemRoles(
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<{ admin: RoleEntity; user: RoleEntity }> {
|
||||||
|
await this.syncPermissions(manager);
|
||||||
|
const admin = await this.ensureRole(
|
||||||
|
adminRoleName,
|
||||||
|
allPermissions,
|
||||||
|
true,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
const user = await this.ensureRole(
|
||||||
|
userRoleName,
|
||||||
|
[
|
||||||
|
Permission.ItemsRead,
|
||||||
|
Permission.SessionsReadOwn,
|
||||||
|
Permission.NotificationsReadOwn,
|
||||||
|
Permission.NotificationsUpdateOwn,
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return { admin, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncPermissions(manager?: EntityManager): Promise<void> {
|
||||||
|
const repo = (manager ?? this.dataSource.manager).getRepository(
|
||||||
|
PermissionEntity,
|
||||||
|
);
|
||||||
|
await repo.save(
|
||||||
|
allPermissions.map((permission) => ({
|
||||||
|
id: permission,
|
||||||
|
description: permission,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRole(id: string, manager?: EntityManager): Promise<RoleEntity> {
|
||||||
|
const role = await this.roles.findById(id, manager);
|
||||||
|
if (!role) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.RoleNotFound,
|
||||||
|
'Die Rolle wurde nicht gefunden.',
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureRole(
|
||||||
|
name: string,
|
||||||
|
permissions: Permission[],
|
||||||
|
protectedRole: boolean,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<RoleEntity> {
|
||||||
|
const repo = (manager ?? this.dataSource.manager).getRepository(RoleEntity);
|
||||||
|
let role = await repo.findOne({
|
||||||
|
where: { name },
|
||||||
|
relations: { permissions: true },
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
role = new RoleEntity();
|
||||||
|
role.name = name;
|
||||||
|
role.protected = protectedRole;
|
||||||
|
}
|
||||||
|
role.permissions = await this.loadPermissionEntities(permissions, manager);
|
||||||
|
role.protected = protectedRole;
|
||||||
|
return repo.save(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadPermissionEntities(
|
||||||
|
permissions: Permission[],
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<PermissionEntity[]> {
|
||||||
|
return Promise.all(
|
||||||
|
permissions.map(async (permission) => {
|
||||||
|
const entity = await (manager ?? this.dataSource.manager)
|
||||||
|
.getRepository(PermissionEntity)
|
||||||
|
.findOneBy({ id: permission });
|
||||||
|
if (!entity) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.UnknownPermission,
|
||||||
|
'Unbekannte Permission.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeName(name: string): string {
|
||||||
|
return name.trim().toLowerCase().replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertRoleNameAvailable(
|
||||||
|
name: string,
|
||||||
|
exceptRoleId?: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.roles.findByName(name, manager);
|
||||||
|
if (existing && existing.id !== exceptRoleId) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.RoleNameAlreadyExists,
|
||||||
|
'Der Rollenname ist bereits vergeben.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAdminPermissions(permissions: Permission[]): void {
|
||||||
|
const submitted = new Set(permissions);
|
||||||
|
const missing = allPermissions.filter(
|
||||||
|
(permission) => !submitted.has(permission),
|
||||||
|
);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.LastAdminRequired,
|
||||||
|
'Mindestens ein aktiver Administrator muss erhalten bleiben.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAdminDto(role: RoleEntity) {
|
||||||
|
return {
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
system: role.protected,
|
||||||
|
protected: role.protected,
|
||||||
|
permissions: role.permissions,
|
||||||
|
userCount: role.users?.length ?? 0,
|
||||||
|
users: role.users,
|
||||||
|
createdAt: role.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/backend/src/sessions/dto/session.dto.ts
Normal file
9
apps/backend/src/sessions/dto/session.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface SessionListItemDto {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastActivityAt: string;
|
||||||
|
userAgent: string | null;
|
||||||
|
approximateIp: string | null;
|
||||||
|
current: boolean;
|
||||||
|
revokedAt: string | null;
|
||||||
|
}
|
||||||
69
apps/backend/src/sessions/entities/session.entity.ts
Normal file
69
apps/backend/src/sessions/entities/session.entity.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UserEntity } from '../../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Entity('sessions')
|
||||||
|
export class SessionEntity {
|
||||||
|
@PrimaryColumn({ type: 'char', length: 64 })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => UserEntity, (user) => user.sessions, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user!: UserEntity;
|
||||||
|
|
||||||
|
@Index('idx_sessions_user_id')
|
||||||
|
@Column({ name: 'user_id', type: 'char', length: 36 })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'access_token_encrypted', type: 'text' })
|
||||||
|
accessTokenEncrypted!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'refresh_token_encrypted', type: 'text', nullable: true })
|
||||||
|
refreshTokenEncrypted!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'id_token_encrypted', type: 'text', nullable: true })
|
||||||
|
idTokenEncrypted!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'csrf_token_hash', type: 'char', length: 64 })
|
||||||
|
csrfTokenHash!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'access_token_expires_at', type: 'datetime', precision: 3 })
|
||||||
|
accessTokenExpiresAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'expires_at', type: 'datetime', precision: 3 })
|
||||||
|
expiresAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'absolute_expires_at', type: 'datetime', precision: 3 })
|
||||||
|
absoluteExpiresAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'last_activity_at', type: 'datetime', precision: 3 })
|
||||||
|
lastActivityAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'varchar', length: 512, nullable: true })
|
||||||
|
userAgent!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'last_ip', type: 'varchar', length: 80, nullable: true })
|
||||||
|
lastIp!: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'revoked_at',
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
revokedAt!: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { IsNull, LessThan, Repository } from 'typeorm';
|
||||||
|
import { SessionEntity } from '../entities/session.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionsRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SessionEntity)
|
||||||
|
private readonly repo: Repository<SessionEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findActiveById(id: string): Promise<SessionEntity | null> {
|
||||||
|
return this.repo.findOne({
|
||||||
|
where: { id, revokedAt: IsNull() },
|
||||||
|
relations: { user: { roles: { permissions: true }, settings: true } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listForUser(userId: string): Promise<SessionEntity[]> {
|
||||||
|
return this.repo.find({ where: { userId }, order: { createdAt: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
listActiveForUser(userId: string): Promise<SessionEntity[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { userId, revokedAt: IsNull() },
|
||||||
|
order: { lastActivityAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
countActiveForUser(userId: string): Promise<number> {
|
||||||
|
return this.repo.count({ where: { userId, revokedAt: IsNull() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(session: SessionEntity): Promise<SessionEntity> {
|
||||||
|
return this.repo.save(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(sessionId: string): Promise<number> {
|
||||||
|
const result = await this.repo.update(
|
||||||
|
{ id: sessionId, revokedAt: IsNull() },
|
||||||
|
{ revokedAt: new Date() },
|
||||||
|
);
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeForUser(userId: string, sessionId: string): Promise<number> {
|
||||||
|
const result = await this.repo.update(
|
||||||
|
{ id: sessionId, userId, revokedAt: IsNull() },
|
||||||
|
{ revokedAt: new Date() },
|
||||||
|
);
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAllForUser(
|
||||||
|
userId: string,
|
||||||
|
exceptSessionId?: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const sessions = await this.repo.find({
|
||||||
|
where: { userId, revokedAt: IsNull() },
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
const targets = sessions.filter(
|
||||||
|
(session) => session.id !== exceptSessionId,
|
||||||
|
);
|
||||||
|
await this.repo.save(
|
||||||
|
targets.map((session) => ({ ...session, revokedAt: now })),
|
||||||
|
);
|
||||||
|
return targets.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupExpired(now = new Date()): Promise<void> {
|
||||||
|
await this.repo.delete([
|
||||||
|
{ expiresAt: LessThan(now) },
|
||||||
|
{ absoluteExpiresAt: LessThan(now) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/backend/src/sessions/session-crypto.service.ts
Normal file
54
apps/backend/src/sessions/session-crypto.service.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHash,
|
||||||
|
createHmac,
|
||||||
|
randomBytes,
|
||||||
|
} from 'node:crypto';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionCryptoService {
|
||||||
|
private readonly encryptionKey: Buffer;
|
||||||
|
private readonly hmacKey: Buffer;
|
||||||
|
|
||||||
|
constructor(config: AppConfigService) {
|
||||||
|
this.encryptionKey = createHash('sha256')
|
||||||
|
.update(config.session.encryptionKey)
|
||||||
|
.digest();
|
||||||
|
this.hmacKey = createHash('sha256').update(config.session.secret).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(value: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', this.encryptionKey, iv);
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(value, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(value: string): string {
|
||||||
|
const [iv, tag, encrypted] = value.split('.');
|
||||||
|
if (!iv || !tag || !encrypted) {
|
||||||
|
throw new Error('Invalid encrypted payload');
|
||||||
|
}
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
'aes-256-gcm',
|
||||||
|
this.encryptionKey,
|
||||||
|
Buffer.from(iv, 'base64url'),
|
||||||
|
);
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'base64url'));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(encrypted, 'base64url')),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
hashToken(token: string): string {
|
||||||
|
return createHmac('sha256', this.hmacKey).update(token).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
61
apps/backend/src/sessions/sessions.controller.ts
Normal file
61
apps/backend/src/sessions/sessions.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Controller, Delete, Get, Param, Req } from '@nestjs/common';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||||
|
import { Permission } from '../roles/permissions';
|
||||||
|
import { SessionsService } from './sessions.service';
|
||||||
|
|
||||||
|
@Controller('sessions')
|
||||||
|
export class SessionsController {
|
||||||
|
constructor(private readonly sessions: SessionsService) {}
|
||||||
|
|
||||||
|
@Get('own')
|
||||||
|
@RequirePermissions(Permission.SessionsReadOwn)
|
||||||
|
listOwn(@Req() req: AuthenticatedRequest) {
|
||||||
|
const user = this.requireUser(req);
|
||||||
|
return this.sessions.listOwn(user.id, user.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('own/:id')
|
||||||
|
@RequirePermissions(Permission.SessionsRevokeOwn)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
revokeOwn(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
const user = this.requireUser(req);
|
||||||
|
if (user.sessionId === id) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Conflict,
|
||||||
|
'Die aktuelle Session bitte per Logout beenden.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.sessions.revoke(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('own')
|
||||||
|
@RequirePermissions(Permission.SessionsRevokeOwn)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
revokeOthers(@Req() req: AuthenticatedRequest) {
|
||||||
|
const user = this.requireUser(req);
|
||||||
|
return this.sessions.revokeAllForUser(user.id, user.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('users/:userId')
|
||||||
|
@RequirePermissions(Permission.SessionsManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
revokeAllForUser(@Param('userId') userId: string) {
|
||||||
|
return this.sessions.revokeAllForUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireUser(req: AuthenticatedRequest) {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'Bitte melden Sie sich an.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return req.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backend/src/sessions/sessions.module.ts
Normal file
16
apps/backend/src/sessions/sessions.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { SessionEntity } from './entities/session.entity';
|
||||||
|
import { SessionsController } from './sessions.controller';
|
||||||
|
import { SessionsRepository } from './repositories/sessions.repository';
|
||||||
|
import { SessionCryptoService } from './session-crypto.service';
|
||||||
|
import { SessionsService } from './sessions.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([SessionEntity, UserEntity])],
|
||||||
|
controllers: [SessionsController],
|
||||||
|
providers: [SessionsRepository, SessionCryptoService, SessionsService],
|
||||||
|
exports: [SessionsService, SessionsRepository, SessionCryptoService],
|
||||||
|
})
|
||||||
|
export class SessionsModule {}
|
||||||
209
apps/backend/src/sessions/sessions.service.ts
Normal file
209
apps/backend/src/sessions/sessions.service.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { Permission } from '../roles/permissions';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { SessionEntity } from './entities/session.entity';
|
||||||
|
import { SessionsRepository } from './repositories/sessions.repository';
|
||||||
|
import { SessionCryptoService } from './session-crypto.service';
|
||||||
|
import type { SessionListItemDto } from './dto/session.dto';
|
||||||
|
|
||||||
|
export interface ResolvedSession {
|
||||||
|
user: UserEntity;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSessionDto {
|
||||||
|
id: string;
|
||||||
|
publicId: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastActivityAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
absoluteExpiresAt: string;
|
||||||
|
userAgent: string | null;
|
||||||
|
approximateIp: string | null;
|
||||||
|
current: boolean;
|
||||||
|
revokedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly sessions: SessionsRepository,
|
||||||
|
private readonly crypto: SessionCryptoService,
|
||||||
|
private readonly config: AppConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
user: UserEntity,
|
||||||
|
tokens: {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
accessTokenExpiresAt: Date;
|
||||||
|
},
|
||||||
|
userAgent: string | undefined,
|
||||||
|
ip: string | undefined,
|
||||||
|
): Promise<{ session: SessionEntity; csrfToken: string }> {
|
||||||
|
await this.sessions.cleanupExpired();
|
||||||
|
const now = new Date();
|
||||||
|
const csrfToken = randomBytes(32).toString('base64url');
|
||||||
|
const session = new SessionEntity();
|
||||||
|
session.id = randomBytes(32).toString('hex');
|
||||||
|
session.user = user;
|
||||||
|
session.userId = user.id;
|
||||||
|
session.accessTokenEncrypted = this.crypto.encrypt(tokens.accessToken);
|
||||||
|
session.refreshTokenEncrypted = tokens.refreshToken
|
||||||
|
? this.crypto.encrypt(tokens.refreshToken)
|
||||||
|
: null;
|
||||||
|
session.idTokenEncrypted = tokens.idToken
|
||||||
|
? this.crypto.encrypt(tokens.idToken)
|
||||||
|
: null;
|
||||||
|
session.csrfTokenHash = this.crypto.hashToken(csrfToken);
|
||||||
|
session.accessTokenExpiresAt = tokens.accessTokenExpiresAt;
|
||||||
|
session.expiresAt = new Date(
|
||||||
|
now.getTime() + this.config.session.idleTimeoutSeconds * 1000,
|
||||||
|
);
|
||||||
|
session.absoluteExpiresAt = new Date(
|
||||||
|
now.getTime() + this.config.session.absoluteTimeoutSeconds * 1000,
|
||||||
|
);
|
||||||
|
session.lastActivityAt = now;
|
||||||
|
session.userAgent = userAgent ?? null;
|
||||||
|
session.lastIp = ip ?? null;
|
||||||
|
session.revokedAt = null;
|
||||||
|
return { session: await this.sessions.save(session), csrfToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveSession(
|
||||||
|
sessionId: string,
|
||||||
|
ip: string | undefined,
|
||||||
|
userAgent: string | undefined,
|
||||||
|
): Promise<ResolvedSession> {
|
||||||
|
const session = await this.sessions.findActiveById(sessionId);
|
||||||
|
const now = new Date();
|
||||||
|
if (
|
||||||
|
!session ||
|
||||||
|
session.expiresAt <= now ||
|
||||||
|
session.absoluteExpiresAt <= now ||
|
||||||
|
session.user.active === false
|
||||||
|
) {
|
||||||
|
if (session) {
|
||||||
|
await this.sessions.revoke(session.id);
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'Bitte melden Sie sich erneut an.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.expiresAt = new Date(
|
||||||
|
now.getTime() + this.config.session.idleTimeoutSeconds * 1000,
|
||||||
|
);
|
||||||
|
session.lastActivityAt = now;
|
||||||
|
session.lastIp = ip ?? session.lastIp;
|
||||||
|
session.userAgent = userAgent ?? session.userAgent;
|
||||||
|
await this.sessions.save(session);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: session.user,
|
||||||
|
permissions: this.permissionsFor(session.user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyCsrfToken(sessionId: string, token: string): Promise<boolean> {
|
||||||
|
const session = await this.sessions.findActiveById(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return session.csrfTokenHash === this.crypto.hashToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listOwn(
|
||||||
|
userId: string,
|
||||||
|
currentSessionId: string,
|
||||||
|
): Promise<SessionListItemDto[]> {
|
||||||
|
const sessions = await this.sessions.listForUser(userId);
|
||||||
|
return sessions.map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
createdAt: session.createdAt.toISOString(),
|
||||||
|
lastActivityAt: session.lastActivityAt.toISOString(),
|
||||||
|
userAgent: session.userAgent,
|
||||||
|
approximateIp: this.maskIp(session.lastIp),
|
||||||
|
current: session.id === currentSessionId,
|
||||||
|
revokedAt: session.revokedAt?.toISOString() ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke(sessionId: string): Promise<number> {
|
||||||
|
return this.sessions.revoke(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIdTokenForLogout(sessionId: string): Promise<string | undefined> {
|
||||||
|
const session = await this.sessions.findActiveById(sessionId);
|
||||||
|
if (!session?.idTokenEncrypted) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.crypto.decrypt(session.idTokenEncrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeAllForUser(userId: string, exceptSessionId?: string): Promise<number> {
|
||||||
|
return this.sessions.revokeAllForUser(userId, exceptSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForAdmin(
|
||||||
|
userId: string,
|
||||||
|
currentSessionId: string | undefined,
|
||||||
|
): Promise<AdminSessionDto[]> {
|
||||||
|
const sessions = await this.sessions.listForUser(userId);
|
||||||
|
return sessions.map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
publicId: `${session.id.slice(0, 8)}...${session.id.slice(-6)}`,
|
||||||
|
createdAt: session.createdAt.toISOString(),
|
||||||
|
lastActivityAt: session.lastActivityAt.toISOString(),
|
||||||
|
expiresAt: session.expiresAt.toISOString(),
|
||||||
|
absoluteExpiresAt: session.absoluteExpiresAt.toISOString(),
|
||||||
|
userAgent: session.userAgent,
|
||||||
|
approximateIp: this.maskIp(session.lastIp),
|
||||||
|
current: session.id === currentSessionId,
|
||||||
|
revokedAt: session.revokedAt?.toISOString() ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeForAdmin(userId: string, sessionId: string): Promise<void> {
|
||||||
|
const affected = await this.sessions.revokeForUser(userId, sessionId);
|
||||||
|
if (affected < 1) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.SessionNotFound,
|
||||||
|
'Die Session wurde nicht gefunden.',
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countActiveForUser(userId: string): Promise<number> {
|
||||||
|
return this.sessions.countActiveForUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private permissionsFor(user: UserEntity): Permission[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
user.roles.flatMap((role) =>
|
||||||
|
role.permissions.map((permission) => permission.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskIp(ip: string | null): string | null {
|
||||||
|
if (!ip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ip.includes(':')) {
|
||||||
|
return `${ip.split(':').slice(0, 3).join(':')}:...`;
|
||||||
|
}
|
||||||
|
return ip.replace(/\.\d+$/, '.0');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SessionCryptoService } from '../session-crypto.service';
|
||||||
|
import type { AppConfigService } from '../../config/config.service';
|
||||||
|
|
||||||
|
describe('SessionCryptoService', () => {
|
||||||
|
const config = {
|
||||||
|
session: {
|
||||||
|
encryptionKey: '12345678901234567890123456789012',
|
||||||
|
secret: 'abcdefghijklmnopqrstuvwxyz123456',
|
||||||
|
},
|
||||||
|
} as AppConfigService;
|
||||||
|
|
||||||
|
it('encrypts tokens without storing plaintext', () => {
|
||||||
|
const crypto = new SessionCryptoService(config);
|
||||||
|
const encrypted = crypto.encrypt('access-token');
|
||||||
|
|
||||||
|
expect(encrypted).not.toContain('access-token');
|
||||||
|
expect(crypto.decrypt(encrypted)).toBe('access-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates stable CSRF token hashes', () => {
|
||||||
|
const crypto = new SessionCryptoService(config);
|
||||||
|
|
||||||
|
expect(crypto.hashToken('csrf')).toBe(crypto.hashToken('csrf'));
|
||||||
|
expect(crypto.hashToken('csrf')).not.toBe(crypto.hashToken('other'));
|
||||||
|
});
|
||||||
|
});
|
||||||
69
apps/backend/src/users/dto/user.dto.ts
Normal file
69
apps/backend/src/users/dto/user.dto.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateUserRolesDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
roleIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateUserActiveDto {
|
||||||
|
@IsBoolean()
|
||||||
|
active!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateSettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
tablePageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
sidebarExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminUserSortField = 'name' | 'email' | 'lastLoginAt' | 'createdAt';
|
||||||
|
export type AdminUserActiveFilter = 'all' | 'active' | 'inactive';
|
||||||
|
|
||||||
|
export class AdminUserListQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['all', 'active', 'inactive'])
|
||||||
|
active: AdminUserActiveFilter = 'all';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
roleId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['name', 'email', 'lastLoginAt', 'createdAt'])
|
||||||
|
sort: AdminUserSortField = 'createdAt';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ASC', 'DESC'])
|
||||||
|
direction: 'ASC' | 'DESC' = 'DESC';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize = 25;
|
||||||
|
}
|
||||||
24
apps/backend/src/users/entities/user-settings.entity.ts
Normal file
24
apps/backend/src/users/entities/user-settings.entity.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
OneToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
|
@Entity('user_settings')
|
||||||
|
export class UserSettingsEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@OneToOne(() => UserEntity, (user) => user.settings, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user!: UserEntity;
|
||||||
|
|
||||||
|
@Column({ name: 'table_page_size', type: 'int', default: 20 })
|
||||||
|
tablePageSize!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'sidebar_expanded', type: 'boolean', default: true })
|
||||||
|
sidebarExpanded!: boolean;
|
||||||
|
}
|
||||||
70
apps/backend/src/users/entities/user.entity.ts
Normal file
70
apps/backend/src/users/entities/user.entity.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
OneToMany,
|
||||||
|
OneToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { RoleEntity } from '../../roles/entities/role.entity';
|
||||||
|
import { SessionEntity } from '../../sessions/entities/session.entity';
|
||||||
|
import { UserSettingsEntity } from './user-settings.entity';
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
@Unique('uq_users_issuer_subject', ['issuer', 'subject'])
|
||||||
|
export class UserEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index('idx_users_issuer')
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
issuer!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
subject!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 320, nullable: true })
|
||||||
|
email!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
active!: boolean;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'last_login_at',
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
lastLoginAt!: Date | null;
|
||||||
|
|
||||||
|
@ManyToMany(() => RoleEntity, (role) => role.users, { eager: true })
|
||||||
|
@JoinTable({
|
||||||
|
name: 'user_roles',
|
||||||
|
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
||||||
|
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||||
|
})
|
||||||
|
roles!: RoleEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => SessionEntity, (session) => session.user)
|
||||||
|
sessions!: SessionEntity[];
|
||||||
|
|
||||||
|
@OneToOne(() => UserSettingsEntity, (settings) => settings.user, {
|
||||||
|
cascade: true,
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
settings!: UserSettingsEntity;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
112
apps/backend/src/users/repositories/users.repository.ts
Normal file
112
apps/backend/src/users/repositories/users.repository.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
|
import type {
|
||||||
|
AdminUserActiveFilter,
|
||||||
|
AdminUserSortField,
|
||||||
|
} from '../dto/user.dto';
|
||||||
|
import { UserEntity } from '../entities/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findById(id: string, manager?: EntityManager): Promise<UserEntity | null> {
|
||||||
|
return (manager?.getRepository(UserEntity) ?? this.repo).findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findByIdentity(issuer: string, subject: string): Promise<UserEntity | null> {
|
||||||
|
return this.repo.findOne({ where: { issuer, subject } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(
|
||||||
|
query: string | undefined,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<[UserEntity[], number]> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.leftJoinAndSelect('user.roles', 'role');
|
||||||
|
if (query) {
|
||||||
|
qb.where('user.name LIKE :query OR user.email LIKE :query', {
|
||||||
|
query: `%${query}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return qb
|
||||||
|
.orderBy('user.createdAt', 'DESC')
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminSearch(
|
||||||
|
query: {
|
||||||
|
search?: string;
|
||||||
|
active: AdminUserActiveFilter;
|
||||||
|
roleId?: string;
|
||||||
|
sort: AdminUserSortField;
|
||||||
|
direction: 'ASC' | 'DESC';
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
},
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<[UserEntity[], number]> {
|
||||||
|
const repo = manager?.getRepository(UserEntity) ?? this.repo;
|
||||||
|
const qb = repo
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.leftJoinAndSelect('user.roles', 'role')
|
||||||
|
.leftJoinAndSelect('role.permissions', 'permission');
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere('user.name LIKE :search OR user.email LIKE :search', {
|
||||||
|
search: `%${query.search}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.active !== 'all') {
|
||||||
|
qb.andWhere('user.active = :active', {
|
||||||
|
active: query.active === 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.roleId) {
|
||||||
|
qb.andWhere(
|
||||||
|
'EXISTS (SELECT 1 FROM user_roles ur WHERE ur.user_id = user.id AND ur.role_id = :roleId)',
|
||||||
|
{ roleId: query.roleId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return qb
|
||||||
|
.orderBy(`user.${query.sort}`, query.direction)
|
||||||
|
.skip((query.page - 1) * query.pageSize)
|
||||||
|
.take(query.pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
findByIdWithRoles(
|
||||||
|
id: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<UserEntity | null> {
|
||||||
|
return (manager?.getRepository(UserEntity) ?? this.repo).findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { roles: { permissions: true }, settings: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(user: UserEntity, manager?: EntityManager): Promise<UserEntity> {
|
||||||
|
return (manager?.getRepository(UserEntity) ?? this.repo).save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
findFirstActiveAdmin(excludedUserId?: string): Promise<UserEntity | null> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.innerJoin('user.roles', 'role')
|
||||||
|
.where('user.active = :active', { active: true })
|
||||||
|
.andWhere('role.name = :role', { role: 'admin' })
|
||||||
|
.orderBy('user.createdAt', 'ASC');
|
||||||
|
if (excludedUserId) {
|
||||||
|
qb.andWhere('user.id <> :excludedUserId', { excludedUserId });
|
||||||
|
}
|
||||||
|
return qb.getOne();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/backend/src/users/tests/users.service.spec.ts
Normal file
45
apps/backend/src/users/tests/users.service.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ErrorCode } from '../../common/errors/error-codes';
|
||||||
|
import { UsersService } from '../users.service';
|
||||||
|
import type { AuditService } from '../../audit/audit.service';
|
||||||
|
import type { NotificationsService } from '../../notifications/notifications.service';
|
||||||
|
import type { RolesService } from '../../roles/roles.service';
|
||||||
|
import type { SessionsService } from '../../sessions/sessions.service';
|
||||||
|
import type { UsersRepository } from '../repositories/users.repository';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
describe('UsersService', () => {
|
||||||
|
it('blocks changes that would remove the last active admin', async () => {
|
||||||
|
const dataSource = {
|
||||||
|
manager: {
|
||||||
|
getRepository: () => ({
|
||||||
|
createQueryBuilder: () => ({
|
||||||
|
innerJoin: () => ({
|
||||||
|
where: () => ({
|
||||||
|
andWhere: () => ({
|
||||||
|
andWhere: () => ({
|
||||||
|
getCount: () => Promise.resolve(0),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as unknown as DataSource;
|
||||||
|
const service = new UsersService(
|
||||||
|
{} as UsersRepository,
|
||||||
|
{} as RolesService,
|
||||||
|
{} as SessionsService,
|
||||||
|
{} as AuditService,
|
||||||
|
{} as NotificationsService,
|
||||||
|
dataSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertAnotherActiveAdminRemains('user-1'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: ErrorCode.LastAdminRequired,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
165
apps/backend/src/users/users.controller.ts
Normal file
165
apps/backend/src/users/users.controller.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||||
|
import { Permission } from '../roles/permissions';
|
||||||
|
import {
|
||||||
|
AdminUserListQueryDto,
|
||||||
|
UpdateSettingsDto,
|
||||||
|
UpdateUserActiveDto,
|
||||||
|
UpdateUserRolesDto,
|
||||||
|
} from './dto/user.dto';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@RequirePermissions(Permission.SessionsReadOwn)
|
||||||
|
me(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.users.get(req.user?.id ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me/settings')
|
||||||
|
@RequirePermissions(Permission.SessionsReadOwn)
|
||||||
|
updateSettings(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Body() dto: UpdateSettingsDto,
|
||||||
|
) {
|
||||||
|
return this.users.updateSettings(req.user?.id ?? '', dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('users')
|
||||||
|
@RequirePermissions(Permission.UsersRead)
|
||||||
|
list(
|
||||||
|
@Query('search') search?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.users.list(search, Number(page ?? 1), Number(pageSize ?? 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/users')
|
||||||
|
@RequirePermissions(Permission.UsersRead)
|
||||||
|
adminList(@Query() query: AdminUserListQueryDto) {
|
||||||
|
return this.users.adminList(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/users/:id')
|
||||||
|
@RequirePermissions(Permission.UsersRead)
|
||||||
|
adminGet(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.users.adminGet(id, req.user?.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('admin/users/:id/deactivate')
|
||||||
|
@RequirePermissions(Permission.UsersManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
deactivate(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.users.setActive(this.requireUser(req), id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('admin/users/:id/activate')
|
||||||
|
@RequirePermissions(Permission.UsersManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
activate(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.users.setActive(this.requireUser(req), id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/users/:id/roles/:roleId')
|
||||||
|
@RequirePermissions(Permission.UsersManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
assignRole(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('roleId') roleId: string,
|
||||||
|
) {
|
||||||
|
return this.users.assignRole(this.requireUser(req), id, roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/users/:id/roles/:roleId')
|
||||||
|
@RequirePermissions(Permission.UsersManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
removeRole(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('roleId') roleId: string,
|
||||||
|
) {
|
||||||
|
return this.users.removeRole(this.requireUser(req), id, roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/users/:id/sessions')
|
||||||
|
@RequirePermissions(Permission.SessionsManage)
|
||||||
|
adminSessions(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.users
|
||||||
|
.adminGet(id, req.user?.sessionId)
|
||||||
|
.then((user) => user.sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/users/:userId/sessions/:sessionId')
|
||||||
|
@RequirePermissions(Permission.SessionsManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
revokeSession(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
) {
|
||||||
|
return this.users.revokeUserSession(
|
||||||
|
this.requireUser(req),
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/users/:id/sessions')
|
||||||
|
@RequirePermissions(Permission.SessionsManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
revokeSessions(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.users.revokeUserSessions(this.requireUser(req), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('users/:id/active')
|
||||||
|
@RequirePermissions(Permission.UsersManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
setActive(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateUserActiveDto,
|
||||||
|
) {
|
||||||
|
return this.users.setActive(this.requireUser(req), id, dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('users/:id/roles')
|
||||||
|
@RequirePermissions(Permission.UsersManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
setRoles(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateUserRolesDto,
|
||||||
|
) {
|
||||||
|
return this.users.setRoles(this.requireUser(req), id, dto.roleIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireUser(req: AuthenticatedRequest) {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'Bitte melden Sie sich an.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return req.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user