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
+2
View File
@@ -31,6 +31,7 @@ import {
} from "./routes/domain-monitors.js";
import { settingsRoutes } from "./routes/settings.js";
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
import { auditRoutes } from "./routes/audit.js";
import * as certificateService from "./services/certificate-service.js";
import * as healthCheckService from "./services/health-check-service.js";
import * as serviceConfigService from "./services/service-config-service.js";
@@ -83,6 +84,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await protectedApi.register(domainMonitorRoutes);
await protectedApi.register(notificationRoutes);
await protectedApi.register(settingsRoutes);
await protectedApi.register(auditRoutes);
},
{ prefix: "/api/v1" },
);
+5
View File
@@ -21,6 +21,8 @@ export interface AppConfig {
authRequired: boolean;
authIssuer: string;
authPortalUrl: string;
/** Bearer secret for POST {authPortalUrl}/api/v1/ingest/audit */
authAuditIngestSecret: string | null;
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
@@ -65,5 +67,8 @@ export function loadConfig(): AppConfig {
process.env.VITE_AUTH_PORTAL_URL ??
"http://localhost:5175"
).replace(/\/$/, ""),
authAuditIngestSecret:
process.env.AUTH_AUDIT_INGEST_SECRET?.trim() ||
(!isProd ? "dev-audit-ingest-secret" : null),
};
}
+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. */
+22
View File
@@ -0,0 +1,22 @@
import type { FastifyInstance } from "fastify";
import { listAudit } from "@cfdm/db";
import { auditListQuerySchema } from "@cfdm/shared";
import { AppError } from "../errors.js";
export async function auditRoutes(app: FastifyInstance): Promise<void> {
app.get("/audit", async (request) => {
const parsed = auditListQuerySchema.safeParse(request.query);
if (!parsed.success) {
throw AppError.validation("Некорректные параметры запроса");
}
const q = parsed.data;
return listAudit(app.db, {
action: q.action,
severity: q.severity,
userId: q.user_id,
sourceApp: q.source_app,
limit: q.limit,
});
});
}
+37 -2
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as dnsService from "../services/dns-service.js";
import { recordAudit } from "../lib/audit.js";
export async function dnsRoutes(app: FastifyInstance) {
const createSchema = z.object({
@@ -31,12 +32,25 @@ export async function dnsRoutes(app: FastifyInstance) {
app.post("/domains/:id/dns", async (request) => {
const { id } = request.params as { id: string };
const body = createSchema.parse(request.body);
return dnsService.create(
const record = await dnsService.create(
request.server.db,
request.server.cf,
Number(id),
body,
);
recordAudit(request.server, request, {
action: "dns.create",
targetType: "app_resource",
targetId: String(record.id),
summary: `Создана DNS-запись ${record.name} (${record.record_type})`,
details: {
domain_id: Number(id),
record_type: record.record_type,
name: record.name,
content: record.content,
},
});
return record;
});
app.post("/domains/:id/dns/bulk", async (request) => {
@@ -69,13 +83,21 @@ export async function dnsRoutes(app: FastifyInstance) {
id: string;
recordId: string;
};
return dnsService.update(
const record = await dnsService.update(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
request.body as dnsService.UpdateDnsRequest,
);
recordAudit(request.server, request, {
action: "dns.update",
targetType: "app_resource",
targetId: String(recordId),
summary: `Обновлена DNS-запись ${record.name}`,
details: { domain_id: Number(id), record_id: Number(recordId) },
});
return record;
});
app.delete("/domains/:id/dns/:recordId", async (request) => {
@@ -83,12 +105,25 @@ export async function dnsRoutes(app: FastifyInstance) {
id: string;
recordId: string;
};
const record = dnsService.get(
request.server.db,
Number(id),
Number(recordId),
);
await dnsService.deleteRecord(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
);
recordAudit(request.server, request, {
action: "dns.delete",
severity: "warning",
targetType: "app_resource",
targetId: String(recordId),
summary: `Удалена DNS-запись ${record.name}`,
details: { domain_id: Number(id), name: record.name },
});
return { deleted: true };
});
+28 -2
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import { bulkUpdateDomainsSchema, updateDomainSchema } from "@cfdm/shared";
import { z } from "zod";
import * as domainService from "../services/domain-service.js";
import { recordAudit } from "../lib/audit.js";
export async function domainRoutes(app: FastifyInstance) {
const createSchema = z.object({
@@ -17,12 +18,20 @@ export async function domainRoutes(app: FastifyInstance) {
app.post("/domains", async (request) => {
const body = createSchema.parse(request.body);
return domainService.createDomain(
const domain = await domainService.createDomain(
request.server.db,
request.server.cf,
body.group_id ?? null,
body.zone_name,
);
recordAudit(request.server, request, {
action: "domain.create",
targetType: "app_resource",
targetId: String(domain.id),
summary: `Добавлен домен ${domain.zone_name}`,
details: { zone_name: domain.zone_name, group_id: domain.group_id },
});
return domain;
});
app.post("/domains/bulk", async (request) => {
@@ -47,12 +56,29 @@ export async function domainRoutes(app: FastifyInstance) {
app.patch("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateDomainSchema.parse(request.body);
return domainService.updateDomain(request.server.db, Number(id), body);
const domain = domainService.updateDomain(request.server.db, Number(id), body);
recordAudit(request.server, request, {
action: "domain.update",
targetType: "app_resource",
targetId: String(domain.id),
summary: `Обновлён домен ${domain.zone_name}`,
details: body,
});
return domain;
});
app.delete("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
const domain = domainService.getDomain(request.server.db, Number(id));
domainService.deleteDomain(request.server.db, Number(id));
recordAudit(request.server, request, {
action: "domain.delete",
severity: "warning",
targetType: "app_resource",
targetId: String(id),
summary: `Удалён домен ${domain.zone_name}`,
details: { zone_name: domain.zone_name },
});
return { deleted: true };
});
+29 -2
View File
@@ -1,6 +1,8 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { repos } from "@cfdm/db";
import * as groupService from "../services/group-service.js";
import { recordAudit } from "../lib/audit.js";
export async function groupRoutes(app: FastifyInstance) {
const bodySchema = z.object({
@@ -14,7 +16,15 @@ export async function groupRoutes(app: FastifyInstance) {
app.post("/groups", async (request) => {
const body = bodySchema.parse(request.body);
return groupService.createGroup(request.server.db, body.name, body.slug);
const group = groupService.createGroup(request.server.db, body.name, body.slug);
recordAudit(request.server, request, {
action: "group.create",
targetType: "app_resource",
targetId: String(group.id),
summary: `Создана группа «${group.name}»`,
details: { name: group.name, slug: group.slug },
});
return group;
});
app.get("/groups/:id", async (request) => {
@@ -25,17 +35,34 @@ export async function groupRoutes(app: FastifyInstance) {
app.patch("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
const body = bodySchema.parse(request.body);
return groupService.updateGroup(
const group = groupService.updateGroup(
request.server.db,
Number(id),
body.name,
body.slug,
);
recordAudit(request.server, request, {
action: "group.update",
targetType: "app_resource",
targetId: String(group.id),
summary: `Обновлена группа «${group.name}»`,
details: { name: group.name, slug: group.slug },
});
return group;
});
app.delete("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
const group = repos.getGroup(request.server.db, Number(id));
groupService.deleteGroup(request.server.db, Number(id));
recordAudit(request.server, request, {
action: "group.delete",
severity: "warning",
targetType: "app_resource",
targetId: String(id),
summary: `Удалена группа «${group.name}»`,
details: { name: group.name, slug: group.slug },
});
return { deleted: true };
});
}
+28 -2
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
import { recordAudit } from "../lib/audit.js";
export async function serviceRoutes(app: FastifyInstance) {
const createSchema = z.object({
@@ -39,7 +40,15 @@ export async function serviceRoutes(app: FastifyInstance) {
body.service_group_id,
);
}
return serviceConfig.getView(request.server.db, service.id);
const view = serviceConfig.getView(request.server.db, service.id);
recordAudit(request.server, request, {
action: "service.create",
targetType: "app_resource",
targetId: String(service.id),
summary: `Создан сервис «${service.name}»`,
details: { name: service.name, slug: service.slug },
});
return view;
});
app.get("/services/:id", async (request) => {
@@ -50,17 +59,34 @@ export async function serviceRoutes(app: FastifyInstance) {
app.patch("/services/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateServiceConfigSchema.parse(request.body);
return serviceConfig.updateConfig(
const view = await serviceConfig.updateConfig(
request.server.db,
request.server.cf,
Number(id),
body,
);
recordAudit(request.server, request, {
action: "service.update",
targetType: "app_resource",
targetId: String(id),
summary: `Обновлён сервис «${view.name}»`,
details: body,
});
return view;
});
app.delete("/services/:id", async (request) => {
const { id } = request.params as { id: string };
const view = serviceConfig.getView(request.server.db, Number(id));
repos.deleteService(request.server.db, Number(id));
recordAudit(request.server, request, {
action: "service.delete",
severity: "warning",
targetType: "app_resource",
targetId: String(id),
summary: `Удалён сервис «${view.name}»`,
details: { name: view.name, slug: view.slug },
});
return { deleted: true };
});
@@ -0,0 +1,23 @@
import { request } from "undici";
import type { IngestAuditEvent } from "@cfdm/shared";
export async function pushAuditEvents(
portalUrl: string,
secret: string,
events: IngestAuditEvent[],
): Promise<void> {
const base = portalUrl.replace(/\/$/, "");
const res = await request(`${base}/api/v1/ingest/audit`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${secret}`,
},
body: JSON.stringify({ events }),
});
if (res.statusCode >= 400) {
const body = await res.body.text();
throw new Error(`portal audit ingest ${res.statusCode}: ${body}`);
}
}