This commit is contained in:
Bastian Wagner
2026-07-16 09:49:22 +02:00
commit 543e8273a7
157 changed files with 22761 additions and 0 deletions

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,99 @@
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 { 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,
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,48 @@
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',
}
@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;
await this.auth.logout(sessionId);
res.clearCookie(this.config.session.cookieName, { path: '/' });
res.clearCookie('csrf_token', { path: '/' });
res.redirect(this.config.frontendBaseUrl);
}
}

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,286 @@
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<void> {
if (sessionId) {
await this.sessions.revoke(sessionId);
}
}
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 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,13 @@
export enum ErrorCode {
PermissionDenied = 'PERMISSION_DENIED',
Unauthorized = 'UNAUTHORIZED',
ValidationFailed = 'VALIDATION_FAILED',
NotFound = 'NOT_FOUND',
Conflict = 'CONFLICT',
CsrfInvalid = 'CSRF_INVALID',
UserDisabled = 'USER_DISABLED',
LastAdminRequired = 'LAST_ADMIN_REQUIRED',
MigrationMissing = 'MIGRATION_MISSING',
RateLimitExceeded = 'RATE_LIMIT_EXCEEDED',
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,90 @@
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('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,45 @@
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;
};
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,152 @@
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_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,
},
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,19 @@
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 { 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,
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,144 @@
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,
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,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,23 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { loadConfigForCli } from '../config/env';
import { entities } from './entities';
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
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],
});

View File

@@ -0,0 +1,24 @@
import type { TypeOrmModuleOptions } from '@nestjs/typeorm';
import type { AppConfigService } from '../config/config.service';
import { entities } from './entities';
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
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],
};
}

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,53 @@
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Length,
Min,
} from 'class-validator';
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()
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@IsInt()
@Min(1)
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,51 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
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(@Body() dto: CreateItemDto) {
return this.items.create(dto);
}
@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,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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])],
controllers: [ItemsController],
providers: [ItemsService, ItemsRepository],
})
export class ItemsModule {}

View File

@@ -0,0 +1,81 @@
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 { 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 {
constructor(private readonly items: ItemsRepository) {}
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): Promise<ItemEntity> {
const item = new ItemEntity();
item.name = dto.name;
item.description = dto.description ?? null;
item.status = dto.status;
return this.items.save(item);
}
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);
}
}

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,53 @@
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 { 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);
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);
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,14 @@
import { IsArray, IsEnum, IsString, Length } from 'class-validator';
import { Permission } from '../permissions';
export class CreateRoleDto {
@IsString()
@Length(2, 80)
name!: string;
@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,42 @@
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: '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,25 @@
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',
}
export const allPermissions = Object.values(Permission);
export const administrativePermissions = [
Permission.UsersRead,
Permission.UsersManage,
Permission.RolesRead,
Permission.RolesManage,
Permission.AuditRead,
Permission.SessionsManage,
] as const;

View File

