feat(audit): локальный журнал и push в auth-portal
Таблица audit_log, recordAudit на CRUD, GET /api/v1/audit и dual-write source_app=cfdm. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
30447d8380
commit
859ad23006
22 files changed
+1561
-106
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { and, desc, eq, or } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
AuditLogEntry,
|
||||
AuditSeverity,
|
||||
AuditSourceApp,
|
||||
AuditTargetType,
|
||||
} from "@cfdm/shared";
|
||||
import type { Db } from "./client.js";
|
||||
import { auditLog } from "./schema.js";
|
||||
|
||||
export type AppendAuditInput = {
|
||||
eventId?: string | null;
|
||||
sourceApp?: AuditSourceApp;
|
||||
action: string;
|
||||
severity?: AuditSeverity;
|
||||
actorUserId?: string | null;
|
||||
actorEmail?: string | null;
|
||||
actorName?: string | null;
|
||||
targetType?: AuditTargetType | null;
|
||||
targetId?: string | null;
|
||||
summary: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
ip?: string | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
|
||||
let details: Record<string, unknown> | null = null;
|
||||
if (row.details_json) {
|
||||
try {
|
||||
details = JSON.parse(row.details_json) as Record<string, unknown>;
|
||||
} catch {
|
||||
details = { raw: row.details_json };
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
event_id: row.event_id,
|
||||
source_app: (row.source_app as AuditSourceApp) || "cfdm",
|
||||
action: row.action,
|
||||
severity: row.severity as AuditSeverity,
|
||||
actor_user_id: row.actor_user_id,
|
||||
actor_email: row.actor_email,
|
||||
actor_name: row.actor_name,
|
||||
target_type: (row.target_type as AuditTargetType | null) ?? null,
|
||||
target_id: row.target_id,
|
||||
summary: row.summary,
|
||||
details,
|
||||
ip: row.ip,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns true if inserted, false if duplicate event_id */
|
||||
export function appendAudit(db: Db, input: AppendAuditInput): boolean {
|
||||
const now = input.createdAt ?? new Date().toISOString();
|
||||
const eventId = input.eventId ?? null;
|
||||
|
||||
if (eventId) {
|
||||
const existing = db
|
||||
.select({ id: auditLog.id })
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.event_id, eventId))
|
||||
.get();
|
||||
if (existing) return false;
|
||||
}
|
||||
|
||||
db.insert(auditLog)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
event_id: eventId,
|
||||
source_app: input.sourceApp ?? "cfdm",
|
||||
action: input.action,
|
||||
severity: input.severity ?? "info",
|
||||
actor_user_id: input.actorUserId ?? null,
|
||||
actor_email: input.actorEmail ?? null,
|
||||
actor_name: input.actorName ?? null,
|
||||
target_type: input.targetType ?? null,
|
||||
target_id: input.targetId ?? null,
|
||||
summary: input.summary,
|
||||
details_json: input.details ? JSON.stringify(input.details) : null,
|
||||
ip: input.ip ?? null,
|
||||
created_at: now,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function listAudit(
|
||||
db: Db,
|
||||
opts: {
|
||||
action?: string;
|
||||
severity?: AuditSeverity;
|
||||
userId?: string;
|
||||
sourceApp?: AuditSourceApp;
|
||||
limit?: number;
|
||||
} = {},
|
||||
): AuditLogEntry[] {
|
||||
const limit = opts.limit ?? 200;
|
||||
const conditions = [];
|
||||
if (opts.action) conditions.push(eq(auditLog.action, opts.action));
|
||||
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity));
|
||||
if (opts.sourceApp) conditions.push(eq(auditLog.source_app, opts.sourceApp));
|
||||
if (opts.userId) {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(auditLog.actor_user_id, opts.userId),
|
||||
eq(auditLog.target_id, opts.userId),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
const rows =
|
||||
conditions.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(auditLog.created_at))
|
||||
.limit(limit)
|
||||
.all()
|
||||
: db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.orderBy(desc(auditLog.created_at))
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./schema.js";
|
||||
export * from "./client.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./audit-log.js";
|
||||
export * from "./settings-repo.js";
|
||||
export * as repos from "./repos.js";
|
||||
export type { DnsListFilter, UpdateSubdomainPatch } from "./repos.js";
|
||||
@@ -359,6 +359,25 @@ export const notificationLog = sqliteTable("notification_log", {
|
||||
.default(sql`datetime('now')`),
|
||||
});
|
||||
|
||||
export const auditLog = sqliteTable("audit_log", {
|
||||
id: text("id").primaryKey(),
|
||||
event_id: text("event_id"),
|
||||
source_app: text("source_app").notNull().default("cfdm"),
|
||||
action: text("action").notNull(),
|
||||
severity: text("severity").notNull().default("info"),
|
||||
actor_user_id: text("actor_user_id"),
|
||||
actor_email: text("actor_email"),
|
||||
actor_name: text("actor_name"),
|
||||
target_type: text("target_type"),
|
||||
target_id: text("target_id"),
|
||||
summary: text("summary").notNull(),
|
||||
details_json: text("details_json"),
|
||||
ip: text("ip"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
});
|
||||
|
||||
export const schema = {
|
||||
groups,
|
||||
services,
|
||||
@@ -379,4 +398,5 @@ export const schema = {
|
||||
domainMonitors,
|
||||
domainMonitorResults,
|
||||
notificationLog,
|
||||
auditLog,
|
||||
};
|
||||
Reference in new issue
Block a user