alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
418 lines (354 loc) • 11.4 kB
text/typescript
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[];
}