@@ -0,0 +1,39 @@
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): Promise<RoleEntity | null> {
return this.repo.findOne({ where: { id }, relations: { users: 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): Promise<RoleEntity> {
return 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,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PermissionEntity } from './entities/permission.entity';
import { RoleEntity } from './entities/role.entity';
import { RolesController } from './roles.controller';
import { RolesRepository } from './repositories/roles.repository';
import { RolesService } from './roles.service';
@Module({
imports: [TypeOrmModule.forFeature([RoleEntity, PermissionEntity])],
controllers: [RolesController],
providers: [RolesRepository, RolesService],
exports: [RolesRepository, RolesService],
})
export class RolesModule {}

View File

@@ -0,0 +1,156 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager } from 'typeorm';
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,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
list(): Promise<RoleEntity[]> {
return this.roles.list();
}
async create(dto: CreateRoleDto): Promise<RoleEntity> {
const role = new RoleEntity();
role.name = dto.name;
role.protected = false;
role.permissions = await this.loadPermissionEntities(dto.permissions);
return this.roles.save(role);
}
async update(id: string, dto: UpdateRoleDto): Promise<RoleEntity> {
const role = await this.getRole(id);
if (
role.name === adminRoleName &&
dto.permissions.length !== allPermissions.length
) {
throw new ApiError(
ErrorCode.PermissionDenied,
'Die Adminrolle muss alle Rechte behalten.',
403,
);
}
role.name = role.protected ? role.name : dto.name;
role.permissions = await this.loadPermissionEntities(
role.name === adminRoleName ? allPermissions : dto.permissions,
);
return this.roles.save(role);
}
async delete(id: string): Promise<void> {
const role = await this.getRole(id);
if (role.protected) {
throw new ApiError(
ErrorCode.Conflict,
'Systemrollen koennen nicht geloescht werden.',
409,
);
}
if (role.users.length > 0) {
throw new ApiError(
ErrorCode.Conflict,
'Die Rolle ist noch Benutzern zugewiesen.',
409,
);
}
await this.roles.remove(role);
}
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],
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): Promise<RoleEntity> {
const role = await this.roles.findById(id);
if (!role) {
throw new ApiError(
ErrorCode.NotFound,
'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.ValidationFailed,
'Unbekannte Permission.',
400,
);
}
return entity;
}),
);
}
}

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,53 @@
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' } });
}
save(session: SessionEntity): Promise<SessionEntity> {
return this.repo.save(session);
}
async revoke(sessionId: string): Promise<void> {
await this.repo.update({ id: sessionId }, { revokedAt: new Date() });
}
async revokeAllForUser(
userId: string,
exceptSessionId?: string,
): Promise<void> {
const sessions = await this.repo.find({
where: { userId, revokedAt: IsNull() },
});
const now = new Date();
await this.repo.save(
sessions
.filter((session) => session.id !== exceptSessionId)
.map((session) => ({ ...session, revokedAt: now })),
);
}
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,154 @@
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[];
}
@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<void> {
return this.sessions.revoke(sessionId);
}
revokeAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
return this.sessions.revokeAllForUser(userId, exceptSessionId);
}
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,20 @@
import { IsArray, IsBoolean, IsOptional, IsString } 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;
}

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,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { UserEntity } from '../entities/user.entity';
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>,
) {}
findById(id: string): Promise<UserEntity | null> {
return 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 save(user: UserEntity, manager?: EntityManager): Promise<UserEntity> {
return (manager?.getRepository(UserEntity) ?? this.repo).save(user);
}
}

View File

@@ -0,0 +1,41 @@
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 { 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 = {
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,
dataSource,
);
await expect(
service.assertAnotherActiveAdminRemains('user-1'),
).rejects.toMatchObject({
code: ErrorCode.LastAdminRequired,
});
});
});

View File

