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:
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user