Initial commit

This commit is contained in:
2026-07-19 13:09:04 +02:00
commit 8cf57d7878
215 changed files with 30417 additions and 0 deletions

14
.dockerignore Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
apps/backend/public/
coverage/
.angular/
.env
.env.*
!.env.example
npm-debug.log*
*.local

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24.18.0

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
dist
coverage
node_modules
package-lock.json
apps/frontend/.angular

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "all"
}

27
AGENTS.md Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

40
apps/backend/README.md Normal file
View 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.

View 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' }],
},
},
);

View 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
View 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"
}
}

View 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('*');
}
}

View 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));
}
}

View 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 {}

View 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 };
}
}

View 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;
}

View 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,
});
}
}

View 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);
}
}

View 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 {}

View 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');
});
});

View 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();
}
}

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

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

View 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;
}

View 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;
}

View 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);
}
}

View 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);
}
}

View 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',
}

View 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);
}
}
}

View File

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

View 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';
}

View File

@@ -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());
}
}

View 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,
);
},
});
}

View 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 {}

View 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/);
});
});

View 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;
}
}

View 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;
};
}

View 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);
}

View 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();
}
}

View 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 {}

View 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 };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MigrationHealthService } from './migration-health.service';
@Module({
providers: [MigrationHealthService],
exports: [MigrationHealthService],
})
export class DatabaseModule {}

View 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,
];

View 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');
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View 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();

View 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,
],
});

View 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,
],
};
}

View 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' };
}
}

View 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 {}

View 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;
}

View 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;
}

View 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));
}
}

View 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 {}

View 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',
);
}
}
}

View 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);
}
}

View 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
View 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();

View 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;
}

View File

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

View 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);
}

View 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;
}
}

View 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 {}

View 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(),
};
}
}

View File

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

View File

@@ -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.',
}),
]);
});
});

View File

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

View 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;
}
}

View 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 {}

View 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;
}

View 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;
}

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

View 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);
}
}

View 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);
}
}

View 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 {}

View 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(),
};
}
}

View 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;
}

View 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;
}

View File

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

View 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');
}
}

View 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;
}
}

View 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 {}

View 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');
}
}

View File

@@ -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'));
});
});

View 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;
}

View 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;
}

View 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;
}

View 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();
}
}

View 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,
});
});
});

View 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