UNPKG

alepha

Version:

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

798 lines (797 loc) 23.4 kB
import { $atom, $inject, $module, $state, Alepha, KIND, Primitive, createPrimitive, z } from "alepha"; import { $entity, $repository, db, pageQuerySchema } from "alepha/orm"; import { $secure } from "alepha/security"; import { $action } from "alepha/server"; import { $logger } from "alepha/logger"; import { $scheduler } from "alepha/scheduler"; //#region ../../src/api/audits/entities/audits.ts /** * Audit severity levels for categorizing events. */ const auditSeveritySchema = z.enum([ "info", "warning", "critical" ]).describe("Severity level of the audit event").default("info"); /** * Audit log entity for tracking important system events. * * Stores comprehensive audit information including: * - Who performed the action (userId, userRealm) * - What happened (type, action, resource) * - When it happened (createdAt) * - Context and details (metadata, ipAddress, userAgent) */ const audits = $entity({ name: "audits", schema: z.object({ id: db.primaryKey(z.bigint()), createdAt: db.createdAt(), organizationId: db.organization(), /** * Audit event type (e.g., "auth", "user", "payment", "system"). * Used for categorizing and filtering audit events. */ type: z.text({ description: "Audit event type (e.g., auth, user, payment, system)" }), /** * Specific action performed (e.g., "login", "logout", "create", "update", "delete"). */ action: z.text({ description: "Specific action performed (e.g., login, create, update)" }), /** * Severity level of the event. */ severity: db.default(auditSeveritySchema, "info"), /** * User ID who performed the action (null for system events). */ userId: z.uuid().optional(), /** * User realm for multi-tenant support. */ userRealm: z.text().optional(), /** * User email at the time of the event (denormalized for history). */ userEmail: z.email().optional(), /** * Resource type affected (e.g., "user", "order", "file"). */ resourceType: z.text().optional(), /** * Resource ID affected. */ resourceId: z.text().optional(), /** * Human-readable description of the event. */ description: z.text().optional(), /** * Additional metadata/context as JSON. */ metadata: z.json().optional(), /** * Client IP address. */ ipAddress: z.text().optional(), /** * Client user agent. */ userAgent: z.text().optional(), /** * Session ID if applicable. */ sessionId: z.uuid().optional(), /** * Request ID for correlation. */ requestId: z.text().optional(), /** * Whether the action was successful. */ success: db.default(z.boolean(), true), /** * Error message if the action failed. */ errorMessage: z.text().optional() }), indexes: [ "createdAt", "type", "action", "userId", "userRealm", "resourceType", "resourceId", "severity", { columns: ["type", "action"] }, { columns: ["userId", "createdAt"] }, { columns: ["userRealm", "createdAt"] } ] }); const auditEntitySchema = audits.schema; const auditEntityInsertSchema = audits.insertSchema; //#endregion //#region ../../src/api/audits/schemas/auditQuerySchema.ts /** * Query schema for searching and filtering audit logs. */ const auditQuerySchema = pageQuerySchema.extend({ type: z.text({ description: "Filter by audit type" }).optional(), action: z.text({ description: "Filter by action" }).optional(), severity: auditSeveritySchema.optional(), userId: z.uuid().describe("Filter by user ID").optional(), userRealm: z.text({ description: "Filter by user realm" }).optional(), resourceType: z.text({ description: "Filter by resource type" }).optional(), resourceId: z.text({ description: "Filter by resource ID" }).optional(), success: z.boolean().describe("Filter by success status").optional(), from: z.datetime().describe("Start date filter").optional(), to: z.datetime().describe("End date filter").optional(), search: z.text({ description: "Search in description" }).optional() }); //#endregion //#region ../../src/api/audits/schemas/auditResourceSchema.ts /** * Resource schema for audit log responses. */ const auditResourceSchema = audits.schema; //#endregion //#region ../../src/api/audits/schemas/createAuditSchema.ts /** * Schema for creating a new audit log entry. */ const createAuditSchema = z.object({ type: z.text({ description: "Audit event type" }), action: z.text({ description: "Specific action performed" }), severity: auditSeveritySchema.optional(), userId: z.uuid().optional(), userRealm: z.text().optional(), userEmail: z.email().optional(), resourceType: z.text().optional(), resourceId: z.text().optional(), description: z.text().optional(), metadata: z.json().optional(), ipAddress: z.text().optional(), userAgent: z.text().optional(), sessionId: z.uuid().optional(), requestId: z.text().optional(), success: z.boolean().optional(), errorMessage: z.text().optional() }); //#endregion //#region ../../src/api/audits/services/AuditService.ts /** * Service for managing audit logs. * * Provides methods for: * - Creating audit entries * - Querying audit history * - Aggregating audit statistics * - Managing registered audit types */ var AuditService = class { alepha = $inject(Alepha); log = $logger(); repo = $repository(audits); /** * Registry of audit types and their allowed actions. */ auditTypes = /* @__PURE__ */ new Map(); /** * Register an audit type with its allowed actions. */ registerType(definition) { this.auditTypes.set(definition.type, definition); this.log.debug("Audit type registered", { type: definition.type, actions: definition.actions }); } /** * Get all registered audit types. */ getRegisteredTypes() { 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. */ getDistinctActions() { return [...new Set(this.getRegisteredTypes().flatMap((type) => type.actions))].sort(); } /** * Get current request context if available. */ getRequestContext() { 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. */ async create(data) { const request = this.getRequestContext(); const contextData = {}; 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; if (!data.sessionId && request.metadata?.sessionId) contextData.sessionId = request.metadata.sessionId; 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, 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). */ async record(type, action, options = {}) { return this.create({ type, action, ...options }); } /** * Find audit entries with filtering and pagination. */ async find(query = {}) { 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 !== void 0) where.success = { eq: query.success }; if (query.from) where.createdAt = { ...where.createdAt, gte: query.from }; if (query.to) where.createdAt = { ...where.createdAt, 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. */ async getById(id) { return this.repo.getById(id); } /** * Get audit entries for a specific user. */ async findByUser(userId, query = {}) { return this.find({ ...query, userId }); } /** * Get audit entries for a specific resource. */ async findByResource(resourceType, resourceId, query = {}) { return this.find({ ...query, resourceType, resourceId }); } /** * Get audit statistics for a time period. */ async getStats(options = {}) { 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, lte: options.to.toISOString() }; if (options.userRealm) where.userRealm = { eq: options.userRealm }; const all = await this.repo.findMany({ where }); const stats = { total: all.length, byType: {}, bySeverity: { info: 0, warning: 0, critical: 0 }, successRate: 0, recentFailures: [] }; let successCount = 0; for (const entry of all) { stats.byType[entry.type] = (stats.byType[entry.type] || 0) + 1; const severity = entry.severity; stats.bySeverity[severity]++; if (entry.success) successCount++; } stats.successRate = stats.total > 0 ? successCount / stats.total : 1; stats.recentFailures = all.filter((e) => !e.success).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 10); return stats; } /** * Delete audit entries created before the given date (retention policy). * * @returns number of deleted entries. */ async deleteOlderThan(date) { 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. */ async deleteExpired(now, defaultRetentionDays) { const dayMs = 1440 * 60 * 1e3; const cutoff = (days) => (/* @__PURE__ */ new Date(now.getTime() - days * dayMs)).toISOString(); const overrides = this.getRegisteredTypes().filter((type) => type.retentionDays !== void 0); let deleted = 0; for (const type of overrides) { const days = type.retentionDays; if (days <= 0) continue; const ids = await this.repo.deleteMany({ type: { eq: type.type }, createdAt: { lt: cutoff(days) } }); deleted += ids.length; } 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; } }; //#endregion //#region ../../src/api/audits/controllers/AdminAuditController.ts /** * REST API controller for audit log management. * * Provides endpoints for: * - Querying audit logs with filtering * - Creating audit entries * - Getting audit statistics * - Viewing registered audit types */ var AdminAuditController = class { url = "/audits"; group = "admin:audits"; auditService = $inject(AuditService); repo = $repository(audits); /** * Find audit entries with filtering and pagination. */ findAudits = $action({ path: this.url, group: this.group, use: [$secure({ permissions: ["admin:audit:read"] })], description: "Find audit entries with filtering and pagination", schema: { query: auditQuerySchema, response: z.page(auditResourceSchema) }, handler: ({ query }) => this.auditService.find(query) }); /** * Get a single audit entry by ID. */ getAudit = $action({ path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:audit:read"] })], description: "Get a single audit entry by ID", schema: { params: z.object({ id: z.text() }), response: auditResourceSchema }, handler: ({ params }) => this.auditService.getById(params.id) }); /** * Delete many audit entries by id in one repository call. Use with care — * audit logs are usually retained for compliance reasons. */ deleteAudits = $action({ method: "POST", path: `${this.url}/delete`, group: this.group, use: [$secure({ permissions: ["admin:audit:delete"] })], description: "Delete many audit entries", schema: { body: z.object({ ids: z.array(z.text()).min(1).max(1e3) }), response: z.object({ deleted: z.array(z.text()) }) }, handler: async ({ body }) => { return { deleted: (await this.repo.deleteMany({ id: { inArray: body.ids } })).map(String) }; } }); /** * Create a new audit entry. * System-only — this permission should never be assigned to human roles. */ createAudit = $action({ method: "POST", path: this.url, group: this.group, use: [$secure({ permissions: ["admin:audit:create"] })], description: "Create a new audit entry", schema: { body: createAuditSchema, response: auditResourceSchema }, handler: ({ body }) => this.auditService.create(body) }); /** * Get audit entries for a specific user. */ findByUser = $action({ path: `${this.url}/user/:userId`, group: this.group, use: [$secure({ permissions: ["admin:audit:read"] })], description: "Get audit entries for a specific user", schema: { params: z.object({ userId: z.uuid() }), query: auditQuerySchema.omit({ userId: true }), response: z.page(auditResourceSchema) }, handler: ({ params, query }) => this.auditService.findByUser(params.userId, query) }); /** * Get audit entries for a specific resource. */ findByResource = $action({ path: `${this.url}/resource/:resourceType/:resourceId`, group: this.group, use: [$secure({ permissions: ["admin:audit:read"] })], description: "Get audit entries for a specific resource", schema: { params: z.object({ resourceType: z.text(), resourceId: z.text() }), query: auditQuerySchema.omit({ resourceType: true, resourceId: true }), response: z.page(auditResourceSchema) }, handler: ({ params, query }) => this.auditService.findByResource(params.resourceType, params.resourceId, query) }); /** * Get audit statistics. */ getAuditStats = $action({ path: `${this.url}/stats`, group: this.group, use: [$secure({ permissions: ["admin:audit:read"] })], description: "Get audit statistics for a time period", schema: { query: z.object({ from: z.datetime().optional(), to: z.datetime().optional(), userRealm: z.text().optional() }), response: z.object({ total: z.integer(), byType: z.record(z.text(), z.integer()), bySeverity: z.object({ info: z.integer(), warning: z.integer(), critical: z.integer() }), successRate: z.number(), recentFailures: z.array(auditResourceSchema) }) }, handler: ({ query }) => this.auditService.getStats({ from: query.from ? new Date(query.from) : void 0, to: query.to ? new Date(query.to) : void 0, userRealm: query.userRealm }) }); /** * List distinct action names present in the audit log (for filters). */ getAuditActions = $action({ path: `${this.url}/actions`, group: this.group, use: [$secure({ permissions: ["admin:audit:read"] })], description: "List distinct action names present in the audit log", schema: { response: z.array(z.text()) }, handler: () => this.auditService.getDistinctActions() }); /** * Get registered audit types. */ getTypes = $action({ path: `${this.url}/types`, group: this.group, use: [$secure({ permissions: ["admin:audit:read"] })], description: "Get all registered audit types", schema: { response: z.array(z.object({ type: z.text(), description: z.text().optional(), actions: z.array(z.text()) })) }, handler: () => this.auditService.getRegisteredTypes() }); /** * Get distinct values for filters. */ getFilterOptions = $action({ path: `${this.url}/filters`, group: this.group, use: [$secure({ permissions: ["admin:audit:read"] })], description: "Get distinct values for audit filters", schema: { response: z.object({ types: z.array(z.text()), actions: z.array(z.text()), resourceTypes: z.array(z.text()), userRealms: z.array(z.text()) }) }, handler: async () => { const types = this.auditService.getRegisteredTypes(); return { types: types.map((t) => t.type), actions: types.flatMap((t) => t.actions), resourceTypes: [ "user", "session", "file", "order", "payment" ], userRealms: ["default"] }; } }); }; //#endregion //#region ../../src/api/audits/parameters/AuditParameters.ts /** * Audit module configuration atom. */ const auditOptions = $atom({ name: "alepha.api.audits.options", schema: z.object({ /** * Default number of days audit entries are retained before the periodic * cleanup job ({@link AuditJobs}) deletes them. * * Applies to every audit type that does not declare its own * `retentionDays`. Set to `0` to disable automatic cleanup of those types * entirely (keep them forever). */ retentionDays: z.integer().min(0).describe("Default days to retain audit entries before cleanup. 0 disables cleanup.").default(90) }), default: { retentionDays: 90 } }); /** * Typed accessor for audit module configuration ({@link auditOptions}). */ var AuditParameters = class { options = $state(auditOptions); get(key) { return this.options[key]; } }; //#endregion //#region ../../src/api/audits/jobs/AuditJobs.ts /** * Scheduled enforcement of the audit retention policy. */ var AuditJobs = class { auditService = $inject(AuditService); auditParameters = $inject(AuditParameters); /** * Delete audit entries that have outlived their retention window. * * Runs daily and applies each audit type's dedicated `retentionDays` plus the * global default (`auditOptions.retentionDays`). A default of `0` disables * cleanup for types without a dedicated retention. */ cleanExpired = $scheduler({ name: "api:audits:cleanExpired", cron: "0 3 * * *", description: "Delete expired audit entries (retention policy)", handler: async ({ now }) => { const defaultRetentionDays = this.auditParameters.get("retentionDays"); await this.auditService.deleteExpired(now.toDate(), defaultRetentionDays); } }); }; //#endregion //#region ../../src/api/audits/primitives/$audit.ts /** * Audit type primitive for registering domain-specific audit events. * * Provides a type-safe way to define and log audit events within a specific domain. * * @example * ```ts * class PaymentAudits { * audit = $audit({ * type: "payment", * description: "Payment-related audit events", * actions: ["create", "refund", "cancel", "dispute"], * }); * * async logPaymentCreated(paymentId: string, userId: string, amount: number) { * await this.audit.log("create", { * userId, * resourceType: "payment", * resourceId: paymentId, * description: `Payment of ${amount} created`, * metadata: { amount }, * }); * } * } * ``` */ var AuditPrimitive = class extends Primitive { auditService = $inject(AuditService); /** * The audit type identifier. */ get type() { return this.options.type; } /** * The audit type description. */ get description() { return this.options.description; } /** * The allowed actions for this audit type. */ get actions() { return this.options.actions; } /** * The dedicated retention (in days) for this audit type, if set. */ get retentionDays() { return this.options.retentionDays; } /** * Log an audit event for this type. */ async log(action, options = {}) { await this.auditService.record(this.options.type, action, options); } /** * Log a successful audit event. */ async logSuccess(action, options = {}) { await this.log(action, { ...options, success: true }); } /** * Log a failed audit event. */ async logFailure(action, errorMessage, options = {}) { await this.log(action, { ...options, success: false, errorMessage }); } /** * Called during initialization to register this audit type. */ onInit() { const definition = { type: this.options.type, description: this.options.description, actions: this.options.actions, retentionDays: this.options.retentionDays }; this.auditService.registerType(definition); } }; /** * Create an audit type primitive. * * @example * ```ts * class OrderAudits { * audit = $audit({ * type: "order", * description: "Order management events", * actions: ["create", "update", "cancel", "fulfill", "ship"], * }); * } * ``` */ const $audit = (options) => { return createPrimitive(AuditPrimitive, options); }; $audit[KIND] = AuditPrimitive; //#endregion //#region ../../src/api/audits/index.ts /** * Audit trail for compliance. * * **Features:** * - Domain-specific audit types * - Audit event logging * - Filtering and searching * - User action tracking * - Retention policy with a default + per-type TTL ({@link AuditParameters}, {@link AuditJobs}) * * @module alepha.api.audits */ const AlephaApiAudits = $module({ name: "alepha.api.audits", services: [ AuditService, AdminAuditController, AuditParameters, AuditJobs ] }); //#endregion export { $audit, AdminAuditController, AlephaApiAudits, AuditJobs, AuditParameters, AuditPrimitive, AuditService, auditEntityInsertSchema, auditEntitySchema, auditOptions, auditQuerySchema, auditResourceSchema, auditSeveritySchema, audits, createAuditSchema }; //# sourceMappingURL=index.js.map