feat(audit): локальный журнал и push в auth-portal
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m9s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 5s

Таблица audit_log, recordAudit на CRUD, GET /api/v1/audit и dual-write source_app=cfdm.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 13:24:32 +07:00
co-authored by Cursor
parent 30447d8380
commit 859ad23006
22 changed files with 1561 additions and 106 deletions
+101
View File
@@ -0,0 +1,101 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyRequest } from "fastify";
import { appendAudit, type AppendAuditInput } from "@cfdm/db";
import { pushAuditEvents } from "../services/audit-portal-push.js";
export function clientIp(request: FastifyRequest): string | null {
const forwarded = request.headers["x-forwarded-for"];
if (typeof forwarded === "string" && forwarded.trim()) {
return forwarded.split(",")[0]?.trim() ?? null;
}
return request.ip ?? null;
}
export function actorFromRequest(
request: FastifyRequest,
): Pick<AppendAuditInput, "actorUserId" | "actorEmail" | "actorName"> {
const u = request.authUser;
if (u) {
return {
actorUserId: u.id,
actorEmail: u.email || null,
actorName: u.name || null,
};
}
const payload = request.user;
if (payload?.sub) {
return {
actorUserId: String(payload.sub),
actorEmail: payload.email ? String(payload.email) : null,
actorName: payload.name ? String(payload.name) : null,
};
}
return {
actorUserId: null,
actorEmail: null,
actorName: null,
};
}
type RecordAuditInput = Omit<
AppendAuditInput,
| "sourceApp"
| "eventId"
| "ip"
| "actorUserId"
| "actorEmail"
| "actorName"
>;
/** Local append + optional async push to auth-portal ingest. */
export function recordAudit(
app: FastifyInstance,
request: FastifyRequest,
input: RecordAuditInput,
): void {
const eventId = randomUUID();
const actor = actorFromRequest(request);
const ip = clientIp(request);
const createdAt = input.createdAt ?? new Date().toISOString();
const full: AppendAuditInput = {
...input,
...actor,
eventId,
sourceApp: "cfdm",
ip,
createdAt,
};
try {
appendAudit(app.db, full);
} catch (err) {
app.log.warn({ err }, "audit_log append failed");
}
const secret = app.config.authAuditIngestSecret;
const portalUrl = app.config.authPortalUrl;
if (!secret || !portalUrl) return;
void pushAuditEvents(portalUrl, secret, [
{
event_id: eventId,
source_app: "cfdm",
action: input.action,
severity: input.severity,
actor_user_id: actor.actorUserId,
actor_email: actor.actorEmail,
actor_name: actor.actorName,
target_type: input.targetType,
target_id: input.targetId,
summary: input.summary,
details: input.details,
ip,
created_at: createdAt,
},
]).catch((err) => {
app.log.warn({ err, eventId }, "audit portal push failed");
});
}
+5
View File
@@ -118,6 +118,11 @@ const RULES: Rule[] = [
p.startsWith("/api/v1/health-checks"),
permission: "cfdm:settings:admin",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/v1/audit"),
permission: "cfdm:settings:admin",
},
];
/** Resolve required permission for method+path, or null if public / unknown. */