From 859ad230067ea1b77bc1b56af5c370dc6ea13db7 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 13:24:32 +0700 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=20=D0=B8=20push=20=D0=B2=20auth-portal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Таблица audit_log, recordAudit на CRUD, GET /api/v1/audit и dual-write source_app=cfdm. Co-authored-by: Cursor --- .env.example | 2 + apps/api/src/app.ts | 2 + apps/api/src/config.ts | 5 + apps/api/src/lib/audit.ts | 101 ++++ apps/api/src/lib/permissions.ts | 5 + apps/api/src/routes/audit.ts | 22 + apps/api/src/routes/dns.ts | 39 +- apps/api/src/routes/domains.ts | 30 +- apps/api/src/routes/groups.ts | 31 +- apps/api/src/routes/services.ts | 30 +- apps/api/src/services/audit-portal-push.ts | 23 + apps/api/test/audit.test.ts | 97 ++++ packages/db/dist/index.d.ts | 575 ++++++++++++++++++++- packages/db/dist/index.js | 282 ++++++---- packages/db/migrations/017_audit_log.sql | 23 + packages/db/src/audit-log.ts | 131 +++++ packages/db/src/index.ts | 1 + packages/db/src/schema.ts | 20 + packages/shared/dist/index.d.ts | 110 +++- packages/shared/dist/index.js | 68 +++ packages/shared/src/audit.ts | 69 +++ packages/shared/src/index.ts | 1 + 22 files changed, 1561 insertions(+), 106 deletions(-) create mode 100644 apps/api/src/lib/audit.ts create mode 100644 apps/api/src/routes/audit.ts create mode 100644 apps/api/src/services/audit-portal-push.ts create mode 100644 apps/api/test/audit.test.ts create mode 100644 packages/db/migrations/017_audit_log.sql create mode 100644 packages/db/src/audit-log.ts create mode 100644 packages/shared/src/audit.ts diff --git a/.env.example b/.env.example index e79c352..37d2bd7 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ AUTH_JWT_SECRET=dev-secret-change-me # alias: JWT_SECRET= AUTH_ISSUER=https://auth.shnt.top AUTH_PORTAL_URL=http://localhost:5175 +# Shared with auth-portal AUDIT_INGEST_SECRET — dual-write audit to portal +AUTH_AUDIT_INGEST_SECRET=dev-audit-ingest-secret JWT_TTL_HOURS=24 # Legacy local login (только при AUTH_REQUIRED=false) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 09b437c..e120659 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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" }, ); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index b5800a5..40e1ac3 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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), }; } diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts new file mode 100644 index 0000000..aff5739 --- /dev/null +++ b/apps/api/src/lib/audit.ts @@ -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 { + 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"); + }); +} diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index 73f8a3c..bd7fa31 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -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. */ diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts new file mode 100644 index 0000000..2babbf8 --- /dev/null +++ b/apps/api/src/routes/audit.ts @@ -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 { + 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, + }); + }); +} diff --git a/apps/api/src/routes/dns.ts b/apps/api/src/routes/dns.ts index 5f9d7b4..7bd4337 100644 --- a/apps/api/src/routes/dns.ts +++ b/apps/api/src/routes/dns.ts @@ -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 }; }); diff --git a/apps/api/src/routes/domains.ts b/apps/api/src/routes/domains.ts index f79e610..fccf1d2 100644 --- a/apps/api/src/routes/domains.ts +++ b/apps/api/src/routes/domains.ts @@ -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 }; }); diff --git a/apps/api/src/routes/groups.ts b/apps/api/src/routes/groups.ts index 9f455c0..916821c 100644 --- a/apps/api/src/routes/groups.ts +++ b/apps/api/src/routes/groups.ts @@ -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 }; }); } diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index 39f13f4..e8e5698 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -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 }; }); diff --git a/apps/api/src/services/audit-portal-push.ts b/apps/api/src/services/audit-portal-push.ts new file mode 100644 index 0000000..6b484a9 --- /dev/null +++ b/apps/api/src/services/audit-portal-push.ts @@ -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 { + 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}`); + } +} diff --git a/apps/api/test/audit.test.ts b/apps/api/test/audit.test.ts new file mode 100644 index 0000000..ddf6c48 --- /dev/null +++ b/apps/api/test/audit.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import * as auditPortalPush from "../src/services/audit-portal-push.js"; + +describe("audit log", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("GET /api/v1/audit returns local entries", async () => { + const pushSpy = vi + .spyOn(auditPortalPush, "pushAuditEvents") + .mockResolvedValue(undefined); + + const app = await buildApp({ + config: { + ...loadConfig(), + staticDir: null, + authAuditIngestSecret: null, + }, + memory: true, + }); + + const login = await app.inject({ + method: "POST", + url: "/api/v1/auth/login", + payload: { username: "admin", password: "admin" }, + }); + expect(login.statusCode).toBe(200); + const token = (login.json() as { token: string }).token; + + const create = await app.inject({ + method: "POST", + url: "/api/v1/groups", + headers: { authorization: `Bearer ${token}` }, + payload: { name: "Audit Test", slug: "audit-test" }, + }); + expect(create.statusCode).toBe(200); + + const list = await app.inject({ + method: "GET", + url: "/api/v1/audit?action=group.create", + headers: { authorization: `Bearer ${token}` }, + }); + expect(list.statusCode).toBe(200); + const entries = list.json() as { action: string; source_app: string }[]; + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(entries[0]?.action).toBe("group.create"); + expect(entries[0]?.source_app).toBe("cfdm"); + + expect(pushSpy).not.toHaveBeenCalled(); + await app.close(); + }); + + it("recordAudit pushes to portal when secret configured", async () => { + const pushSpy = vi + .spyOn(auditPortalPush, "pushAuditEvents") + .mockResolvedValue(undefined); + + const app = await buildApp({ + config: { + ...loadConfig(), + staticDir: null, + authPortalUrl: "http://portal.test", + authAuditIngestSecret: "test-ingest-secret", + }, + memory: true, + }); + + const login = await app.inject({ + method: "POST", + url: "/api/v1/auth/login", + payload: { username: "admin", password: "admin" }, + }); + const token = (login.json() as { token: string }).token; + + await app.inject({ + method: "POST", + url: "/api/v1/groups", + headers: { authorization: `Bearer ${token}` }, + payload: { name: "Portal Push", slug: "portal-push" }, + }); + + await vi.waitFor(() => { + expect(pushSpy).toHaveBeenCalledOnce(); + }); + + const [portalUrl, secret, events] = pushSpy.mock.calls[0]!; + expect(portalUrl).toBe("http://portal.test"); + expect(secret).toBe("test-ingest-secret"); + expect(events[0]?.source_app).toBe("cfdm"); + expect(events[0]?.action).toBe("group.create"); + + await app.close(); + }); +}); diff --git a/packages/db/dist/index.d.ts b/packages/db/dist/index.d.ts index 04ed91d..cba5a08 100644 --- a/packages/db/dist/index.d.ts +++ b/packages/db/dist/index.d.ts @@ -1,7 +1,7 @@ import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; -import { LbMode, HealthCheckType, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared'; +import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, LbMode, HealthCheckType, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared'; declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "groups"; @@ -3025,6 +3025,279 @@ declare const notificationLog: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }; dialect: "sqlite"; }>; +declare const auditLog: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "audit_log"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + event_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "event_id"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + source_app: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "source_app"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + action: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "action"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + severity: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "severity"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + actor_user_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "actor_user_id"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + actor_email: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "actor_email"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + actor_name: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "actor_name"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + target_type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "target_type"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + target_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "target_id"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + summary: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "summary"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + details_json: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "details_json"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + ip: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ip"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; +}>; declare const schema: { groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "groups"; @@ -6048,6 +6321,279 @@ declare const schema: { }; dialect: "sqlite"; }>; + auditLog: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "audit_log"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + event_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "event_id"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + source_app: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "source_app"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + action: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "action"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + severity: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "severity"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + actor_user_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "actor_user_id"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + actor_email: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "actor_email"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + actor_name: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "actor_name"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + target_type: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "target_type"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + target_id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "target_id"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + summary: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "summary"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + details_json: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "details_json"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + ip: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ip"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "audit_log"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; }; type Sqlite = Database.Database; @@ -6071,6 +6617,31 @@ declare class ConflictError extends Error { constructor(message: string); } +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 | null; + ip?: string | null; + createdAt?: string | null; +}; +/** @returns true if inserted, false if duplicate event_id */ +declare function appendAudit(db: Db, input: AppendAuditInput): boolean; +declare function listAudit(db: Db, opts?: { + action?: string; + severity?: AuditSeverity; + userId?: string; + sourceApp?: AuditSourceApp; + limit?: number; +}): AuditLogEntry[]; + type AppSettingsDto = { id: string; vpsTrackerUrl: string; @@ -6429,4 +7000,4 @@ declare namespace repos { export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_DomainMonitorRow as DomainMonitorRow, type repos_HealthAggregate as HealthAggregate, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_addDomainTags as addDomainTags, repos_aggregateIpHealthByRefs as aggregateIpHealthByRefs, repos_aggregateIpHealthByServiceIds as aggregateIpHealthByServiceIds, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createDomainMonitor as createDomainMonitor, repos_createGroup as createGroup, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteDomainMonitor as deleteDomainMonitor, repos_deleteGroup as deleteGroup, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getDomainMonitor as getDomainMonitor, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_insertNotificationLog as insertNotificationLog, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomainMonitorResults as listDomainMonitorResults, repos_listDomainMonitorResultsForDomain as listDomainMonitorResultsForDomain, repos_listDomainMonitors as listDomainMonitors, repos_listDomainTags as listDomainTags, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listEnabledDomainMonitors as listEnabledDomainMonitors, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listIpHealthStatus as listIpHealthStatus, repos_listNotificationLog as listNotificationLog, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_mergeHealthAggregates as mergeHealthAggregates, repos_pruneStaleIpHealthStatus as pruneStaleIpHealthStatus, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setDomainTags as setDomainTags, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateDomainMonitorResult as updateDomainMonitorResult, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertIpHealthStatus as upsertIpHealthStatus, repos_upsertSubdomain as upsertSubdomain }; } -export { type AppSettingsDto, type AppSettingsPatch, ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, getAppSettings, getAppSettingsSecrets, groups, healthCheck, ipHealthStatus, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings }; +export { type AppSettingsDto, type AppSettingsPatch, type AppendAuditInput, ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, appendAudit, auditLog, certificates, createDb, createMemoryDb, dnsRecords, domainMonitorResults, domainMonitors, domainTags, domains, getAppSettings, getAppSettingsSecrets, groups, healthCheck, ipHealthStatus, listAudit, notificationLog, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings }; diff --git a/packages/db/dist/index.js b/packages/db/dist/index.js index 9321aa9..db927b4 100644 --- a/packages/db/dist/index.js +++ b/packages/db/dist/index.js @@ -245,6 +245,22 @@ var notificationLog = sqliteTable("notification_log", { message: text("message").notNull(), created_at: text("created_at").notNull().default(sql`datetime('now')`) }); +var 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')`) +}); var schema = { groups, services, @@ -264,7 +280,8 @@ var schema = { domainTags, domainMonitors, domainMonitorResults, - notificationLog + notificationLog, + auditLog }; // src/client.ts @@ -329,8 +346,80 @@ var ConflictError = class extends Error { } }; +// src/audit-log.ts +import { and, desc, eq, or } from "drizzle-orm"; +import { randomUUID } from "crypto"; +function mapRow(row) { + let details = null; + if (row.details_json) { + try { + details = JSON.parse(row.details_json); + } catch { + details = { raw: row.details_json }; + } + } + return { + id: row.id, + event_id: row.event_id, + source_app: row.source_app || "cfdm", + action: row.action, + severity: row.severity, + actor_user_id: row.actor_user_id, + actor_email: row.actor_email, + actor_name: row.actor_name, + target_type: row.target_type ?? null, + target_id: row.target_id, + summary: row.summary, + details, + ip: row.ip, + created_at: row.created_at + }; +} +function appendAudit(db, input) { + const now = input.createdAt ?? (/* @__PURE__ */ 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; +} +function listAudit(db, opts = {}) { + 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); +} + // src/settings-repo.ts -import { eq } from "drizzle-orm"; +import { eq as eq2 } from "drizzle-orm"; var SETTINGS_ID = "settings-main"; function toDto(row) { return { @@ -345,17 +434,17 @@ function toDto(row) { }; } function getAppSettings(db) { - const row = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get(); + const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(); if (!row) { db.insert(appSettings).values({ id: SETTINGS_ID }).run(); return toDto( - db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get() + db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get() ); } return toDto(row); } function getAppSettingsSecrets(db) { - const row = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get(); + const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(); return { vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "", vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "", @@ -363,11 +452,11 @@ function getAppSettingsSecrets(db) { }; } function updateAppSettings(db, patch) { - const existing = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get(); + const existing = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(); if (!existing) { db.insert(appSettings).values({ id: SETTINGS_ID }).run(); } - const current = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get(); + const current = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(); db.update(appSettings).set({ // app_switcher_json: deprecated — source of truth is auth-portal vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url, @@ -375,14 +464,14 @@ function updateAppSettings(db, patch) { vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled, show_quick_actions: patch.showQuickActions !== void 0 ? patch.showQuickActions : current.show_quick_actions, updated_at: (/* @__PURE__ */ new Date()).toISOString() - }).where(eq(appSettings.id, SETTINGS_ID)).run(); + }).where(eq2(appSettings.id, SETTINGS_ID)).run(); return getAppSettings(db); } function touchVpsTrackerSync(db) { db.update(appSettings).set({ vps_tracker_last_sync_at: (/* @__PURE__ */ new Date()).toISOString(), updated_at: (/* @__PURE__ */ new Date()).toISOString() - }).where(eq(appSettings.id, SETTINGS_ID)).run(); + }).where(eq2(appSettings.id, SETTINGS_ID)).run(); } // src/repos.ts @@ -496,12 +585,12 @@ __export(repos_exports, { upsertSubdomain: () => upsertSubdomain }); import { dnsRecordNamesMatch } from "@cfdm/shared"; -import { and, asc, count, eq as eq2, isNull, like, notInArray, or, sql as sql2 } from "drizzle-orm"; +import { and as and2, asc, count, eq as eq3, isNull, like, notInArray, or as or2, sql as sql2 } from "drizzle-orm"; function listGroups(db) { return db.select().from(groups).orderBy(asc(groups.name)).all(); } function getGroup(db, id) { - const row = db.select().from(groups).where(eq2(groups.id, id)).get(); + const row = db.select().from(groups).where(eq3(groups.id, id)).get(); if (!row) throw new NotFoundError(`group ${id}`); return row; } @@ -519,17 +608,17 @@ function createGroup(db, name, slug) { return getGroup(db, id); } function updateGroup(db, id, name, slug) { - const result = db.update(groups).set({ name, slug, updated_at: sql2`datetime('now')` }).where(eq2(groups.id, id)).run(); + const result = db.update(groups).set({ name, slug, updated_at: sql2`datetime('now')` }).where(eq3(groups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`group ${id}`); return getGroup(db, id); } function deleteGroup(db, id) { - const result = db.delete(groups).where(eq2(groups.id, id)).run(); + const result = db.delete(groups).where(eq3(groups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`group ${id}`); } function listDomains(db, groupId) { if (groupId != null) { - return db.select().from(domains).where(eq2(domains.group_id, groupId)).orderBy(asc(domains.zone_name)).all(); + return db.select().from(domains).where(eq3(domains.group_id, groupId)).orderBy(asc(domains.zone_name)).all(); } return db.select().from(domains).orderBy(asc(domains.zone_name)).all(); } @@ -598,7 +687,7 @@ function findDomainByZoneName(db, zoneName) { return rows[0] ?? null; } function getDomain(db, id) { - const row = db.select().from(domains).where(eq2(domains.id, id)).get(); + const row = db.select().from(domains).where(eq3(domains.id, id)).get(); if (!row) throw new NotFoundError(`domain ${id}`); return row; } @@ -623,33 +712,33 @@ function updateDomain(db, id, patch) { if (patch.environment !== void 0) { updates.environment = patch.environment; } - const result = db.update(domains).set(updates).where(eq2(domains.id, id)).run(); + const result = db.update(domains).set(updates).where(eq3(domains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`domain ${id}`); return getDomain(db, id); } function deleteDomain(db, id) { - const result = db.delete(domains).where(eq2(domains.id, id)).run(); + const result = db.delete(domains).where(eq3(domains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`domain ${id}`); } function setDomainLastSynced(db, id) { db.update(domains).set({ last_synced_at: sql2`datetime('now')`, updated_at: sql2`datetime('now')` - }).where(eq2(domains.id, id)).run(); + }).where(eq3(domains.id, id)).run(); } function listAllDomains(db) { return listDomains(db); } function listSubdomainsByDomain(db, domainId) { - return db.select().from(subdomains).where(eq2(subdomains.domain_id, domainId)).orderBy(asc(subdomains.name)).all(); + return db.select().from(subdomains).where(eq3(subdomains.domain_id, domainId)).orderBy(asc(subdomains.name)).all(); } function getSubdomain(db, id) { - const row = db.select().from(subdomains).where(eq2(subdomains.id, id)).get(); + const row = db.select().from(subdomains).where(eq3(subdomains.id, id)).get(); if (!row) throw new NotFoundError(`subdomain ${id}`); return row; } function findSubdomainByDomainAndName(db, domainId, name) { - const row = db.select().from(subdomains).where(and(eq2(subdomains.domain_id, domainId), eq2(subdomains.name, name))).get(); + const row = db.select().from(subdomains).where(and2(eq3(subdomains.domain_id, domainId), eq3(subdomains.name, name))).get(); return row ?? null; } function upsertSubdomain(db, domainId, name, fqdn) { @@ -673,12 +762,12 @@ function updateSubdomain(db, id, patch) { if (patch.cert_monitoring !== void 0) { updates.cert_monitoring = patch.cert_monitoring; } - const result = db.update(subdomains).set(updates).where(eq2(subdomains.id, id)).run(); + const result = db.update(subdomains).set(updates).where(eq3(subdomains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`); return getSubdomain(db, id); } function deleteSubdomain(db, id) { - const result = db.delete(subdomains).where(eq2(subdomains.id, id)).run(); + const result = db.delete(subdomains).where(eq3(subdomains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`); } function listAllSubdomains(db) { @@ -688,9 +777,9 @@ function mapDnsRecord(row) { return row; } function listDnsRecords(db, domainId, filter = {}) { - const conditions = [eq2(dnsRecords.domain_id, domainId)]; + const conditions = [eq3(dnsRecords.domain_id, domainId)]; if (filter.record_type) { - conditions.push(eq2(dnsRecords.record_type, filter.record_type.toUpperCase())); + conditions.push(eq3(dnsRecords.record_type, filter.record_type.toUpperCase())); } if (filter.name) { conditions.push(like(dnsRecords.name, `%${filter.name}%`)); @@ -699,15 +788,15 @@ function listDnsRecords(db, domainId, filter = {}) { conditions.push(like(dnsRecords.content, `%${filter.content}%`)); } if (filter.proxied != null) { - conditions.push(eq2(dnsRecords.proxied, filter.proxied)); + conditions.push(eq3(dnsRecords.proxied, filter.proxied)); } if (filter.sync_status) { - conditions.push(eq2(dnsRecords.sync_status, filter.sync_status)); + conditions.push(eq3(dnsRecords.sync_status, filter.sync_status)); } if (filter.q) { const pat = `%${filter.q}%`; conditions.push( - or( + or2( like(dnsRecords.name, pat), like(dnsRecords.content, pat), like(dnsRecords.record_type, pat) @@ -718,10 +807,10 @@ function listDnsRecords(db, domainId, filter = {}) { const page = Math.max(1, filter.page ?? 1); const limit = Math.min(200, Math.max(1, filter.limit ?? 50)); const offset = (page - 1) * limit; - return db.select().from(dnsRecords).where(and(...conditions)).orderBy(asc(sortCol)).limit(limit).offset(offset).all().map(mapDnsRecord); + return db.select().from(dnsRecords).where(and2(...conditions)).orderBy(asc(sortCol)).limit(limit).offset(offset).all().map(mapDnsRecord); } function getDnsRecord(db, domainId, id) { - const row = db.select().from(dnsRecords).where(and(eq2(dnsRecords.id, id), eq2(dnsRecords.domain_id, domainId))).get(); + const row = db.select().from(dnsRecords).where(and2(eq3(dnsRecords.id, id), eq3(dnsRecords.domain_id, domainId))).get(); if (!row) throw new NotFoundError(`dns record ${id}`); return mapDnsRecord(row); } @@ -752,7 +841,7 @@ function updateDnsFields(db, id, recordType, name, content, ttl, proxied, priori sync_status: syncStatus, last_error: lastError, updated_at: sql2`datetime('now')` - }).where(eq2(dnsRecords.id, id)).run(); + }).where(eq3(dnsRecords.id, id)).run(); } function setDnsSyncStatus(db, id, syncStatus, cfRecordId, lastError) { db.update(dnsRecords).set({ @@ -760,19 +849,19 @@ function setDnsSyncStatus(db, id, syncStatus, cfRecordId, lastError) { cf_record_id: cfRecordId, last_error: lastError, updated_at: sql2`datetime('now')` - }).where(eq2(dnsRecords.id, id)).run(); + }).where(eq3(dnsRecords.id, id)).run(); } function deleteDnsRecord(db, id) { - db.delete(dnsRecords).where(eq2(dnsRecords.id, id)).run(); + db.delete(dnsRecords).where(eq3(dnsRecords.id, id)).run(); } function listDnsByDomain(db, domainId) { - return db.select().from(dnsRecords).where(eq2(dnsRecords.domain_id, domainId)).all().map(mapDnsRecord); + return db.select().from(dnsRecords).where(eq3(dnsRecords.domain_id, domainId)).all().map(mapDnsRecord); } function findDnsByCfId(db, domainId, cfRecordId) { const row = db.select().from(dnsRecords).where( - and( - eq2(dnsRecords.domain_id, domainId), - eq2(dnsRecords.cf_record_id, cfRecordId) + and2( + eq3(dnsRecords.domain_id, domainId), + eq3(dnsRecords.cf_record_id, cfRecordId) ) ).get(); return row ? mapDnsRecord(row) : null; @@ -781,7 +870,7 @@ function markDnsPendingDelete(db, id) { setDnsSyncStatus(db, id, "pending_delete", null, null); } function maxSortOrderInGroup(db, groupId) { - const condition = groupId === null ? isNull(services.service_group_id) : eq2(services.service_group_id, groupId); + const condition = groupId === null ? isNull(services.service_group_id) : eq3(services.service_group_id, groupId); const row = db.select({ maxOrder: sql2`coalesce(max(${services.sort_order}), -1)` }).from(services).where(condition).get(); return row?.maxOrder ?? -1; } @@ -789,13 +878,13 @@ function listServices(db) { return db.select().from(services).orderBy(asc(services.sort_order), asc(services.name)).all(); } function listServicesByGroup(db, groupId) { - return db.select().from(services).where(eq2(services.service_group_id, groupId)).orderBy(asc(services.sort_order), asc(services.name)).all(); + return db.select().from(services).where(eq3(services.service_group_id, groupId)).orderBy(asc(services.sort_order), asc(services.name)).all(); } function listUngroupedServices(db) { return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.sort_order), asc(services.name)).all(); } function getService(db, id) { - const row = db.select().from(services).where(eq2(services.id, id)).get(); + const row = db.select().from(services).where(eq3(services.id, id)).get(); if (!row) throw new NotFoundError(`service ${id}`); return row; } @@ -810,11 +899,11 @@ function updateService(db, id, name, slug) { slug, subdomain: slug, updated_at: sql2`datetime('now')` - }).where(eq2(services.id, id)).run(); + }).where(eq3(services.id, id)).run(); return getService(db, id); } function setServiceEnabled(db, id, enabled) { - db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq2(services.id, id)).run(); + db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq3(services.id, id)).run(); return getService(db, id); } function setServiceLb(db, id, weight, priority) { @@ -822,7 +911,7 @@ function setServiceLb(db, id, weight, priority) { lb_weight: weight, lb_priority: priority, updated_at: sql2`datetime('now')` - }).where(eq2(services.id, id)).run(); + }).where(eq3(services.id, id)).run(); } function setServiceGroup(db, id, groupId) { const sortOrder = maxSortOrderInGroup(db, groupId) + 1; @@ -830,14 +919,14 @@ function setServiceGroup(db, id, groupId) { service_group_id: groupId, sort_order: sortOrder, updated_at: sql2`datetime('now')` - }).where(eq2(services.id, id)).run(); + }).where(eq3(services.id, id)).run(); } function reorderServices(db, groupId, orderedIds) { const uniqueIds = new Set(orderedIds); if (uniqueIds.size !== orderedIds.length) { throw new Error("duplicate service ids in reorder request"); } - const condition = groupId === null ? isNull(services.service_group_id) : eq2(services.service_group_id, groupId); + const condition = groupId === null ? isNull(services.service_group_id) : eq3(services.service_group_id, groupId); const existing = db.select({ id: services.id }).from(services).where(condition).all().map((row) => row.id); const existingSet = new Set(existing); for (const serviceId of orderedIds) { @@ -847,12 +936,12 @@ function reorderServices(db, groupId, orderedIds) { } db.transaction((tx) => { for (let index = 0; index < orderedIds.length; index++) { - tx.update(services).set({ sort_order: index, updated_at: sql2`datetime('now')` }).where(eq2(services.id, orderedIds[index])).run(); + tx.update(services).set({ sort_order: index, updated_at: sql2`datetime('now')` }).where(eq3(services.id, orderedIds[index])).run(); } }); } function deleteService(db, id) { - const result = db.delete(services).where(eq2(services.id, id)).run(); + const result = db.delete(services).where(eq3(services.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service ${id}`); } function mapServiceGroup(row) { @@ -880,7 +969,7 @@ function listServiceGroups(db) { return db.select().from(serviceGroups).orderBy(asc(serviceGroups.name)).all().map(mapServiceGroup); } function getServiceGroup(db, id) { - const row = db.select().from(serviceGroups).where(eq2(serviceGroups.id, id)).get(); + const row = db.select().from(serviceGroups).where(eq3(serviceGroups.id, id)).get(); if (!row) throw new NotFoundError(`service group ${id}`); return mapServiceGroup(row); } @@ -929,37 +1018,37 @@ function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) { if (lbPatch.health_check_verify_tls !== void 0) update.health_check_verify_tls = lbPatch.health_check_verify_tls; } - const result = db.update(serviceGroups).set(update).where(eq2(serviceGroups.id, id)).run(); + const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service group ${id}`); return getServiceGroup(db, id); } function setServiceGroupEnabled(db, id, enabled) { - const result = db.update(serviceGroups).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq2(serviceGroups.id, id)).run(); + const result = db.update(serviceGroups).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq3(serviceGroups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service group ${id}`); return getServiceGroup(db, id); } function deleteServiceGroup(db, id) { - const result = db.delete(serviceGroups).where(eq2(serviceGroups.id, id)).run(); + const result = db.delete(serviceGroups).where(eq3(serviceGroups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service group ${id}`); } function listServiceIps(db, serviceId) { - return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq2(serviceIps.service_id, serviceId)).all().map((r) => r.ip); + return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((r) => r.ip); } function replaceServiceIps(db, serviceId, ips) { - db.delete(serviceIps).where(eq2(serviceIps.service_id, serviceId)).run(); + db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run(); for (const ip of ips) { db.insert(serviceIps).values({ service_id: serviceId, ip }).run(); } } function listBindingIps(db, bindingId) { - return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq2(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip); + return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip); } function listBindingIpsWithMeta(db, bindingId) { return db.select({ ip: serviceBindingIps.ip, weight: serviceBindingIps.weight, priority: serviceBindingIps.priority - }).from(serviceBindingIps).where(eq2(serviceBindingIps.binding_id, bindingId)).all(); + }).from(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).all(); } function replaceBindingIps(db, bindingId, ips) { replaceBindingIpsWithMeta( @@ -969,7 +1058,7 @@ function replaceBindingIps(db, bindingId, ips) { ); } function replaceBindingIpsWithMeta(db, bindingId, entries) { - db.delete(serviceBindingIps).where(eq2(serviceBindingIps.binding_id, bindingId)).run(); + db.delete(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).run(); for (const entry of entries) { db.insert(serviceBindingIps).values({ binding_id: bindingId, @@ -1000,13 +1089,13 @@ function updateBindingLbConfig(db, bindingId, patch) { update.health_check_timeout_ms = patch.health_check_timeout_ms; if (patch.health_check_verify_tls !== void 0) update.health_check_verify_tls = patch.health_check_verify_tls; - db.update(serviceBindings).set(update).where(eq2(serviceBindings.id, bindingId)).run(); + db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run(); } function setBindingCnameTarget(db, bindingId, target) { db.update(serviceBindings).set({ cname_target: target, updated_at: sql2`datetime('now')` - }).where(eq2(serviceBindings.id, bindingId)).run(); + }).where(eq3(serviceBindings.id, bindingId)).run(); } function listRecordsForBinding(db, bindingId) { return db.all(sql2` @@ -1024,9 +1113,9 @@ function linkBindingRecord(db, bindingId, dnsRecordId) { } function unlinkBindingRecord(db, bindingId, dnsRecordId) { db.delete(serviceBindingRecords).where( - and( - eq2(serviceBindingRecords.binding_id, bindingId), - eq2(serviceBindingRecords.dns_record_id, dnsRecordId) + and2( + eq3(serviceBindingRecords.binding_id, bindingId), + eq3(serviceBindingRecords.dns_record_id, dnsRecordId) ) ).run(); } @@ -1046,9 +1135,9 @@ function linkGroupDnsRecord(db, groupId, dnsRecordId) { } function unlinkGroupDnsRecord(db, groupId, dnsRecordId) { db.delete(serviceGroupDnsRecords).where( - and( - eq2(serviceGroupDnsRecords.group_id, groupId), - eq2(serviceGroupDnsRecords.dns_record_id, dnsRecordId) + and2( + eq3(serviceGroupDnsRecords.group_id, groupId), + eq3(serviceGroupDnsRecords.dns_record_id, dnsRecordId) ) ).run(); } @@ -1138,7 +1227,7 @@ function listBindingsByService(db, serviceId) { `); } function getBinding(db, id) { - const row = db.select().from(serviceBindings).where(eq2(serviceBindings.id, id)).get(); + const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get(); if (!row) throw new NotFoundError(`service binding ${id}`); return row; } @@ -1157,10 +1246,10 @@ function getBindingView(db, id) { } function findBinding(db, serviceId, domainId, hostname) { const row = db.select().from(serviceBindings).where( - and( - eq2(serviceBindings.service_id, serviceId), - eq2(serviceBindings.domain_id, domainId), - eq2(serviceBindings.hostname, hostname) + and2( + eq3(serviceBindings.service_id, serviceId), + eq3(serviceBindings.domain_id, domainId), + eq3(serviceBindings.hostname, hostname) ) ).get(); return row ?? null; @@ -1180,43 +1269,43 @@ function updateBindingFields(db, id, serviceId, hostname, dnsRecordId) { hostname, dns_record_id: dnsRecordId, updated_at: sql2`datetime('now')` - }).where(eq2(serviceBindings.id, id)).run(); + }).where(eq3(serviceBindings.id, id)).run(); } function setBindingDnsRecordId(db, bindingId, dnsRecordId) { db.update(serviceBindings).set({ dns_record_id: dnsRecordId, updated_at: sql2`datetime('now')` - }).where(eq2(serviceBindings.id, bindingId)).run(); + }).where(eq3(serviceBindings.id, bindingId)).run(); } function bindingsToRemove(db, serviceId, keepIds) { - const all = db.select().from(serviceBindings).where(eq2(serviceBindings.service_id, serviceId)).all(); + const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all(); return all.filter((b) => !keepIds.includes(b.id)); } function deleteBindingsExcept(db, serviceId, keepIds) { - const all = db.select().from(serviceBindings).where(eq2(serviceBindings.service_id, serviceId)).all(); + const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all(); for (const binding of all) { if (!keepIds.includes(binding.id)) { - db.delete(serviceBindings).where(eq2(serviceBindings.id, binding.id)).run(); + db.delete(serviceBindings).where(eq3(serviceBindings.id, binding.id)).run(); } } } function deleteBinding(db, id) { - const result = db.delete(serviceBindings).where(eq2(serviceBindings.id, id)).run(); + const result = db.delete(serviceBindings).where(eq3(serviceBindings.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service binding ${id}`); } function listCertificates(db, status) { if (status) { - return db.select().from(certificates).where(eq2(certificates.status, status)).orderBy(asc(certificates.expires_at)).all(); + return db.select().from(certificates).where(eq3(certificates.status, status)).orderBy(asc(certificates.expires_at)).all(); } return db.select().from(certificates).orderBy(asc(certificates.expires_at)).all(); } function getCertificate(db, id) { - const row = db.select().from(certificates).where(eq2(certificates.id, id)).get(); + const row = db.select().from(certificates).where(eq3(certificates.id, id)).get(); if (!row) throw new NotFoundError(`certificate ${id}`); return row; } function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError) { - const existing = db.select().from(certificates).where(eq2(certificates.hostname, hostname)).get(); + const existing = db.select().from(certificates).where(eq3(certificates.hostname, hostname)).get(); if (existing) { db.update(certificates).set({ domain_id: domainId, @@ -1226,7 +1315,7 @@ function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, last_error: lastError, status, updated_at: sql2`datetime('now')` - }).where(eq2(certificates.id, existing.id)).run(); + }).where(eq3(certificates.id, existing.id)).run(); return getCertificate(db, existing.id); } const id = db.insert(certificates).values({ @@ -1259,7 +1348,7 @@ function createSyncJob(db, id, domainId) { db.insert(syncJobs).values({ id, domain_id: domainId, status: "pending" }).run(); } function getSyncJob(db, id) { - const row = db.select().from(syncJobs).where(eq2(syncJobs.id, id)).get(); + const row = db.select().from(syncJobs).where(eq3(syncJobs.id, id)).get(); if (!row) throw new NotFoundError(`sync job ${id}`); return row; } @@ -1268,7 +1357,7 @@ function finishSyncJob(db, id, status, message) { status, message, finished_at: sql2`datetime('now')` - }).where(eq2(syncJobs.id, id)).run(); + }).where(eq3(syncJobs.id, id)).run(); } function listIpHealthStatus(db, scope, refId) { return db.all(sql2` @@ -1409,18 +1498,18 @@ function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecuti } function deleteIpHealthStatusForRef(db, scope, refId) { db.delete(ipHealthStatus).where( - and( - eq2(ipHealthStatus.scope, scope), - eq2(ipHealthStatus.ref_id, refId) + and2( + eq3(ipHealthStatus.scope, scope), + eq3(ipHealthStatus.ref_id, refId) ) ).run(); } function deleteIpHealthStatusForIp(db, scope, refId, ip) { db.delete(ipHealthStatus).where( - and( - eq2(ipHealthStatus.scope, scope), - eq2(ipHealthStatus.ref_id, refId), - eq2(ipHealthStatus.ip, ip) + and2( + eq3(ipHealthStatus.scope, scope), + eq3(ipHealthStatus.ref_id, refId), + eq3(ipHealthStatus.ip, ip) ) ).run(); } @@ -1553,10 +1642,10 @@ function listHealthCheckTargets(db) { })); } function listDomainTags(db, domainId) { - return db.select({ tag: domainTags.tag }).from(domainTags).where(eq2(domainTags.domain_id, domainId)).all().map((r) => r.tag); + return db.select({ tag: domainTags.tag }).from(domainTags).where(eq3(domainTags.domain_id, domainId)).all().map((r) => r.tag); } function setDomainTags(db, domainId, tags) { - db.delete(domainTags).where(eq2(domainTags.domain_id, domainId)).run(); + db.delete(domainTags).where(eq3(domainTags.domain_id, domainId)).run(); const unique = [...new Set(tags.map((t) => t.trim()).filter(Boolean))]; for (const tag of unique) { db.insert(domainTags).values({ domain_id: domainId, tag }).run(); @@ -1573,13 +1662,13 @@ function addDomainTags(db, domainId, tags) { } } function listDomainMonitors(db, domainId) { - return db.select().from(domainMonitors).where(eq2(domainMonitors.domain_id, domainId)).orderBy(asc(domainMonitors.id)).all(); + return db.select().from(domainMonitors).where(eq3(domainMonitors.domain_id, domainId)).orderBy(asc(domainMonitors.id)).all(); } function listEnabledDomainMonitors(db) { - return db.select().from(domainMonitors).where(eq2(domainMonitors.enabled, true)).all(); + return db.select().from(domainMonitors).where(eq3(domainMonitors.enabled, true)).all(); } function getDomainMonitor(db, id) { - const row = db.select().from(domainMonitors).where(eq2(domainMonitors.id, id)).get(); + const row = db.select().from(domainMonitors).where(eq3(domainMonitors.id, id)).get(); if (!row) throw new NotFoundError(`domain_monitor ${id}`); return row; } @@ -1597,7 +1686,7 @@ function createDomainMonitor(db, domainId, input) { return getDomainMonitor(db, id); } function deleteDomainMonitor(db, id) { - const result = db.delete(domainMonitors).where(eq2(domainMonitors.id, id)).run(); + const result = db.delete(domainMonitors).where(eq3(domainMonitors.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`domain_monitor ${id}`); } function updateDomainMonitorResult(db, monitorId, status, latencyMs, error) { @@ -1607,7 +1696,7 @@ function updateDomainMonitorResult(db, monitorId, status, latencyMs, error) { last_checked_at: sql2`datetime('now')`, last_error: error, updated_at: sql2`datetime('now')` - }).where(eq2(domainMonitors.id, monitorId)).run(); + }).where(eq3(domainMonitors.id, monitorId)).run(); db.insert(domainMonitorResults).values({ monitor_id: monitorId, status, @@ -1665,6 +1754,8 @@ export { ConflictError, NotFoundError, appSettings, + appendAudit, + auditLog, certificates, createDb, createMemoryDb, @@ -1678,6 +1769,7 @@ export { groups, healthCheck, ipHealthStatus, + listAudit, notificationLog, repos_exports as repos, resolveDatabasePath, diff --git a/packages/db/migrations/017_audit_log.sql b/packages/db/migrations/017_audit_log.sql new file mode 100644 index 0000000..e341fb4 --- /dev/null +++ b/packages/db/migrations/017_audit_log.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY NOT NULL, + event_id TEXT, + source_app TEXT NOT NULL DEFAULT 'cfdm', + action TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'info', + actor_user_id TEXT, + actor_email TEXT, + actor_name TEXT, + target_type TEXT, + target_id TEXT, + summary TEXT NOT NULL, + details_json TEXT, + ip TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at); +CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_id, created_at); +CREATE INDEX IF NOT EXISTS idx_audit_log_source ON audit_log(source_app, created_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log(event_id) WHERE event_id IS NOT NULL; diff --git a/packages/db/src/audit-log.ts b/packages/db/src/audit-log.ts new file mode 100644 index 0000000..3d9e549 --- /dev/null +++ b/packages/db/src/audit-log.ts @@ -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 | null; + ip?: string | null; + createdAt?: string | null; +}; + +function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry { + let details: Record | null = null; + if (row.details_json) { + try { + details = JSON.parse(row.details_json) as Record; + } 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); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 83cfb20..8030bcf 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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"; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 71ba224..0fd69d4 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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, }; diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index 10ead42..50f6469 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -1503,4 +1503,112 @@ declare const vpsTrackerEventSchema: z.ZodObject<{ }, z.core.$strip>; type VpsTrackerEvent = z.infer; -export { type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NotificationLog, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, notificationLogSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema }; +declare const AUDIT_SEVERITIES: readonly ["info", "warning", "critical"]; +type AuditSeverity = (typeof AUDIT_SEVERITIES)[number]; +declare const auditSeveritySchema: z.ZodEnum<{ + warning: "warning"; + info: "info"; + critical: "critical"; +}>; +declare const AUDIT_SOURCE_APPS: readonly ["portal", "vps", "cfdm", "bgp", "fw"]; +type AuditSourceApp = (typeof AUDIT_SOURCE_APPS)[number]; +declare const auditSourceAppSchema: z.ZodEnum<{ + bgp: "bgp"; + vps: "vps"; + portal: "portal"; + cfdm: "cfdm"; + fw: "fw"; +}>; +declare const AUDIT_TARGET_TYPES: readonly ["user", "settings", "session", "system", "app_resource"]; +type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]; +declare const auditTargetTypeSchema: z.ZodEnum<{ + user: "user"; + settings: "settings"; + session: "session"; + system: "system"; + app_resource: "app_resource"; +}>; +declare const auditLogEntrySchema: z.ZodObject<{ + id: z.ZodString; + event_id: z.ZodNullable; + source_app: z.ZodEnum<{ + bgp: "bgp"; + vps: "vps"; + portal: "portal"; + cfdm: "cfdm"; + fw: "fw"; + }>; + action: z.ZodString; + severity: z.ZodEnum<{ + warning: "warning"; + info: "info"; + critical: "critical"; + }>; + actor_user_id: z.ZodNullable; + actor_email: z.ZodNullable; + actor_name: z.ZodNullable; + target_type: z.ZodNullable>; + target_id: z.ZodNullable; + summary: z.ZodString; + details: z.ZodNullable>; + ip: z.ZodNullable; + created_at: z.ZodString; +}, z.core.$strip>; +type AuditLogEntry = z.infer; +declare const auditListQuerySchema: z.ZodObject<{ + action: z.ZodOptional; + severity: z.ZodOptional>; + user_id: z.ZodOptional; + source_app: z.ZodOptional>; + limit: z.ZodDefault>; +}, z.core.$strip>; +type AuditListQuery = z.infer; +declare const ingestAuditEventSchema: z.ZodObject<{ + event_id: z.ZodString; + source_app: z.ZodEnum<{ + bgp: "bgp"; + vps: "vps"; + cfdm: "cfdm"; + fw: "fw"; + }>; + action: z.ZodString; + severity: z.ZodOptional>; + actor_user_id: z.ZodOptional>; + actor_email: z.ZodOptional>; + actor_name: z.ZodOptional>; + target_type: z.ZodOptional>>; + target_id: z.ZodOptional>; + summary: z.ZodString; + details: z.ZodOptional>>; + ip: z.ZodOptional>; + created_at: z.ZodOptional; +}, z.core.$strip>; +type IngestAuditEvent = z.infer; + +export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NotificationLog, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, notificationLogSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema }; diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index f12b20c..eff9434 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -601,7 +601,69 @@ var vpsTrackerEventSchema = z3.object({ ), timestamp: z3.string().datetime().optional() }); + +// src/audit.ts +import { z as z4 } from "zod"; +var AUDIT_SEVERITIES = ["info", "warning", "critical"]; +var auditSeveritySchema = z4.enum(AUDIT_SEVERITIES); +var AUDIT_SOURCE_APPS = [ + "portal", + "vps", + "cfdm", + "bgp", + "fw" +]; +var auditSourceAppSchema = z4.enum(AUDIT_SOURCE_APPS); +var AUDIT_TARGET_TYPES = [ + "user", + "settings", + "session", + "system", + "app_resource" +]; +var auditTargetTypeSchema = z4.enum(AUDIT_TARGET_TYPES); +var auditLogEntrySchema = z4.object({ + id: z4.string(), + event_id: z4.string().nullable(), + source_app: auditSourceAppSchema, + action: z4.string(), + severity: auditSeveritySchema, + actor_user_id: z4.string().nullable(), + actor_email: z4.string().nullable(), + actor_name: z4.string().nullable(), + target_type: auditTargetTypeSchema.nullable(), + target_id: z4.string().nullable(), + summary: z4.string(), + details: z4.record(z4.string(), z4.unknown()).nullable(), + ip: z4.string().nullable(), + created_at: z4.string() +}); +var auditListQuerySchema = z4.object({ + action: z4.string().optional(), + severity: auditSeveritySchema.optional(), + user_id: z4.string().optional(), + source_app: auditSourceAppSchema.optional(), + limit: z4.coerce.number().int().min(1).max(500).default(200) +}); +var ingestAuditEventSchema = z4.object({ + event_id: z4.string().min(1).max(128), + source_app: z4.enum(["vps", "cfdm", "bgp", "fw"]), + action: z4.string().min(1).max(200), + severity: auditSeveritySchema.optional(), + actor_user_id: z4.string().nullable().optional(), + actor_email: z4.string().email().nullable().optional(), + actor_name: z4.string().nullable().optional(), + target_type: auditTargetTypeSchema.nullable().optional(), + target_id: z4.string().nullable().optional(), + summary: z4.string().min(1).max(500), + details: z4.record(z4.string(), z4.unknown()).nullable().optional(), + ip: z4.string().nullable().optional(), + created_at: z4.string().optional() +}); export { + AUDIT_SEVERITIES, + AUDIT_SOURCE_APPS, + AUDIT_TARGET_TYPES, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, @@ -621,6 +683,11 @@ export { appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, + auditListQuerySchema, + auditLogEntrySchema, + auditSeveritySchema, + auditSourceAppSchema, + auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, @@ -653,6 +720,7 @@ export { healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, + ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, diff --git a/packages/shared/src/audit.ts b/packages/shared/src/audit.ts new file mode 100644 index 0000000..7a438ab --- /dev/null +++ b/packages/shared/src/audit.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +export const AUDIT_SEVERITIES = ["info", "warning", "critical"] as const; +export type AuditSeverity = (typeof AUDIT_SEVERITIES)[number]; +export const auditSeveritySchema = z.enum(AUDIT_SEVERITIES); + +export const AUDIT_SOURCE_APPS = [ + "portal", + "vps", + "cfdm", + "bgp", + "fw", +] as const; +export type AuditSourceApp = (typeof AUDIT_SOURCE_APPS)[number]; +export const auditSourceAppSchema = z.enum(AUDIT_SOURCE_APPS); + +export const AUDIT_TARGET_TYPES = [ + "user", + "settings", + "session", + "system", + "app_resource", +] as const; +export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]; +export const auditTargetTypeSchema = z.enum(AUDIT_TARGET_TYPES); + +export const auditLogEntrySchema = z.object({ + id: z.string(), + event_id: z.string().nullable(), + source_app: auditSourceAppSchema, + action: z.string(), + severity: auditSeveritySchema, + actor_user_id: z.string().nullable(), + actor_email: z.string().nullable(), + actor_name: z.string().nullable(), + target_type: auditTargetTypeSchema.nullable(), + target_id: z.string().nullable(), + summary: z.string(), + details: z.record(z.string(), z.unknown()).nullable(), + ip: z.string().nullable(), + created_at: z.string(), +}); +export type AuditLogEntry = z.infer; + +export const auditListQuerySchema = z.object({ + action: z.string().optional(), + severity: auditSeveritySchema.optional(), + user_id: z.string().optional(), + source_app: auditSourceAppSchema.optional(), + limit: z.coerce.number().int().min(1).max(500).default(200), +}); +export type AuditListQuery = z.infer; + +export const ingestAuditEventSchema = z.object({ + event_id: z.string().min(1).max(128), + source_app: z.enum(["vps", "cfdm", "bgp", "fw"]), + action: z.string().min(1).max(200), + severity: auditSeveritySchema.optional(), + actor_user_id: z.string().nullable().optional(), + actor_email: z.string().email().nullable().optional(), + actor_name: z.string().nullable().optional(), + target_type: auditTargetTypeSchema.nullable().optional(), + target_id: z.string().nullable().optional(), + summary: z.string().min(1).max(500), + details: z.record(z.string(), z.unknown()).nullable().optional(), + ip: z.string().nullable().optional(), + created_at: z.string().optional(), +}); +export type IngestAuditEvent = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4b95a74..329035a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,7 @@ export * from "./parse-fqdn.js"; export * from "./schemas.js"; export * from "./app-switcher.js"; export * from "./integration-vps-tracker.js"; +export * from "./audit.js"; export type { CfZone, CfDnsRecord,