UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

418 lines (354 loc) 11.4 kB
import { $inject, Alepha } from "alepha"; import { $logger } from "alepha/logger"; import { $repository, type Page } from "alepha/orm"; import type { ServerRequest } from "alepha/server"; import { type AuditEntity, type AuditSeverity, audits, } from "../entities/audits.ts"; import type { AuditQuery } from "../schemas/auditQuerySchema.ts"; import type { CreateAudit } from "../schemas/createAuditSchema.ts"; /** * Registered audit type definition. */ export interface AuditTypeDefinition { type: string; description?: string; actions: string[]; /** * Dedicated retention (in days) for this audit type. * * Overrides the global default applied by the cleanup job. `0` keeps entries * forever. When `undefined`, the global default retention applies. */ retentionDays?: number; } /** * Service for managing audit logs. * * Provides methods for: * - Creating audit entries * - Querying audit history * - Aggregating audit statistics * - Managing registered audit types */ export class AuditService { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly repo = $repository(audits); /** * Registry of audit types and their allowed actions. */ protected readonly auditTypes = new Map<string, AuditTypeDefinition>(); /** * Register an audit type with its allowed actions. */ public registerType(definition: AuditTypeDefinition): void { this.auditTypes.set(definition.type, definition); this.log.debug("Audit type registered", { type: definition.type, actions: definition.actions, }); } /** * Get all registered audit types. */ public getRegisteredTypes(): AuditTypeDefinition[] { return Array.from(this.auditTypes.values()); } /** * Distinct action names across all registered audit types, sorted. * * Sourced from the `$audit` type registry. Audit types register lazily — * when their holder (e.g. `SessionAudits`, `ParameterAudits`) is first * injected — so the admin filter only lists actions for audit domains that * are actually in use, which is the intended behaviour. */ public getDistinctActions(): string[] { return [ ...new Set(this.getRegisteredTypes().flatMap((type) => type.actions)), ].sort(); } /** * Get current request context if available. */ protected getRequestContext(): ServerRequest | undefined { return this.alepha.store.get("alepha.http.request"); } /** * Create a new audit log entry. * Automatically populates ipAddress, userAgent, and requestId from the current request context. */ public async create(data: CreateAudit): Promise<AuditEntity> { const request = this.getRequestContext(); // Auto-populate from request context if not provided const contextData: Partial<CreateAudit> = {}; if (request) { if (!data.ipAddress && request.ip) { contextData.ipAddress = request.ip; } if (!data.userAgent && request.headers["user-agent"]) { contextData.userAgent = request.headers["user-agent"]; } if (!data.requestId && request.requestId) { contextData.requestId = request.requestId; } // Check for session in metadata if (!data.sessionId && request.metadata?.sessionId) { contextData.sessionId = request.metadata.sessionId; } // Extract user from request.user (set by ServerSecurityProvider) const user = request.user; if (user) { if (!data.userId && user.id) { contextData.userId = user.id; } if (!data.userEmail && user.email) { contextData.userEmail = user.email; } if (!data.userRealm && user.realm) { contextData.userRealm = user.realm; } } } this.log.trace("Creating audit entry", { type: data.type, action: data.action, userId: data.userId ?? contextData.userId, }); const success = data.success ?? true; const entry = await this.repo.create({ ...contextData, ...data, // Outcome drives severity: a failed audit (success:false) defaults to // `warning`, otherwise `info`. Explicit `severity` always wins. This is // the single place the OK/Failed → severity rule lives, so holders and // `$audit` primitives don't repeat it. severity: data.severity ?? (success ? "info" : "warning"), success, }); this.log.debug("Audit entry created", { id: entry.id, type: data.type, action: data.action, }); return entry; } /** * Record an audit event (convenience method). */ public async record( type: string, action: string, options: Omit<CreateAudit, "type" | "action"> = {}, ): Promise<AuditEntity> { return this.create({ type, action, ...options }); } /** * Find audit entries with filtering and pagination. */ public async find(query: AuditQuery = {}): Promise<Page<AuditEntity>> { this.log.trace("Finding audit entries", { query }); query.sort ??= "-createdAt"; const where = this.repo.createQueryWhere(); if (query.type) { where.type = { eq: query.type }; } if (query.action) { where.action = { eq: query.action }; } if (query.severity) { where.severity = { eq: query.severity }; } if (query.userId) { where.userId = { eq: query.userId }; } if (query.userRealm) { where.userRealm = { eq: query.userRealm }; } if (query.resourceType) { where.resourceType = { eq: query.resourceType }; } if (query.resourceId) { where.resourceId = { eq: query.resourceId }; } if (query.success !== undefined) { where.success = { eq: query.success }; } if (query.from) { where.createdAt = { ...(where.createdAt as object), gte: query.from }; } if (query.to) { where.createdAt = { ...(where.createdAt as object), lte: query.to }; } if (query.search) { where.description = { like: `%${query.search}%` }; } const result = await this.repo.paginate(query, { where }, { count: true }); this.log.debug("Audit entries found", { count: result.content.length, total: result.page.totalElements, }); return result; } /** * Get audit entry by ID. */ public async getById(id: string): Promise<AuditEntity> { return this.repo.getById(id); } /** * Get audit entries for a specific user. */ public async findByUser( userId: string, query: Omit<AuditQuery, "userId"> = {}, ): Promise<Page<AuditEntity>> { return this.find({ ...query, userId }); } /** * Get audit entries for a specific resource. */ public async findByResource( resourceType: string, resourceId: string, query: Omit<AuditQuery, "resourceType" | "resourceId"> = {}, ): Promise<Page<AuditEntity>> { return this.find({ ...query, resourceType, resourceId }); } /** * Get audit statistics for a time period. */ public async getStats( options: { from?: Date; to?: Date; userRealm?: string } = {}, ): Promise<AuditStats> { this.log.trace("Getting audit stats", options); const where = this.repo.createQueryWhere(); if (options.from) { where.createdAt = { gte: options.from.toISOString() }; } if (options.to) { where.createdAt = { ...(where.createdAt as object), lte: options.to.toISOString(), }; } if (options.userRealm) { where.userRealm = { eq: options.userRealm }; } const all = await this.repo.findMany({ where }); const stats: AuditStats = { total: all.length, byType: {}, bySeverity: { info: 0, warning: 0, critical: 0 }, successRate: 0, recentFailures: [], }; let successCount = 0; for (const entry of all) { // Count by type stats.byType[entry.type] = (stats.byType[entry.type] || 0) + 1; // Count by severity const severity = entry.severity as AuditSeverity; stats.bySeverity[severity]++; // Count successes if (entry.success) { successCount++; } } stats.successRate = stats.total > 0 ? successCount / stats.total : 1; // Get recent failures const failures = all .filter((e) => !e.success) .sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ) .slice(0, 10); stats.recentFailures = failures; return stats; } /** * Delete audit entries created before the given date (retention policy). * * @returns number of deleted entries. */ public async deleteOlderThan(date: Date): Promise<number> { this.log.info("Deleting old audit entries", { olderThan: date }); const deleted = await this.repo.deleteMany({ createdAt: { lt: date.toISOString() }, }); this.log.info("Old audit entries deleted", { count: deleted.length }); return deleted.length; } /** * Delete audit entries that have outlived their retention window. * * Each registered audit type that declares a dedicated `retentionDays` is * pruned using its own window; every other type — including unregistered or * legacy types — falls back to `defaultRetentionDays`. A retention of `0` * (per-type or default) keeps that scope forever. * * `now` is injected rather than read from the clock so the policy is * deterministic and testable. * * @param now - reference "now" used to compute cutoff dates. * @param defaultRetentionDays - global default for types without an override. * @returns total number of deleted entries. */ public async deleteExpired( now: Date, defaultRetentionDays: number, ): Promise<number> { const dayMs = 24 * 60 * 60 * 1000; const cutoff = (days: number): string => new Date(now.getTime() - days * dayMs).toISOString(); // Audit types carrying their own retention window. const overrides = this.getRegisteredTypes().filter( (type) => type.retentionDays !== undefined, ); let deleted = 0; // 1) Per-type dedicated retention. for (const type of overrides) { const days = type.retentionDays as number; if (days <= 0) { continue; // keep this type forever } const ids = await this.repo.deleteMany({ type: { eq: type.type }, createdAt: { lt: cutoff(days) }, }); deleted += ids.length; } // 2) Global default for every other type. if (defaultRetentionDays > 0) { const overriddenTypes = overrides.map((type) => type.type); const ids = overriddenTypes.length > 0 ? await this.repo.deleteMany({ type: { notInArray: overriddenTypes }, createdAt: { lt: cutoff(defaultRetentionDays) }, }) : await this.repo.deleteMany({ createdAt: { lt: cutoff(defaultRetentionDays) }, }); deleted += ids.length; } this.log.info("Expired audit entries deleted", { count: deleted, defaultRetentionDays, }); return deleted; } } /** * Audit statistics summary. */ export interface AuditStats { total: number; byType: Record<string, number>; bySeverity: Record<AuditSeverity, number>; successRate: number; recentFailures: AuditEntity[]; }