logging
This commit is contained in:
6
apps/api/jest.config.js
Normal file
6
apps/api/jest.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testRegex: '.*\\.spec\\.ts$',
|
||||||
|
};
|
||||||
@@ -4,8 +4,12 @@ import { Request } from 'express';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
|
||||||
|
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
|
||||||
|
import { maskEmail } from '../application-error-log/application-error-sanitizer';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { RequestUser } from '../common/request-user';
|
import { RequestUser } from '../common/request-user';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
import { hashToken, randomToken } from '../common/token.util';
|
import { hashToken, randomToken } from '../common/token.util';
|
||||||
import { LldapService } from '../lldap/lldap.service';
|
import { LldapService } from '../lldap/lldap.service';
|
||||||
import { PortalMailService } from '../mail/portal-mail.service';
|
import { PortalMailService } from '../mail/portal-mail.service';
|
||||||
@@ -24,6 +28,8 @@ export class AccountController {
|
|||||||
private readonly mail: PortalMailService,
|
private readonly mail: PortalMailService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
@InjectRepository(EmailChangeRequest)
|
@InjectRepository(EmailChangeRequest)
|
||||||
private readonly emailChanges: Repository<EmailChangeRequest>,
|
private readonly emailChanges: Repository<EmailChangeRequest>,
|
||||||
@InjectRepository(AccountDeleteRequest)
|
@InjectRepository(AccountDeleteRequest)
|
||||||
@@ -64,7 +70,28 @@ export class AccountController {
|
|||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
|
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await this.mail.sendEmailChangeMail(dto.newEmail, token);
|
try {
|
||||||
|
await this.mail.sendEmailChangeMail(dto.newEmail, token);
|
||||||
|
} catch (error) {
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED,
|
||||||
|
module: 'AccountModule',
|
||||||
|
service: AccountController.name,
|
||||||
|
operation: 'sendEmailChangeMail',
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
userId: request.user.username,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
maskedRecipient: maskEmail(dto.newEmail.toLowerCase()),
|
||||||
|
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
type: 'account.email_change_requested',
|
type: 'account.email_change_requested',
|
||||||
username: request.user.username,
|
username: request.user.username,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { OidcModule } from './oidc/oidc.module';
|
|||||||
import { PasswordModule } from './password/password.module';
|
import { PasswordModule } from './password/password.module';
|
||||||
import { RegistrationModule } from './registration/registration.module';
|
import { RegistrationModule } from './registration/registration.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
|
import { ApplicationErrorLogModule } from './application-error-log/application-error-log.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -52,6 +53,7 @@ import { AccountModule } from './account/account.module';
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
ApplicationErrorLogModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export enum ApplicationErrorCategory {
|
||||||
|
BACKGROUND_JOB = 'BACKGROUND_JOB',
|
||||||
|
DATABASE = 'DATABASE',
|
||||||
|
EMAIL = 'EMAIL',
|
||||||
|
EXTERNAL_API = 'EXTERNAL_API',
|
||||||
|
FILE = 'FILE',
|
||||||
|
UNHANDLED = 'UNHANDLED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ApplicationErrorCode {
|
||||||
|
ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED = 'ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED',
|
||||||
|
BACKGROUND_JOB_FAILED = 'BACKGROUND_JOB_FAILED',
|
||||||
|
DATABASE_OPERATION_FAILED = 'DATABASE_OPERATION_FAILED',
|
||||||
|
EMAIL_PROVIDER_UNAVAILABLE = 'EMAIL_PROVIDER_UNAVAILABLE',
|
||||||
|
EXTERNAL_API_REQUEST_FAILED = 'EXTERNAL_API_REQUEST_FAILED',
|
||||||
|
FILE_GENERATION_FAILED = 'FILE_GENERATION_FAILED',
|
||||||
|
PASSWORD_RESET_EMAIL_SEND_FAILED = 'PASSWORD_RESET_EMAIL_SEND_FAILED',
|
||||||
|
REGISTRATION_APPROVAL_NOTIFICATION_FAILED = 'REGISTRATION_APPROVAL_NOTIFICATION_FAILED',
|
||||||
|
REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED = 'REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED',
|
||||||
|
UNHANDLED_BACKEND_EXCEPTION = 'UNHANDLED_BACKEND_EXCEPTION',
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'application_error_logs' })
|
||||||
|
@Index(['createdAt'])
|
||||||
|
@Index(['code'])
|
||||||
|
@Index(['category'])
|
||||||
|
@Index(['correlationId'])
|
||||||
|
@Index(['userId'])
|
||||||
|
@Index(['tenantId'])
|
||||||
|
@Index(['httpStatusCode'])
|
||||||
|
export class ApplicationErrorLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ default: 'error' })
|
||||||
|
level!: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
stackTrace?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
errorType?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
backendModule?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
service?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
operation?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
httpMethod?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
apiPath?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
httpStatusCode?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
correlationId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
tenantId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
environment?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
host?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
handled!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Global, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
|
import { APP_FILTER } from '@nestjs/core';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ApplicationErrorFilter } from './application-error.filter';
|
||||||
|
import { ApplicationErrorLog } from './application-error-log.entity';
|
||||||
|
import { ApplicationErrorLoggerService } from './application-error-logger.service';
|
||||||
|
import { CorrelationIdMiddleware } from '../common/correlation-id.middleware';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, TypeOrmModule.forFeature([ApplicationErrorLog])],
|
||||||
|
providers: [
|
||||||
|
RequestContextService,
|
||||||
|
CorrelationIdMiddleware,
|
||||||
|
ApplicationErrorLoggerService,
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: ApplicationErrorFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [ApplicationErrorLoggerService, RequestContextService],
|
||||||
|
})
|
||||||
|
export class ApplicationErrorLogModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
consumer.apply(CorrelationIdMiddleware).forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export interface ApplicationErrorRequestContext {
|
||||||
|
correlationId?: string;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
userId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplicationErrorLogInput {
|
||||||
|
error: unknown;
|
||||||
|
level?: string;
|
||||||
|
category?: string;
|
||||||
|
code?: string;
|
||||||
|
module?: string;
|
||||||
|
service?: string;
|
||||||
|
operation?: string;
|
||||||
|
requestContext?: ApplicationErrorRequestContext;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
handled?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, jest } from '@jest/globals';
|
||||||
|
import { ApplicationErrorCategory, ApplicationErrorCode } from './application-error-codes';
|
||||||
|
import { ApplicationErrorLoggerService } from './application-error-logger.service';
|
||||||
|
|
||||||
|
describe('ApplicationErrorLoggerService', () => {
|
||||||
|
function createService(
|
||||||
|
save = jest.fn<(entity: unknown) => Promise<unknown>>().mockResolvedValue(undefined),
|
||||||
|
) {
|
||||||
|
const repository = {
|
||||||
|
create: jest.fn((entity) => entity),
|
||||||
|
save,
|
||||||
|
};
|
||||||
|
const config = {
|
||||||
|
get: jest.fn((key: string) => (key === 'NODE_ENV' ? 'test' : undefined)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new ApplicationErrorLoggerService(repository as never, config as never),
|
||||||
|
repository,
|
||||||
|
save,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stores a normal Error object with stack and type', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
const error = new Error('SMTP failed');
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.EMAIL_PROVIDER_UNAVAILABLE,
|
||||||
|
module: 'MailModule',
|
||||||
|
service: 'PortalMailService',
|
||||||
|
operation: 'sendMail',
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.EMAIL_PROVIDER_UNAVAILABLE,
|
||||||
|
message: 'SMTP failed',
|
||||||
|
errorType: 'Error',
|
||||||
|
stackTrace: expect.stringContaining('Error: SMTP failed'),
|
||||||
|
handled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes NestJS HTTP exceptions', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
error: new BadRequestException({ statusCode: 400, code: 'VALIDATION_FAILED', message: ['email invalid'] }),
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'VALIDATION_FAILED',
|
||||||
|
httpStatusCode: 400,
|
||||||
|
errorType: 'BadRequestException',
|
||||||
|
level: 'warning',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('safely stores unknown error values', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
|
||||||
|
await service.log({ error: { reason: 'plain object' }, handled: true });
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: '{"reason":"plain object"}',
|
||||||
|
errorType: 'object',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes sensitive fields including nested values', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
error: new Error('failed'),
|
||||||
|
context: {
|
||||||
|
password: 'secret',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer abc',
|
||||||
|
safe: 'value',
|
||||||
|
},
|
||||||
|
nested: [{ resetToken: 'secret-token', visible: true }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = save.mock.calls[0][0] as { context: Record<string, unknown> };
|
||||||
|
expect(saved.context.password).toBeUndefined();
|
||||||
|
expect((saved.context.headers as Record<string, unknown>).authorization).toBeUndefined();
|
||||||
|
expect((saved.context.headers as Record<string, unknown>).safe).toBe('value');
|
||||||
|
expect(((saved.context.nested as Array<Record<string, unknown>>)[0]).resetToken).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('masks email addresses in context values', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
error: new Error('failed for max.mustermann@example.com'),
|
||||||
|
context: {
|
||||||
|
recipient: 'max.mustermann@example.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = save.mock.calls[0][0] as { message: string; context: Record<string, unknown> };
|
||||||
|
expect(saved.message).toBe('failed for m***@example.com');
|
||||||
|
expect(saved.context.recipient).toBe('m***@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles circular context values safely', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
const circular: Record<string, unknown> = { name: 'root' };
|
||||||
|
circular.self = circular;
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
error: new Error('failed'),
|
||||||
|
context: circular,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = save.mock.calls[0][0] as { context: Record<string, unknown> };
|
||||||
|
expect(saved.context.self).toBe('[Circular]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits oversized context payloads', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
error: new Error('failed'),
|
||||||
|
context: {
|
||||||
|
items: Array.from({ length: 20 }, () => 'x'.repeat(2_000)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = save.mock.calls[0][0] as { context: Record<string, unknown> };
|
||||||
|
expect(saved.context.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores correlation, user and tenant context', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
error: new Error('failed'),
|
||||||
|
requestContext: {
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/password/reset/request',
|
||||||
|
userId: 'user-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
httpMethod: 'POST',
|
||||||
|
apiPath: '/api/password/reset/request',
|
||||||
|
userId: 'user-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when database logging fails', async () => {
|
||||||
|
const loggerSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
const { service } = createService(
|
||||||
|
jest.fn<(entity: unknown) => Promise<unknown>>().mockRejectedValue(new Error('database unavailable')),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.log({ error: new Error('original') })).resolves.toBeUndefined();
|
||||||
|
expect(loggerSpy).toHaveBeenCalled();
|
||||||
|
loggerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be used for handled background job failures', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
|
||||||
|
await service.log({
|
||||||
|
error: new Error('job failed'),
|
||||||
|
category: ApplicationErrorCategory.BACKGROUND_JOB,
|
||||||
|
code: ApplicationErrorCode.BACKGROUND_JOB_FAILED,
|
||||||
|
service: 'NightlyImportJob',
|
||||||
|
operation: 'run',
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
category: ApplicationErrorCategory.BACKGROUND_JOB,
|
||||||
|
code: ApplicationErrorCode.BACKGROUND_JOB_FAILED,
|
||||||
|
handled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist the same Error instance twice', async () => {
|
||||||
|
const { service, save } = createService();
|
||||||
|
const error = new Error('same failure');
|
||||||
|
|
||||||
|
await service.log({ error });
|
||||||
|
await service.log({ error });
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { HttpException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { hostname } from 'node:os';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ApplicationErrorLog } from './application-error-log.entity';
|
||||||
|
import { ApplicationErrorLogInput } from './application-error-log.types';
|
||||||
|
import { markErrorAsLogged, wasErrorLogged } from './logged-error-marker';
|
||||||
|
import { sanitizeContext, sanitizeString, sanitizeValue } from './application-error-sanitizer';
|
||||||
|
|
||||||
|
interface ExtractedError {
|
||||||
|
message: string;
|
||||||
|
stackTrace?: string;
|
||||||
|
errorType?: string;
|
||||||
|
httpStatusCode?: number;
|
||||||
|
responseCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApplicationErrorLoggerService {
|
||||||
|
private readonly fallbackLogger = new Logger(ApplicationErrorLoggerService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ApplicationErrorLog)
|
||||||
|
private readonly logs: Repository<ApplicationErrorLog>,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async log(input: ApplicationErrorLogInput): Promise<void> {
|
||||||
|
if (wasErrorLogged(input.error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = this.extractError(input.error);
|
||||||
|
try {
|
||||||
|
const entity = this.logs.create({
|
||||||
|
level: input.level ?? this.levelForStatus(input.requestContext?.statusCode ?? extracted.httpStatusCode),
|
||||||
|
category: input.category,
|
||||||
|
code: input.code ?? extracted.responseCode,
|
||||||
|
message: extracted.message,
|
||||||
|
stackTrace: extracted.stackTrace,
|
||||||
|
errorType: extracted.errorType,
|
||||||
|
backendModule: input.module,
|
||||||
|
service: input.service,
|
||||||
|
operation: input.operation,
|
||||||
|
httpMethod: input.requestContext?.method,
|
||||||
|
apiPath: input.requestContext?.path,
|
||||||
|
httpStatusCode: input.requestContext?.statusCode ?? extracted.httpStatusCode,
|
||||||
|
correlationId: input.requestContext?.correlationId,
|
||||||
|
userId: input.requestContext?.userId,
|
||||||
|
tenantId: input.requestContext?.tenantId,
|
||||||
|
environment: this.config.get<string>('NODE_ENV') ?? 'development',
|
||||||
|
host: hostname(),
|
||||||
|
context: sanitizeContext(input.context),
|
||||||
|
handled: input.handled ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.logs.save(entity);
|
||||||
|
markErrorAsLogged(input.error);
|
||||||
|
} catch (logError) {
|
||||||
|
this.fallbackLogger.error(
|
||||||
|
`Application error log write failed: ${this.extractError(logError).message}; original: ${extracted.message}`,
|
||||||
|
this.extractError(logError).stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractError(error: unknown): ExtractedError {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
const response = error.getResponse();
|
||||||
|
const responseObject = typeof response === 'object' && response !== null ? response : undefined;
|
||||||
|
const responseCode =
|
||||||
|
responseObject && 'code' in responseObject && typeof responseObject.code === 'string'
|
||||||
|
? responseObject.code
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: sanitizeString(error.message || this.messageFromResponse(response)),
|
||||||
|
stackTrace: error.stack ? sanitizeString(error.stack) : undefined,
|
||||||
|
errorType: error.constructor.name,
|
||||||
|
httpStatusCode: error.getStatus(),
|
||||||
|
responseCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
message: sanitizeString(error.message || error.name),
|
||||||
|
stackTrace: error.stack ? sanitizeString(error.stack) : undefined,
|
||||||
|
errorType: error.constructor.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return {
|
||||||
|
message: sanitizeString(error),
|
||||||
|
errorType: 'String',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: sanitizeString(JSON.stringify(sanitizeValue(error)) ?? 'Unknown non-error value'),
|
||||||
|
errorType: error === null ? 'Null' : typeof error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private messageFromResponse(response: string | object): string {
|
||||||
|
if (typeof response === 'string') {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('message' in response) {
|
||||||
|
const message = response.message;
|
||||||
|
return Array.isArray(message) ? message.join('; ') : String(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'HTTP exception';
|
||||||
|
}
|
||||||
|
|
||||||
|
private levelForStatus(statusCode?: number): string {
|
||||||
|
if (!statusCode || statusCode >= 500) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
const sensitiveKeyFragments = [
|
||||||
|
'password',
|
||||||
|
'currentpassword',
|
||||||
|
'newpassword',
|
||||||
|
'token',
|
||||||
|
'accesstoken',
|
||||||
|
'refreshtoken',
|
||||||
|
'idtoken',
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'secret',
|
||||||
|
'apikey',
|
||||||
|
'clientsecret',
|
||||||
|
'resettoken',
|
||||||
|
'sessionid',
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxDepth = 5;
|
||||||
|
const maxObjectKeys = 50;
|
||||||
|
const maxArrayItems = 20;
|
||||||
|
const maxStringLength = 2_000;
|
||||||
|
const maxContextLength = 16_000;
|
||||||
|
|
||||||
|
export function isSensitiveKey(key: string): boolean {
|
||||||
|
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||||
|
return sensitiveKeyFragments.some((fragment) => normalized.includes(fragment));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskEmail(email: string): string {
|
||||||
|
const [localPart, domain] = email.split('@');
|
||||||
|
if (!localPart || !domain) {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${localPart[0] ?? '*'}***@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeString(value: string): string {
|
||||||
|
const withoutBearer = value.replace(/bearer\s+[a-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]');
|
||||||
|
const withMaskedEmails = withoutBearer.replace(
|
||||||
|
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
|
||||||
|
(email) => maskEmail(email),
|
||||||
|
);
|
||||||
|
|
||||||
|
return truncate(withMaskedEmails, maxStringLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeContext(input: unknown): Record<string, unknown> | undefined {
|
||||||
|
const sanitized = sanitizeValue(input, 0, new WeakSet<object>());
|
||||||
|
if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(sanitized);
|
||||||
|
if (serialized.length <= maxContextLength) {
|
||||||
|
return sanitized as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
truncated: true,
|
||||||
|
originalLength: serialized.length,
|
||||||
|
preview: serialized.slice(0, maxContextLength),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeValue(input: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
|
||||||
|
if (input === null || input === undefined) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return sanitizeString(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'number' || typeof input === 'boolean') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'bigint') {
|
||||||
|
return input.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'symbol' || typeof input === 'function') {
|
||||||
|
return `[${typeof input}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input instanceof Date) {
|
||||||
|
return input.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input instanceof Error) {
|
||||||
|
return {
|
||||||
|
name: input.name,
|
||||||
|
message: sanitizeString(input.message),
|
||||||
|
stack: input.stack ? sanitizeString(input.stack) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth >= maxDepth) {
|
||||||
|
return '[MaxDepth]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(input)) {
|
||||||
|
return '[Circular]';
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(input);
|
||||||
|
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input.slice(0, maxArrayItems).map((item) => sanitizeValue(item, depth + 1, seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(input).slice(0, maxObjectKeys)) {
|
||||||
|
if (isSensitiveKey(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output[key] = sanitizeValue(value, depth + 1, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string, maxLength: number): string {
|
||||||
|
if (value.length <= maxLength) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, maxLength)}...[truncated]`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, jest } from '@jest/globals';
|
||||||
|
import { ApplicationErrorFilter } from './application-error.filter';
|
||||||
|
import { ApplicationErrorCode } from './application-error-codes';
|
||||||
|
import { markErrorAsLogged } from './logged-error-marker';
|
||||||
|
|
||||||
|
describe('ApplicationErrorFilter', () => {
|
||||||
|
function createFilter() {
|
||||||
|
const reply = jest.fn();
|
||||||
|
const errorLogger = { log: jest.fn<(input: unknown) => Promise<void>>().mockResolvedValue(undefined) };
|
||||||
|
const requestContext = {
|
||||||
|
get: jest.fn(() => ({ correlationId: 'corr-1' })),
|
||||||
|
};
|
||||||
|
const filter = new ApplicationErrorFilter(
|
||||||
|
{ httpAdapter: { reply } } as never,
|
||||||
|
errorLogger as never,
|
||||||
|
requestContext as never,
|
||||||
|
);
|
||||||
|
const host = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
method: 'POST',
|
||||||
|
route: { path: '/test' },
|
||||||
|
path: '/test',
|
||||||
|
originalUrl: '/test?x=1',
|
||||||
|
url: '/test?x=1',
|
||||||
|
query: { x: '1' },
|
||||||
|
params: { id: '2' },
|
||||||
|
user: { username: 'user-1', sub: 'sub-1' },
|
||||||
|
}),
|
||||||
|
getResponse: () => ({}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { filter, reply, errorLogger, host };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not log expected validation errors as critical failures', () => {
|
||||||
|
const { filter, reply, errorLogger, host } = createFilter();
|
||||||
|
|
||||||
|
filter.catch(new BadRequestException('invalid input'), host as never);
|
||||||
|
|
||||||
|
expect(errorLogger.log).not.toHaveBeenCalled();
|
||||||
|
expect(reply).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ statusCode: 400 }), 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs unexpected exceptions globally', () => {
|
||||||
|
const { filter, reply, errorLogger, host } = createFilter();
|
||||||
|
const error = new Error('boom');
|
||||||
|
|
||||||
|
filter.catch(error, host as never);
|
||||||
|
|
||||||
|
expect(errorLogger.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error,
|
||||||
|
code: ApplicationErrorCode.UNHANDLED_BACKEND_EXCEPTION,
|
||||||
|
requestContext: expect.objectContaining({
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
statusCode: 500,
|
||||||
|
}),
|
||||||
|
handled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(reply).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ statusCode: 500, correlationId: 'corr-1' }),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not log exceptions that were already explicitly recorded', () => {
|
||||||
|
const { filter, errorLogger, host } = createFilter();
|
||||||
|
const exception = new InternalServerErrorException('already logged');
|
||||||
|
markErrorAsLogged(exception);
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(errorLogger.log).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
103
apps/api/src/application-error-log/application-error.filter.ts
Normal file
103
apps/api/src/application-error-log/application-error.filter.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { ApplicationErrorCategory, ApplicationErrorCode } from './application-error-codes';
|
||||||
|
import { ApplicationErrorLoggerService } from './application-error-logger.service';
|
||||||
|
import { wasErrorLogged } from './logged-error-marker';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
|
import { RequestUser } from '../common/request-user';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
@Injectable()
|
||||||
|
export class ApplicationErrorFilter implements ExceptionFilter {
|
||||||
|
constructor(
|
||||||
|
private readonly httpAdapterHost: HttpAdapterHost,
|
||||||
|
private readonly errorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const http = host.switchToHttp();
|
||||||
|
const request = http.getRequest<Request & { user?: RequestUser }>();
|
||||||
|
const response = http.getResponse();
|
||||||
|
const statusCode = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
const context = this.requestContext.get();
|
||||||
|
const correlationId = context.correlationId;
|
||||||
|
|
||||||
|
if (this.shouldLog(exception, statusCode)) {
|
||||||
|
void this.errorLogger.log({
|
||||||
|
error: exception,
|
||||||
|
category: this.categoryFor(exception),
|
||||||
|
code: ApplicationErrorCode.UNHANDLED_BACKEND_EXCEPTION,
|
||||||
|
module: 'HTTP',
|
||||||
|
operation: `${request.method} ${request.route?.path ?? request.path}`,
|
||||||
|
requestContext: {
|
||||||
|
correlationId,
|
||||||
|
method: request.method,
|
||||||
|
path: request.originalUrl || request.url,
|
||||||
|
statusCode,
|
||||||
|
userId: request.user?.username ?? request.user?.sub,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
query: request.query,
|
||||||
|
params: request.params,
|
||||||
|
},
|
||||||
|
handled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = this.responseBody(exception, statusCode, correlationId);
|
||||||
|
this.httpAdapterHost.httpAdapter.reply(response, body, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(exception: unknown, statusCode: number): boolean {
|
||||||
|
if (wasErrorLogged(exception)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(exception instanceof HttpException)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusCode >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
private categoryFor(exception: unknown): ApplicationErrorCategory {
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
return ApplicationErrorCategory.UNHANDLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplicationErrorCategory.UNHANDLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private responseBody(exception: unknown, statusCode: number, correlationId?: string): unknown {
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
if (typeof exceptionResponse === 'string') {
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
message: exceptionResponse,
|
||||||
|
...(statusCode >= 500 && correlationId ? { correlationId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...exceptionResponse,
|
||||||
|
...(statusCode >= 500 && correlationId ? { correlationId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
message: 'Internal server error',
|
||||||
|
...(correlationId ? { correlationId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/api/src/application-error-log/logged-error-marker.ts
Normal file
25
apps/api/src/application-error-log/logged-error-marker.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const loggedErrorMarker = Symbol('applicationErrorLogged');
|
||||||
|
|
||||||
|
export function markErrorAsLogged(error: unknown): void {
|
||||||
|
if (!error || (typeof error !== 'object' && typeof error !== 'function')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasErrorLogged(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(error, loggedErrorMarker, {
|
||||||
|
value: true,
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wasErrorLogged(error: unknown): boolean {
|
||||||
|
return Boolean(
|
||||||
|
error &&
|
||||||
|
(typeof error === 'object' || typeof error === 'function') &&
|
||||||
|
(error as Record<symbol, unknown>)[loggedErrorMarker],
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/api/src/common/correlation-id.middleware.ts
Normal file
26
apps/api/src/common/correlation-id.middleware.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
import { RequestContextService } from './request-context.service';
|
||||||
|
|
||||||
|
export const correlationIdHeader = 'x-correlation-id';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CorrelationIdMiddleware implements NestMiddleware {
|
||||||
|
constructor(private readonly requestContext: RequestContextService) {}
|
||||||
|
|
||||||
|
use(request: Request, response: Response, next: NextFunction): void {
|
||||||
|
const header = request.headers[correlationIdHeader];
|
||||||
|
const correlationId = Array.isArray(header) ? header[0] : header || randomUUID();
|
||||||
|
|
||||||
|
response.setHeader('X-Correlation-ID', correlationId);
|
||||||
|
this.requestContext.run(
|
||||||
|
{
|
||||||
|
correlationId,
|
||||||
|
method: request.method,
|
||||||
|
path: request.originalUrl || request.url,
|
||||||
|
},
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/api/src/common/request-context.service.ts
Normal file
23
apps/api/src/common/request-context.service.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
|
||||||
|
export interface RequestContextData {
|
||||||
|
correlationId?: string;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
userId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RequestContextService {
|
||||||
|
private readonly storage = new AsyncLocalStorage<RequestContextData>();
|
||||||
|
|
||||||
|
run<T>(context: RequestContextData, callback: () => T): T {
|
||||||
|
return this.storage.run(context, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): RequestContextData {
|
||||||
|
return this.storage.getStore() ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Ber, BerWriter, Client } from 'ldapts';
|
import { Ber, BerWriter, Client } from 'ldapts';
|
||||||
|
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
|
||||||
|
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
|
||||||
|
import { markErrorAsLogged } from '../application-error-log/logged-error-marker';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
|
|
||||||
interface LldapUserInput {
|
interface LldapUserInput {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -75,7 +79,11 @@ export class LldapService {
|
|||||||
|
|
||||||
private cachedHeaders?: { expiresAt: number; headers: Record<string, string> };
|
private cachedHeaders?: { expiresAt: number; headers: Record<string, string> };
|
||||||
|
|
||||||
constructor(private readonly config: ConfigService) {}
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async createUser(input: LldapUserInput): Promise<void> {
|
async createUser(input: LldapUserInput): Promise<void> {
|
||||||
await this.graphql(
|
await this.graphql(
|
||||||
@@ -113,7 +121,26 @@ export class LldapService {
|
|||||||
this.passwordModifyRequestValue(this.userDn(username), password),
|
this.passwordModifyRequestValue(this.userDn(username), password),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`);
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EXTERNAL_API,
|
||||||
|
code: ApplicationErrorCode.EXTERNAL_API_REQUEST_FAILED,
|
||||||
|
module: 'LldapModule',
|
||||||
|
service: LldapService.name,
|
||||||
|
operation: 'setPassword',
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
userId: username,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
provider: 'LLDAP',
|
||||||
|
protocol: 'LDAP',
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
const exception = new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`);
|
||||||
|
markErrorAsLogged(exception);
|
||||||
|
throw exception;
|
||||||
} finally {
|
} finally {
|
||||||
await client.unbind().catch(() => undefined);
|
await client.unbind().catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -394,15 +421,22 @@ export class LldapService {
|
|||||||
|
|
||||||
private async graphql<T = unknown>(query: string, variables: Record<string, unknown>): Promise<T> {
|
private async graphql<T = unknown>(query: string, variables: Record<string, unknown>): Promise<T> {
|
||||||
const endpoint = `${this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '')}/api/graphql`;
|
const endpoint = `${this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '')}/api/graphql`;
|
||||||
const headers = await this.adminHeaders();
|
let response: Response;
|
||||||
const response = await fetch(endpoint, {
|
try {
|
||||||
method: 'POST',
|
const headers = await this.adminHeaders();
|
||||||
headers: {
|
response = await fetch(endpoint, {
|
||||||
'content-type': 'application/json',
|
method: 'POST',
|
||||||
...headers,
|
headers: {
|
||||||
},
|
'content-type': 'application/json',
|
||||||
body: JSON.stringify({ query, variables }),
|
...headers,
|
||||||
});
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await this.logGraphqlFailure(error, endpoint, 'request_failed');
|
||||||
|
markErrorAsLogged(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => ({}))) as {
|
const payload = (await response.json().catch(() => ({}))) as {
|
||||||
data?: T;
|
data?: T;
|
||||||
@@ -414,11 +448,21 @@ export class LldapService {
|
|||||||
if (/not found/i.test(message)) {
|
if (/not found/i.test(message)) {
|
||||||
throw new NotFoundException('LLDAP user not found');
|
throw new NotFoundException('LLDAP user not found');
|
||||||
}
|
}
|
||||||
throw new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`);
|
const exception = new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`);
|
||||||
|
await this.logGraphqlFailure(exception, endpoint, 'bad_response', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
errorMessages: payload.errors?.map((error) => error.message),
|
||||||
|
});
|
||||||
|
markErrorAsLogged(exception);
|
||||||
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload.data) {
|
if (!payload.data) {
|
||||||
throw new InternalServerErrorException('LLDAP GraphQL response did not contain data');
|
const exception = new InternalServerErrorException('LLDAP GraphQL response did not contain data');
|
||||||
|
await this.logGraphqlFailure(exception, endpoint, 'missing_data', { status: response.status });
|
||||||
|
markErrorAsLogged(exception);
|
||||||
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload.data;
|
return payload.data;
|
||||||
@@ -484,4 +528,28 @@ export class LldapService {
|
|||||||
private errorMessage(error: unknown): string {
|
private errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : 'unknown error';
|
return error instanceof Error ? error.message : 'unknown error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async logGraphqlFailure(
|
||||||
|
error: unknown,
|
||||||
|
endpoint: string,
|
||||||
|
reason: string,
|
||||||
|
context: Record<string, unknown> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EXTERNAL_API,
|
||||||
|
code: ApplicationErrorCode.EXTERNAL_API_REQUEST_FAILED,
|
||||||
|
module: 'LldapModule',
|
||||||
|
service: LldapService.name,
|
||||||
|
operation: 'graphql',
|
||||||
|
requestContext: this.requestContext.get(),
|
||||||
|
context: {
|
||||||
|
provider: 'LLDAP',
|
||||||
|
endpoint,
|
||||||
|
reason,
|
||||||
|
...context,
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateApplicationErrorLogs1721200000000 implements MigrationInterface {
|
||||||
|
name = 'CreateApplicationErrorLogs1721200000000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'application_error_logs',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'varchar', length: '36', isPrimary: true },
|
||||||
|
{ name: 'level', type: 'varchar', length: '255', default: "'error'" },
|
||||||
|
{ name: 'category', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'code', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'message', type: 'text' },
|
||||||
|
{ name: 'stackTrace', type: 'text', isNullable: true },
|
||||||
|
{ name: 'errorType', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'backendModule', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'service', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'operation', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'httpMethod', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'apiPath', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'httpStatusCode', type: 'int', isNullable: true },
|
||||||
|
{ name: 'correlationId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'userId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'tenantId', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'environment', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'host', type: 'varchar', length: '255', isNullable: true },
|
||||||
|
{ name: 'context', type: 'text', isNullable: true },
|
||||||
|
{ name: 'handled', type: 'tinyint', default: 0 },
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 6,
|
||||||
|
default: 'CURRENT_TIMESTAMP(6)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const columnName of [
|
||||||
|
'createdAt',
|
||||||
|
'code',
|
||||||
|
'category',
|
||||||
|
'correlationId',
|
||||||
|
'userId',
|
||||||
|
'tenantId',
|
||||||
|
'httpStatusCode',
|
||||||
|
]) {
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'application_error_logs',
|
||||||
|
new TableIndex({
|
||||||
|
name: `IDX_application_error_logs_${columnName}`,
|
||||||
|
columnNames: [columnName],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('application_error_logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/api/src/password/password.service.spec.ts
Normal file
68
apps/api/src/password/password.service.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, jest } from '@jest/globals';
|
||||||
|
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
|
||||||
|
import { PasswordService } from './password.service';
|
||||||
|
|
||||||
|
describe('PasswordService', () => {
|
||||||
|
it('logs password reset email failures with the stable error code', async () => {
|
||||||
|
const resetTokens = {
|
||||||
|
create: jest.fn((input) => input),
|
||||||
|
save: jest.fn(async (input: Record<string, unknown>) => ({ ...input, id: 'reset-token-id' })),
|
||||||
|
delete: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const config = {
|
||||||
|
get: jest.fn((key: string) => (key === 'SMTP_HOST' ? 'smtp.example.com' : undefined)),
|
||||||
|
getOrThrow: jest.fn(() => 'test-secret'),
|
||||||
|
};
|
||||||
|
const lldap = {
|
||||||
|
findUserByEmail: jest
|
||||||
|
.fn<(email: string) => Promise<{ id: string; email: string }>>()
|
||||||
|
.mockResolvedValue({ id: 'user-1', email: 'max@example.com' }),
|
||||||
|
};
|
||||||
|
const mailError = new Error('provider rejected max@example.com');
|
||||||
|
const mail = {
|
||||||
|
sendPasswordResetMail: jest.fn<(email: string, token: string) => Promise<void>>().mockRejectedValue(mailError),
|
||||||
|
};
|
||||||
|
const audit = {
|
||||||
|
record: jest.fn<(input: unknown) => Promise<void>>().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const applicationErrorLogger = {
|
||||||
|
log: jest.fn<(input: unknown) => Promise<void>>().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const requestContext = {
|
||||||
|
get: jest.fn(() => ({ correlationId: 'corr-1', method: 'POST', path: '/password/reset/request' })),
|
||||||
|
};
|
||||||
|
const service = new PasswordService(
|
||||||
|
resetTokens as never,
|
||||||
|
config as never,
|
||||||
|
{} as never,
|
||||||
|
lldap as never,
|
||||||
|
mail as never,
|
||||||
|
audit as never,
|
||||||
|
applicationErrorLogger as never,
|
||||||
|
requestContext as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.requestReset('max@example.com', '127.0.0.1', 'jest')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(applicationErrorLogger.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: mailError,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.PASSWORD_RESET_EMAIL_SEND_FAILED,
|
||||||
|
operation: 'sendPasswordResetEmail',
|
||||||
|
requestContext: expect.objectContaining({
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
}),
|
||||||
|
context: expect.objectContaining({
|
||||||
|
maskedRecipient: 'm***@example.com',
|
||||||
|
mailProvider: 'smtp.example.com',
|
||||||
|
}),
|
||||||
|
handled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,11 +3,16 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
|
||||||
|
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
|
||||||
|
import { markErrorAsLogged } from '../application-error-log/logged-error-marker';
|
||||||
import { assertPasswordPolicy } from '../common/password-policy';
|
import { assertPasswordPolicy } from '../common/password-policy';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
import { hashToken, randomToken } from '../common/token.util';
|
import { hashToken, randomToken } from '../common/token.util';
|
||||||
import { LdapAuthService } from '../lldap/ldap-auth.service';
|
import { LdapAuthService } from '../lldap/ldap-auth.service';
|
||||||
import { LldapService } from '../lldap/lldap.service';
|
import { LldapService } from '../lldap/lldap.service';
|
||||||
import { PortalMailService } from '../mail/portal-mail.service';
|
import { PortalMailService } from '../mail/portal-mail.service';
|
||||||
|
import { maskEmail } from '../application-error-log/application-error-sanitizer';
|
||||||
import { PasswordResetToken } from './password-reset-token.entity';
|
import { PasswordResetToken } from './password-reset-token.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -20,6 +25,8 @@ export class PasswordService {
|
|||||||
private readonly lldap: LldapService,
|
private readonly lldap: LldapService,
|
||||||
private readonly mail: PortalMailService,
|
private readonly mail: PortalMailService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async changePassword(
|
async changePassword(
|
||||||
@@ -73,6 +80,24 @@ export class PasswordService {
|
|||||||
await this.mail.sendPasswordResetMail(user.email, token);
|
await this.mail.sendPasswordResetMail(user.email, token);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.resetTokens.delete({ id: resetToken.id }).catch(() => undefined);
|
await this.resetTokens.delete({ id: resetToken.id }).catch(() => undefined);
|
||||||
|
const currentRequestContext = this.requestContext.get();
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.PASSWORD_RESET_EMAIL_SEND_FAILED,
|
||||||
|
module: 'PasswordModule',
|
||||||
|
service: PasswordService.name,
|
||||||
|
operation: 'sendPasswordResetEmail',
|
||||||
|
requestContext: {
|
||||||
|
...currentRequestContext,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
maskedRecipient: maskEmail(user.email),
|
||||||
|
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
await this.audit
|
await this.audit
|
||||||
.record({
|
.record({
|
||||||
type: 'password.reset_mail_failed',
|
type: 'password.reset_mail_failed',
|
||||||
@@ -82,9 +107,11 @@ export class PasswordService {
|
|||||||
metadata: { error: this.errorMessage(error) },
|
metadata: { error: this.errorMessage(error) },
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
throw new ServiceUnavailableException(
|
const exception = new ServiceUnavailableException(
|
||||||
'Der Reset-Link konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
|
'Der Reset-Link konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
|
||||||
);
|
);
|
||||||
|
markErrorAsLogged(exception);
|
||||||
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, IsNull, Repository } from 'typeorm';
|
import { In, IsNull, Repository } from 'typeorm';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes';
|
||||||
|
import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service';
|
||||||
|
import { maskEmail } from '../application-error-log/application-error-sanitizer';
|
||||||
|
import { markErrorAsLogged } from '../application-error-log/logged-error-marker';
|
||||||
import { assertPasswordPolicy } from '../common/password-policy';
|
import { assertPasswordPolicy } from '../common/password-policy';
|
||||||
|
import { RequestContextService } from '../common/request-context.service';
|
||||||
import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util';
|
import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util';
|
||||||
import { LldapService } from '../lldap/lldap.service';
|
import { LldapService } from '../lldap/lldap.service';
|
||||||
import { PortalMailService } from '../mail/portal-mail.service';
|
import { PortalMailService } from '../mail/portal-mail.service';
|
||||||
@@ -22,6 +27,8 @@ export class RegistrationService {
|
|||||||
private readonly lldap: LldapService,
|
private readonly lldap: LldapService,
|
||||||
private readonly mail: PortalMailService,
|
private readonly mail: PortalMailService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) {
|
async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) {
|
||||||
@@ -65,6 +72,23 @@ export class RegistrationService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.emailTokens.delete({ registrationId: registration.id }).catch(() => undefined);
|
await this.emailTokens.delete({ registrationId: registration.id }).catch(() => undefined);
|
||||||
await this.registrations.delete({ id: registration.id }).catch(() => undefined);
|
await this.registrations.delete({ id: registration.id }).catch(() => undefined);
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED,
|
||||||
|
module: 'RegistrationModule',
|
||||||
|
service: RegistrationService.name,
|
||||||
|
operation: 'sendVerificationMail',
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
userId: registration.username,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
maskedRecipient: maskEmail(registration.email),
|
||||||
|
mailProvider: this.config.get<string>('SMTP_HOST') ?? 'smtp',
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
await this.audit
|
await this.audit
|
||||||
.record({
|
.record({
|
||||||
type: 'registration.verification_mail_failed',
|
type: 'registration.verification_mail_failed',
|
||||||
@@ -74,9 +98,11 @@ export class RegistrationService {
|
|||||||
metadata: { error: this.errorMessage(error) },
|
metadata: { error: this.errorMessage(error) },
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
throw new ServiceUnavailableException(
|
const exception = new ServiceUnavailableException(
|
||||||
'Die Bestaetigungs-E-Mail konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
|
'Die Bestaetigungs-E-Mail konnte nicht versendet werden. Bitte versuche es spaeter erneut.',
|
||||||
);
|
);
|
||||||
|
markErrorAsLogged(exception);
|
||||||
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
@@ -114,13 +140,30 @@ export class RegistrationService {
|
|||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.notifyUserManagers(registration).catch((error) =>
|
await this.notifyUserManagers(registration).catch(async (error) => {
|
||||||
this.audit.record({
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.REGISTRATION_APPROVAL_NOTIFICATION_FAILED,
|
||||||
|
module: 'RegistrationModule',
|
||||||
|
service: RegistrationService.name,
|
||||||
|
operation: 'notifyUserManagers',
|
||||||
|
requestContext: {
|
||||||
|
...this.requestContext.get(),
|
||||||
|
userId: registration.username,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
registrationId: registration.id,
|
||||||
|
maskedRecipient: maskEmail(registration.email),
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
return this.audit.record({
|
||||||
type: 'registration.approval_notification_failed',
|
type: 'registration.approval_notification_failed',
|
||||||
username: registration.username,
|
username: registration.username,
|
||||||
metadata: { error: this.errorMessage(error) },
|
metadata: { error: this.errorMessage(error) },
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' };
|
return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true
|
"removeComments": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["dist", "node_modules", "test", "src/**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
75
docs/application-error-logging.md
Normal file
75
docs/application-error-logging.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Application Error Logging
|
||||||
|
|
||||||
|
Das Backend speichert technische Fehler zentral in `application_error_logs`. Die Tabelle ist fuer Fehleranalyse gedacht, nicht fuer fachliches Audit-Logging; bestehende Audit-Events bleiben unveraendert.
|
||||||
|
|
||||||
|
## Felder
|
||||||
|
|
||||||
|
- `level`: technischer Schweregrad, aktuell `error` oder `warning`.
|
||||||
|
- `category`: grobe Fehlergruppe, z. B. `EMAIL`, `EXTERNAL_API`, `DATABASE`, `BACKGROUND_JOB`, `FILE`, `UNHANDLED`.
|
||||||
|
- `code`: stabiler maschinenlesbarer Fehlercode, z. B. `PASSWORD_RESET_EMAIL_SEND_FAILED`.
|
||||||
|
- `message`, `stackTrace`, `errorType`: technische Fehlerdaten aus `Error`, `HttpException` oder unbekannten Fehlerwerten.
|
||||||
|
- `backendModule`, `service`, `operation`: fachliche Herkunft im Backend.
|
||||||
|
- `httpMethod`, `apiPath`, `httpStatusCode`: HTTP-Kontext, sofern vorhanden.
|
||||||
|
- `correlationId`: Request-ID aus `X-Correlation-ID` oder eine pro Request erzeugte UUID.
|
||||||
|
- `userId`, `tenantId`: Benutzer- und Mandantenkontext, sofern vorhanden.
|
||||||
|
- `environment`, `host`: Laufzeitumgebung und Instanz.
|
||||||
|
- `context`: bereinigte strukturierte Zusatzdaten.
|
||||||
|
- `handled`: `true`, wenn der Fehler gezielt behandelt und protokolliert wurde; `false` fuer unerwartete globale Exceptions.
|
||||||
|
|
||||||
|
## Erfassung
|
||||||
|
|
||||||
|
Der globale NestJS-Exception-Filter protokolliert unerwartete Exceptions und technische HTTP-Fehler ab Status 500. Erwartete fachliche Fehler wie Validierung, fehlende Berechtigungen, bewusstes 404 oder Konflikte werden nicht automatisch als Systemfehler gespeichert.
|
||||||
|
|
||||||
|
Explizit protokolliert werden derzeit:
|
||||||
|
|
||||||
|
- Passwort-Reset-Mailfehler mit `PASSWORD_RESET_EMAIL_SEND_FAILED`.
|
||||||
|
- Registrierungs-Bestaetigungs-Mailfehler mit `REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED`.
|
||||||
|
- Benachrichtigung an User-Manager mit `REGISTRATION_APPROVAL_NOTIFICATION_FAILED`.
|
||||||
|
- LLDAP/GraphQL- und LDAP-Integrationsfehler mit `EXTERNAL_API_REQUEST_FAILED`.
|
||||||
|
|
||||||
|
Asynchrone Prozesse ohne HTTP-Request sollen den `ApplicationErrorLoggerService` direkt verwenden und `handled: true` setzen.
|
||||||
|
|
||||||
|
## Datenschutz
|
||||||
|
|
||||||
|
Der Logger entfernt sensible Schluessel rekursiv und case-insensitive, darunter `password`, `currentPassword`, `newPassword`, `token`, `accessToken`, `refreshToken`, `idToken`, `authorization`, `cookie`, `secret`, `apiKey`, `clientSecret`, `resetToken` und `sessionId`.
|
||||||
|
|
||||||
|
Kontextdaten werden in Tiefe, Array-Laenge, Schluesselanzahl und String-Laenge begrenzt. Zirkulaere Objekte werden sicher ersetzt. E-Mail-Adressen in freien Texten werden maskiert, z. B. `m***@example.com`.
|
||||||
|
|
||||||
|
## Fehlersuche
|
||||||
|
|
||||||
|
1. Korrelations-ID aus Response-Header `X-Correlation-ID` oder Backend-Log notieren.
|
||||||
|
2. In `application_error_logs` nach `correlationId`, `code`, `category`, `userId` oder Zeitraum suchen.
|
||||||
|
3. `handled` pruefen: `false` weist auf einen unerwarteten globalen Fehler hin, `true` auf einen gezielt behandelten technischen Fehler.
|
||||||
|
4. `context` fuer Provider-Status, maskierte Empfaenger oder Integrationsdetails verwenden.
|
||||||
|
|
||||||
|
## Beispiel
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
await this.mail.sendPasswordResetMail(user.email, token);
|
||||||
|
} catch (error) {
|
||||||
|
await this.applicationErrorLogger.log({
|
||||||
|
error,
|
||||||
|
category: ApplicationErrorCategory.EMAIL,
|
||||||
|
code: ApplicationErrorCode.PASSWORD_RESET_EMAIL_SEND_FAILED,
|
||||||
|
module: 'PasswordModule',
|
||||||
|
service: PasswordService.name,
|
||||||
|
operation: 'sendPasswordResetEmail',
|
||||||
|
requestContext: {
|
||||||
|
correlationId,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
maskedRecipient: maskEmail(user.email),
|
||||||
|
mailProvider,
|
||||||
|
},
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aufbewahrung
|
||||||
|
|
||||||
|
Es gibt im Projekt derzeit keinen Scheduler oder Queue-Mechanismus. Empfohlen ist ein spaeterer, begrenzter Cleanup-Job, der Fehler aelter als 180 Tage in Batches loescht und kritische Kategorien bei Bedarf laenger aufbewahrt. Die Tabelle besitzt Indizes auf `createdAt`, `code`, `category`, `correlationId`, `userId`, `tenantId` und `httpStatusCode`.
|
||||||
Reference in New Issue
Block a user