Merge 60827e0dae into 4b5514a259
This commit is contained in:
commit
9681eed8ef
@ -6,6 +6,7 @@ Docs: https://docs.molt.bot
|
||||
Status: beta.
|
||||
|
||||
### Changes
|
||||
- Security: add audit event logging for compliance and forensic analysis. (#3954)
|
||||
- Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope.
|
||||
- Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev.
|
||||
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
|
||||
|
||||
@ -191,6 +191,33 @@ export type GatewayHttpConfig = {
|
||||
endpoints?: GatewayHttpEndpointsConfig;
|
||||
};
|
||||
|
||||
/** Audit event categories for filtering. */
|
||||
export type GatewayAuditCategory =
|
||||
| "auth"
|
||||
| "authz"
|
||||
| "admin"
|
||||
| "config"
|
||||
| "rate_limit"
|
||||
| "channel"
|
||||
| "session";
|
||||
|
||||
/**
|
||||
* Security audit logging configuration.
|
||||
* Records security-relevant events for compliance and forensics.
|
||||
*/
|
||||
export type GatewayAuditLogConfig = {
|
||||
/** Enable audit event logging (default: false). */
|
||||
enabled?: boolean;
|
||||
/** Output target: 'file' or 'stdout' (default: 'file'). */
|
||||
target?: "file" | "stdout";
|
||||
/** File path for file target (default: ~/.clawdbot/audit/audit.jsonl). */
|
||||
filePath?: string;
|
||||
/** Categories to log (default: all). */
|
||||
categories?: GatewayAuditCategory[];
|
||||
/** Include request IDs for correlation (default: true). */
|
||||
includeRequestIds?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayNodesConfig = {
|
||||
/** Browser routing policy for node-hosted browser proxies. */
|
||||
browser?: {
|
||||
@ -233,6 +260,8 @@ export type GatewayConfig = {
|
||||
tls?: GatewayTlsConfig;
|
||||
http?: GatewayHttpConfig;
|
||||
nodes?: GatewayNodesConfig;
|
||||
/** Security audit event logging configuration. */
|
||||
auditLog?: GatewayAuditLogConfig;
|
||||
/**
|
||||
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
|
||||
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
|
||||
|
||||
254
src/security/audit-events.test.ts
Normal file
254
src/security/audit-events.test.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import { describe, expect, it, afterEach, vi, beforeEach } from "vitest";
|
||||
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
AuditLogger,
|
||||
resolveAuditLogConfig,
|
||||
type AuditEvent,
|
||||
} from "./audit-events.js";
|
||||
|
||||
describe("AuditLogger", () => {
|
||||
let testDir: string;
|
||||
let testLogPath: string;
|
||||
let logger: AuditLogger | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `audit-test-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
testLogPath = join(testDir, "audit.jsonl");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logger?.close();
|
||||
logger = null;
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("basic logging", () => {
|
||||
it("does not log when disabled", () => {
|
||||
logger = new AuditLogger({ enabled: false, target: "file", filePath: testLogPath });
|
||||
logger.logAuth({ action: "login", outcome: "success" });
|
||||
logger.close();
|
||||
|
||||
expect(existsSync(testLogPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("logs to file when enabled", async () => {
|
||||
logger = new AuditLogger({ enabled: true, target: "file", filePath: testLogPath });
|
||||
logger.logAuth({ action: "login", outcome: "success", actorId: "user-123" });
|
||||
logger.close();
|
||||
|
||||
// Wait for file write
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(existsSync(testLogPath)).toBe(true);
|
||||
const content = readFileSync(testLogPath, "utf8");
|
||||
const event = JSON.parse(content.trim());
|
||||
expect(event.category).toBe("auth");
|
||||
expect(event.action).toBe("login");
|
||||
expect(event.outcome).toBe("success");
|
||||
expect(event.actor.id).toBe("user-123");
|
||||
});
|
||||
|
||||
it("logs to stdout when target is stdout", () => {
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
logger = new AuditLogger({ enabled: true, target: "stdout" });
|
||||
logger.logAuth({ action: "login", outcome: "success" });
|
||||
logger.close();
|
||||
|
||||
expect(writeSpy).toHaveBeenCalled();
|
||||
const output = writeSpy.mock.calls[0]?.[0] as string;
|
||||
expect(output).toContain("[audit]");
|
||||
expect(output).toContain('"category":"auth"');
|
||||
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("calls custom handler when target is custom", () => {
|
||||
const events: AuditEvent[] = [];
|
||||
const customHandler = (event: AuditEvent) => events.push(event);
|
||||
|
||||
logger = new AuditLogger({
|
||||
enabled: true,
|
||||
target: "custom",
|
||||
customHandler,
|
||||
});
|
||||
|
||||
logger.logAuth({ action: "login", outcome: "success" });
|
||||
logger.close();
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.category).toBe("auth");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event types", () => {
|
||||
let events: AuditEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
events = [];
|
||||
logger = new AuditLogger({
|
||||
enabled: true,
|
||||
target: "custom",
|
||||
customHandler: (event) => events.push(event),
|
||||
});
|
||||
});
|
||||
|
||||
it("logs auth events correctly", () => {
|
||||
logger!.logAuth({
|
||||
action: "login",
|
||||
outcome: "failure",
|
||||
actorId: "user-123",
|
||||
actorIp: "192.168.1.1",
|
||||
method: "token",
|
||||
error: "Invalid token",
|
||||
});
|
||||
|
||||
expect(events[0]?.category).toBe("auth");
|
||||
expect(events[0]?.action).toBe("login");
|
||||
expect(events[0]?.outcome).toBe("failure");
|
||||
expect(events[0]?.actor?.id).toBe("user-123");
|
||||
expect(events[0]?.actor?.ip).toBe("192.168.1.1");
|
||||
expect(events[0]?.details?.method).toBe("token");
|
||||
expect(events[0]?.error).toBe("Invalid token");
|
||||
});
|
||||
|
||||
it("logs authz events correctly", () => {
|
||||
logger!.logAuthz({
|
||||
action: "access_denied",
|
||||
outcome: "denied",
|
||||
actorId: "user-456",
|
||||
resource: "/admin/settings",
|
||||
permission: "admin:write",
|
||||
reason: "Insufficient permissions",
|
||||
});
|
||||
|
||||
expect(events[0]?.category).toBe("authz");
|
||||
expect(events[0]?.action).toBe("access_denied");
|
||||
expect(events[0]?.target?.name).toBe("/admin/settings");
|
||||
expect(events[0]?.details?.permission).toBe("admin:write");
|
||||
expect(events[0]?.details?.reason).toBe("Insufficient permissions");
|
||||
});
|
||||
|
||||
it("logs admin events correctly", () => {
|
||||
logger!.logAdmin({
|
||||
action: "config_change",
|
||||
outcome: "success",
|
||||
actorId: "admin-1",
|
||||
targetType: "gateway",
|
||||
targetId: "config",
|
||||
changes: { bind: "lan" },
|
||||
});
|
||||
|
||||
expect(events[0]?.category).toBe("admin");
|
||||
expect(events[0]?.action).toBe("config_change");
|
||||
expect(events[0]?.target?.type).toBe("gateway");
|
||||
expect(events[0]?.details?.changes).toEqual({ bind: "lan" });
|
||||
});
|
||||
|
||||
it("logs rate limit events correctly", () => {
|
||||
logger!.logRateLimit({
|
||||
action: "request_limited",
|
||||
outcome: "denied",
|
||||
actorIp: "10.0.0.1",
|
||||
retryAfterMs: 5000,
|
||||
});
|
||||
|
||||
expect(events[0]?.category).toBe("rate_limit");
|
||||
expect(events[0]?.action).toBe("request_limited");
|
||||
expect(events[0]?.details?.retryAfterMs).toBe(5000);
|
||||
});
|
||||
|
||||
it("logs session events correctly", () => {
|
||||
logger!.logSession({
|
||||
action: "session_end",
|
||||
outcome: "success",
|
||||
actorId: "user-789",
|
||||
sessionId: "sess-abc",
|
||||
channel: "telegram",
|
||||
durationMs: 300000,
|
||||
});
|
||||
|
||||
expect(events[0]?.category).toBe("session");
|
||||
expect(events[0]?.action).toBe("session_end");
|
||||
expect(events[0]?.target?.id).toBe("sess-abc");
|
||||
expect(events[0]?.details?.durationMs).toBe(300000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filtering", () => {
|
||||
it("filters by category", () => {
|
||||
const events: AuditEvent[] = [];
|
||||
logger = new AuditLogger({
|
||||
enabled: true,
|
||||
target: "custom",
|
||||
categories: ["auth"], // Only auth events
|
||||
customHandler: (event) => events.push(event),
|
||||
});
|
||||
|
||||
logger.logAuth({ action: "login", outcome: "success" });
|
||||
logger.logAdmin({ action: "config_change", outcome: "success" });
|
||||
logger.close();
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.category).toBe("auth");
|
||||
});
|
||||
|
||||
it("excludes requestId when disabled", () => {
|
||||
const events: AuditEvent[] = [];
|
||||
logger = new AuditLogger({
|
||||
enabled: true,
|
||||
target: "custom",
|
||||
includeRequestIds: false,
|
||||
customHandler: (event) => events.push(event),
|
||||
});
|
||||
|
||||
logger.logAuth({ action: "login", outcome: "success", requestId: "req-123" });
|
||||
logger.close();
|
||||
|
||||
expect(events[0]?.requestId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration", () => {
|
||||
it("updates config at runtime", () => {
|
||||
const events: AuditEvent[] = [];
|
||||
logger = new AuditLogger({
|
||||
enabled: false,
|
||||
target: "custom",
|
||||
customHandler: (event) => events.push(event),
|
||||
});
|
||||
|
||||
logger.logAuth({ action: "login", outcome: "success" });
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
logger.updateConfig({ enabled: true });
|
||||
logger.logAuth({ action: "login", outcome: "success" });
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAuditLogConfig", () => {
|
||||
it("returns defaults when no config provided", () => {
|
||||
const config = resolveAuditLogConfig();
|
||||
expect(config.enabled).toBe(false);
|
||||
expect(config.target).toBe("file");
|
||||
expect(config.categories).toContain("auth");
|
||||
expect(config.includeRequestIds).toBe(true);
|
||||
});
|
||||
|
||||
it("merges partial config with defaults", () => {
|
||||
const config = resolveAuditLogConfig({
|
||||
enabled: true,
|
||||
target: "stdout",
|
||||
});
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.target).toBe("stdout");
|
||||
expect(config.categories).toContain("auth"); // Default
|
||||
});
|
||||
});
|
||||
434
src/security/audit-events.ts
Normal file
434
src/security/audit-events.ts
Normal file
@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Security Audit Event Logging
|
||||
*
|
||||
* Records security-relevant events for compliance and forensic analysis:
|
||||
* - Authentication attempts (success/failure)
|
||||
* - Authorization decisions
|
||||
* - Admin/elevated actions
|
||||
* - Configuration changes
|
||||
* - Rate limit violations
|
||||
*
|
||||
* Events are structured JSON for easy parsing by SIEM tools.
|
||||
* Supports multiple output targets: file, stdout, or custom handler.
|
||||
*/
|
||||
|
||||
import { createWriteStream, type WriteStream, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
|
||||
// Event Categories
|
||||
export type AuditCategory =
|
||||
| "auth" // Authentication events
|
||||
| "authz" // Authorization events
|
||||
| "admin" // Administrative actions
|
||||
| "config" // Configuration changes
|
||||
| "rate_limit" // Rate limiting events
|
||||
| "channel" // Channel/messaging events
|
||||
| "session"; // Session lifecycle events
|
||||
|
||||
// Event outcomes
|
||||
export type AuditOutcome = "success" | "failure" | "denied" | "error";
|
||||
|
||||
// Base audit event structure
|
||||
export type AuditEvent = {
|
||||
/** ISO 8601 timestamp */
|
||||
timestamp: string;
|
||||
/** Event category */
|
||||
category: AuditCategory;
|
||||
/** Specific action within the category */
|
||||
action: string;
|
||||
/** Outcome of the action */
|
||||
outcome: AuditOutcome;
|
||||
/** Actor who initiated the action (user ID, IP, session ID, etc.) */
|
||||
actor?: {
|
||||
type: "user" | "system" | "api" | "channel";
|
||||
id?: string;
|
||||
ip?: string;
|
||||
channel?: string;
|
||||
};
|
||||
/** Target of the action */
|
||||
target?: {
|
||||
type: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
/** Additional context */
|
||||
details?: Record<string, unknown>;
|
||||
/** Error message if outcome is failure/error */
|
||||
error?: string;
|
||||
/** Request ID for correlation */
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
// Specific event builders for type safety
|
||||
export type AuthEventParams = {
|
||||
action: "login" | "logout" | "token_refresh" | "password_change" | "api_key_use";
|
||||
outcome: AuditOutcome;
|
||||
actorId?: string;
|
||||
actorIp?: string;
|
||||
method?: "token" | "password" | "tailscale" | "device";
|
||||
error?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type AuthzEventParams = {
|
||||
action: "access_granted" | "access_denied" | "permission_check" | "role_assigned";
|
||||
outcome: AuditOutcome;
|
||||
actorId?: string;
|
||||
actorIp?: string;
|
||||
resource?: string;
|
||||
permission?: string;
|
||||
reason?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type AdminEventParams = {
|
||||
action: "config_change" | "user_add" | "user_remove" | "channel_add" | "channel_remove" | "elevated_exec";
|
||||
outcome: AuditOutcome;
|
||||
actorId?: string;
|
||||
actorIp?: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
changes?: Record<string, unknown>;
|
||||
command?: string;
|
||||
error?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type RateLimitEventParams = {
|
||||
action: "request_limited" | "channel_limited" | "auth_backoff";
|
||||
outcome: "denied";
|
||||
actorId?: string;
|
||||
actorIp?: string;
|
||||
channel?: string;
|
||||
retryAfterMs?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type SessionEventParams = {
|
||||
action: "session_start" | "session_end" | "session_timeout";
|
||||
outcome: AuditOutcome;
|
||||
actorId?: string;
|
||||
sessionId?: string;
|
||||
channel?: string;
|
||||
durationMs?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
// Configuration
|
||||
export type AuditLogConfig = {
|
||||
/** Enable audit logging (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Output target: 'file', 'stdout', or 'custom' (default: 'file') */
|
||||
target?: "file" | "stdout" | "custom";
|
||||
/** File path for file target (default: ~/.clawdbot/audit/audit.jsonl) */
|
||||
filePath?: string;
|
||||
/** Categories to log (default: all) */
|
||||
categories?: AuditCategory[];
|
||||
/** Minimum outcome severity to log (default: all) */
|
||||
minOutcome?: AuditOutcome;
|
||||
/** Include request IDs for correlation (default: true) */
|
||||
includeRequestIds?: boolean;
|
||||
/** Custom handler for 'custom' target */
|
||||
customHandler?: (event: AuditEvent) => void;
|
||||
};
|
||||
|
||||
export type ResolvedAuditLogConfig = Required<Omit<AuditLogConfig, "customHandler">> & {
|
||||
customHandler?: (event: AuditEvent) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_AUDIT_CONFIG: ResolvedAuditLogConfig = {
|
||||
enabled: false,
|
||||
target: "file",
|
||||
filePath: "", // Resolved at runtime
|
||||
categories: ["auth", "authz", "admin", "config", "rate_limit", "channel", "session"],
|
||||
minOutcome: "success", // Log everything
|
||||
includeRequestIds: true,
|
||||
};
|
||||
|
||||
function resolveDefaultAuditPath(): string {
|
||||
const stateDir = resolveStateDir();
|
||||
return join(stateDir, "audit", "audit.jsonl");
|
||||
}
|
||||
|
||||
export function resolveAuditLogConfig(config?: Partial<AuditLogConfig>): ResolvedAuditLogConfig {
|
||||
const filePath = config?.filePath || resolveDefaultAuditPath();
|
||||
return {
|
||||
enabled: config?.enabled ?? DEFAULT_AUDIT_CONFIG.enabled,
|
||||
target: config?.target ?? DEFAULT_AUDIT_CONFIG.target,
|
||||
filePath,
|
||||
categories: config?.categories ?? DEFAULT_AUDIT_CONFIG.categories,
|
||||
minOutcome: config?.minOutcome ?? DEFAULT_AUDIT_CONFIG.minOutcome,
|
||||
includeRequestIds: config?.includeRequestIds ?? DEFAULT_AUDIT_CONFIG.includeRequestIds,
|
||||
customHandler: config?.customHandler,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Security audit logger for recording security-relevant events.
|
||||
* Events are written as newline-delimited JSON (JSONL) for easy parsing.
|
||||
*/
|
||||
export class AuditLogger {
|
||||
private config: ResolvedAuditLogConfig;
|
||||
private fileStream: WriteStream | null = null;
|
||||
private closed = false;
|
||||
|
||||
constructor(config?: Partial<AuditLogConfig>) {
|
||||
this.config = resolveAuditLogConfig(config);
|
||||
|
||||
if (this.config.enabled && this.config.target === "file") {
|
||||
this.initFileStream();
|
||||
}
|
||||
}
|
||||
|
||||
private initFileStream(): void {
|
||||
const dir = dirname(this.config.filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
this.fileStream = createWriteStream(this.config.filePath, {
|
||||
flags: "a", // Append mode
|
||||
mode: 0o600, // Owner read/write only
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
this.fileStream.on("error", (err) => {
|
||||
console.error(`[audit] File write error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a raw audit event.
|
||||
*/
|
||||
log(event: Omit<AuditEvent, "timestamp">): void {
|
||||
if (!this.config.enabled || this.closed) return;
|
||||
|
||||
// Check category filter
|
||||
if (!this.config.categories.includes(event.category)) return;
|
||||
|
||||
const fullEvent: AuditEvent = {
|
||||
...event,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Remove requestId if disabled
|
||||
if (!this.config.includeRequestIds) {
|
||||
delete fullEvent.requestId;
|
||||
}
|
||||
|
||||
this.write(fullEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an authentication event.
|
||||
*/
|
||||
logAuth(params: AuthEventParams): void {
|
||||
this.log({
|
||||
category: "auth",
|
||||
action: params.action,
|
||||
outcome: params.outcome,
|
||||
actor: params.actorId || params.actorIp
|
||||
? { type: "user", id: params.actorId, ip: params.actorIp }
|
||||
: undefined,
|
||||
details: params.method ? { method: params.method } : undefined,
|
||||
error: params.error,
|
||||
requestId: params.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an authorization event.
|
||||
*/
|
||||
logAuthz(params: AuthzEventParams): void {
|
||||
this.log({
|
||||
category: "authz",
|
||||
action: params.action,
|
||||
outcome: params.outcome,
|
||||
actor: params.actorId || params.actorIp
|
||||
? { type: "user", id: params.actorId, ip: params.actorIp }
|
||||
: undefined,
|
||||
target: params.resource
|
||||
? { type: "resource", name: params.resource }
|
||||
: undefined,
|
||||
details: {
|
||||
...(params.permission && { permission: params.permission }),
|
||||
...(params.reason && { reason: params.reason }),
|
||||
},
|
||||
requestId: params.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an administrative action.
|
||||
*/
|
||||
logAdmin(params: AdminEventParams): void {
|
||||
this.log({
|
||||
category: "admin",
|
||||
action: params.action,
|
||||
outcome: params.outcome,
|
||||
actor: params.actorId || params.actorIp
|
||||
? { type: "user", id: params.actorId, ip: params.actorIp }
|
||||
: undefined,
|
||||
target: params.targetType
|
||||
? { type: params.targetType, id: params.targetId }
|
||||
: undefined,
|
||||
details: {
|
||||
...(params.changes && { changes: params.changes }),
|
||||
...(params.command && { command: params.command }),
|
||||
},
|
||||
error: params.error,
|
||||
requestId: params.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a rate limit event.
|
||||
*/
|
||||
logRateLimit(params: RateLimitEventParams): void {
|
||||
this.log({
|
||||
category: "rate_limit",
|
||||
action: params.action,
|
||||
outcome: params.outcome,
|
||||
actor: params.actorId || params.actorIp
|
||||
? { type: "user", id: params.actorId, ip: params.actorIp }
|
||||
: undefined,
|
||||
details: {
|
||||
...(params.channel && { channel: params.channel }),
|
||||
...(params.retryAfterMs && { retryAfterMs: params.retryAfterMs }),
|
||||
},
|
||||
requestId: params.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a session event.
|
||||
*/
|
||||
logSession(params: SessionEventParams): void {
|
||||
this.log({
|
||||
category: "session",
|
||||
action: params.action,
|
||||
outcome: params.outcome,
|
||||
actor: params.actorId
|
||||
? { type: "user", id: params.actorId }
|
||||
: undefined,
|
||||
target: params.sessionId
|
||||
? { type: "session", id: params.sessionId }
|
||||
: undefined,
|
||||
details: {
|
||||
...(params.channel && { channel: params.channel }),
|
||||
...(params.durationMs && { durationMs: params.durationMs }),
|
||||
},
|
||||
requestId: params.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration.
|
||||
*/
|
||||
getConfig(): ResolvedAuditLogConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration at runtime.
|
||||
* Merges new config with existing config values.
|
||||
*/
|
||||
updateConfig(config: Partial<AuditLogConfig>): void {
|
||||
const wasEnabled = this.config.enabled;
|
||||
const wasFile = this.config.target === "file";
|
||||
const oldPath = this.config.filePath;
|
||||
|
||||
// Merge new config with existing config values
|
||||
this.config = {
|
||||
enabled: config.enabled ?? this.config.enabled,
|
||||
target: config.target ?? this.config.target,
|
||||
filePath: config.filePath ?? this.config.filePath,
|
||||
categories: config.categories ?? this.config.categories,
|
||||
minOutcome: config.minOutcome ?? this.config.minOutcome,
|
||||
includeRequestIds: config.includeRequestIds ?? this.config.includeRequestIds,
|
||||
customHandler: config.customHandler ?? this.config.customHandler,
|
||||
};
|
||||
|
||||
// Handle file stream changes
|
||||
if (this.config.enabled && this.config.target === "file") {
|
||||
if (!wasEnabled || !wasFile || oldPath !== this.config.filePath) {
|
||||
this.closeFileStream();
|
||||
this.initFileStream();
|
||||
}
|
||||
} else {
|
||||
this.closeFileStream();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the logger and release resources.
|
||||
*/
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.closeFileStream();
|
||||
}
|
||||
|
||||
private closeFileStream(): void {
|
||||
if (this.fileStream) {
|
||||
this.fileStream.end();
|
||||
this.fileStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
private write(event: AuditEvent): void {
|
||||
const line = JSON.stringify(event) + "\n";
|
||||
|
||||
switch (this.config.target) {
|
||||
case "file":
|
||||
if (this.fileStream) {
|
||||
this.fileStream.write(line);
|
||||
}
|
||||
break;
|
||||
case "stdout":
|
||||
process.stdout.write(`[audit] ${line}`);
|
||||
break;
|
||||
case "custom":
|
||||
if (this.config.customHandler) {
|
||||
this.config.customHandler(event);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for global access
|
||||
let globalAuditLogger: AuditLogger | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the global audit logger instance.
|
||||
*/
|
||||
export function getAuditLogger(config?: Partial<AuditLogConfig>): AuditLogger {
|
||||
if (!globalAuditLogger) {
|
||||
globalAuditLogger = new AuditLogger(config);
|
||||
}
|
||||
return globalAuditLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the global audit logger with configuration.
|
||||
* Call this early in application startup.
|
||||
*/
|
||||
export function initAuditLogger(config?: Partial<AuditLogConfig>): AuditLogger {
|
||||
if (globalAuditLogger) {
|
||||
globalAuditLogger.close();
|
||||
}
|
||||
globalAuditLogger = new AuditLogger(config);
|
||||
return globalAuditLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the global audit logger.
|
||||
* Call this during application shutdown.
|
||||
*/
|
||||
export function closeAuditLogger(): void {
|
||||
if (globalAuditLogger) {
|
||||
globalAuditLogger.close();
|
||||
globalAuditLogger = null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user