alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
798 lines (797 loc) • 23.4 kB
JavaScript
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