@@ -0,0 +1,84 @@
import {
Body,
Controller,
Get,
Param,
Patch,
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 {
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));
}
@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;
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { RolesModule } from '../roles/roles.module';
import { SessionsModule } from '../sessions/sessions.module';
import { UserSettingsEntity } from './entities/user-settings.entity';
import { UserEntity } from './entities/user.entity';
import { UsersRepository } from './repositories/users.repository';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, UserSettingsEntity]),
RolesModule,
AuditModule,
SessionsModule,
],
controllers: [UsersController],
providers: [UsersRepository, UsersService],
exports: [UsersRepository, UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } 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 { RolesService } from '../roles/roles.service';
import { SessionsService } from '../sessions/sessions.service';
import { UserSettingsEntity } from './entities/user-settings.entity';
import { UserEntity } from './entities/user.entity';
import { UsersRepository } from './repositories/users.repository';
@Injectable()
export class UsersService {
constructor(
private readonly users: UsersRepository,
private readonly roles: RolesService,
private readonly sessions: SessionsService,
private readonly audit: AuditService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async list(search: string | undefined, page = 1, pageSize = 20) {
const [items, total] = await this.users.search(search, page, pageSize);
return { items, total, page, pageSize };
}
async get(id: string): Promise<UserEntity> {
const user = await this.users.findById(id);
if (!user) {
throw new ApiError(
ErrorCode.NotFound,
'Der Benutzer wurde nicht gefunden.',
404,
);
}
return user;
}
async setActive(
actor: AuthenticatedUser,
userId: string,
active: boolean,
): Promise<UserEntity> {
const user = await this.get(userId);
if (!active) {
await this.assertAnotherActiveAdminRemains(userId);
}
user.active = active;
const saved = await this.users.save(user);
if (!active) {
await this.sessions.revokeAllForUser(userId);
}
await this.audit.record(
actor.id,
active ? AuditAction.UserActivated : AuditAction.UserDeactivated,
'user',
userId,
);
return saved;
}
async setRoles(
actor: AuthenticatedUser,
userId: string,
roleIds: string[],
): Promise<UserEntity> {
const user = await this.get(userId);
const previousAdmin = this.hasRole(user, 'admin');
const roles = await Promise.all(
roleIds.map((id) => this.roles.getRole(id)),
);
user.roles = roles;
if (previousAdmin && !this.hasRole(user, 'admin')) {
await this.assertAnotherActiveAdminRemains(userId);
}
const saved = await this.users.save(user);
await this.audit.record(
actor.id,
AuditAction.UserRoleAssigned,
'user',
userId,
{
roleIds: roleIds.join(','),
},
);
return saved;
}
async updateSettings(
userId: string,
settings: Partial<
Pick<UserSettingsEntity, 'tablePageSize' | 'sidebarExpanded'>
>,
): Promise<UserEntity> {
const user = await this.get(userId);
user.settings.tablePageSize =
settings.tablePageSize ?? user.settings.tablePageSize;
user.settings.sidebarExpanded =
settings.sidebarExpanded ?? user.settings.sidebarExpanded;
return this.users.save(user);
}
async assertAnotherActiveAdminRemains(excludedUserId: string): Promise<void> {
const result = await this.dataSource
.getRepository(UserEntity)
.createQueryBuilder('user')
.innerJoin('user.roles', 'role')
.where('user.active = :active', { active: true })
.andWhere('user.id <> :excludedUserId', { excludedUserId })
.andWhere('role.name = :role', { role: 'admin' })
.getCount();
if (result < 1) {
throw new ApiError(
ErrorCode.LastAdminRequired,
'Mindestens ein anderer aktiver Administrator muss erhalten bleiben.',
409,
);
}
}
private hasRole(user: UserEntity, roleName: string): boolean {
return user.roles.some((role) => role.name === roleName);
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*.spec.ts", "**/*.test.ts"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"target": "ES2023",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023"],
"types": ["node", "vitest"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"isolatedModules": false,
"strictPropertyInitialization": false,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.spec.ts'],
globals: true,
coverage: {
reporter: ['text', 'html'],
},
},
});

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
apps/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

12
apps/frontend/.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

4
apps/frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
apps/frontend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
apps/frontend/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

35
apps/frontend/README.md Normal file
View File

@@ -0,0 +1,35 @@
# Frontend Workspace
Angular Frontend fuer das Boilerplate. Der Workspace wird normalerweise ueber die
Root-Scripts gesteuert.
## Befehle
```bash
npm --workspace apps/frontend run start
npm --workspace apps/frontend run build
npm --workspace apps/frontend run typecheck
npm --workspace apps/frontend run test
```
Im lokalen Start laeuft Angular auf `http://localhost:4200` und nutzt
`proxy.conf.json`, um `/api` an das Backend auf `http://localhost:3000` zu
proxyn.
## Struktur
- `src/app/layout/app-shell.ts`: Hauptlayout, Navigation und Login-Zustand
- `src/app/app.routes.ts`: Routen und Permission-Daten
- `src/app/core/auth.service.ts`: aktueller Benutzer und Permissions
- `src/app/core/csrf.interceptor.ts`: CSRF-Header fuer schreibende Requests
- `src/app/core/permission.guard.ts`: UI-seitige Routensperre
- `src/app/features`: fachliche Seiten
## Entwicklungsregeln
Das Frontend nutzt keine UI-Library. Komponenten bleiben mobile-first und
verwenden eigenes HTML und SCSS. Permissions im Frontend dienen nur Darstellung
und Navigation; die verbindliche Autorisierung findet im Backend statt.
API-Zugriffe laufen ueber `@boilerplate/api-client`. Der Client ist generiert
und wird aus dem Root mit `npm run api:generate` aktualisiert.

View File

@@ -0,0 +1,77 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.scss"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@boilerplate/frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "ng serve --host 0.0.0.0 --port 4200 --proxy-config proxy.conf.json",
"build": "ng build --configuration production",
"typecheck": "ng build --configuration development --no-progress",
"test": "vitest run --config vitest.config.ts"
},
"dependencies": {
"@angular/common": "22.0.6",
"@angular/compiler": "22.0.6",
"@angular/core": "22.0.6",
"@angular/forms": "22.0.6",
"@angular/platform-browser": "22.0.6",
"@angular/platform-browser-dynamic": "22.0.6",
"@angular/router": "22.0.6",
"@boilerplate/api-client": "1.0.0",
"rxjs": "7.8.2",
"tslib": "2.8.1"
}
}

View File

@@ -0,0 +1,12 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
},
"/health": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,17 @@
import { provideBrowserGlobalErrorListeners } from '@angular/core';
import type { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { csrfInterceptor } from './core/csrf.interceptor';
import { titleStrategyProvider } from './core/title.strategy';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withInterceptors([csrfInterceptor])),
provideRouter(routes, withComponentInputBinding()),
titleStrategyProvider,
],
};

View File

@@ -0,0 +1 @@
<router-outlet />

View File

@@ -0,0 +1,79 @@
import type { Routes } from '@angular/router';
import { permissionGuard } from './core/permission.guard';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./layout/app-shell').then((m) => m.AppShellComponent),
children: [
{
path: '',
title: 'Dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.page').then((m) => m.DashboardPageComponent),
},
{
path: 'profil',
title: 'Profil',
loadComponent: () =>
import('./features/profile/profile.page').then((m) => m.ProfilePageComponent),
},
{
path: 'sessions',
title: 'Eigene Sessions',
loadComponent: () =>
import('./features/sessions/sessions.page').then((m) => m.SessionsPageComponent),
},
{
path: 'items',
title: 'Items',
canActivate: [permissionGuard],
data: { permissions: ['items.read'] },
loadComponent: () =>
import('./features/items/items.page').then((m) => m.ItemsPageComponent),
},
{
path: 'benutzer',
title: 'Benutzerverwaltung',
canActivate: [permissionGuard],
data: { permissions: ['users.read'] },
loadComponent: () =>
import('./features/users/users.page').then((m) => m.UsersPageComponent),
},
{
path: 'rollen',
title: 'Rollenverwaltung',
canActivate: [permissionGuard],
data: { permissions: ['roles.read'] },
loadComponent: () =>
import('./features/roles/roles.page').then((m) => m.RolesPageComponent),
},
{
path: 'audit-log',
title: 'Audit-Log',
canActivate: [permissionGuard],
data: { permissions: ['audit.read'] },
loadComponent: () =>
import('./features/audit/audit.page').then((m) => m.AuditPageComponent),
},
{
path: '403',
title: 'Keine Berechtigung',
loadComponent: () =>
import('./features/errors/forbidden.page').then((m) => m.ForbiddenPageComponent),
},
{
path: 'fehler',
title: 'Fehler',
loadComponent: () =>
import('./features/errors/error.page').then((m) => m.ErrorPageComponent),
},
{
path: '**',
title: 'Nicht gefunden',
loadComponent: () =>
import('./features/errors/not-found.page').then((m) => m.NotFoundPageComponent),
},
],
},
];

View File

View File

@@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
it('renders the router outlet as application entry point', async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('router-outlet')).not.toBeNull();
});
});

View File

@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class App {}

View File

@@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ApiClientService, type UserDto } from '@boilerplate/api-client';
import { AuthService } from './auth.service';
const user: UserDto = {
id: 'u1',
name: 'Ada',
email: 'ada@example.test',
active: true,
lastLoginAt: null,
settings: { tablePageSize: 20, sidebarExpanded: true },
roles: [
{
id: 'r1',
name: 'user',
protected: true,
permissions: [{ id: 'items.read', description: 'items.read' }],
},
],
};
describe('AuthService', () => {
it('derives permissions from roles for navigation decisions', () => {
TestBed.configureTestingModule({
providers: [{ provide: ApiClientService, useValue: { me: () => of(user) } }],
});
const service = TestBed.inject(AuthService);
service.loadMe();
expect(service.has('items.read')).toBe(true);
expect(service.has('users.manage')).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More