feat(audit): локальный журнал и push в auth-portal
Таблица audit_log, recordAudit на CRUD, GET /api/v1/audit и dual-write source_app=cfdm. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user