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

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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 13:24:32 +07:00
co-authored by Cursor
parent 30447d8380
commit 859ad23006
22 changed files with 1561 additions and 106 deletions
+573 -2
View File
File diff suppressed because one or more lines are too long
+187 -95
View File
@@ -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,
+23
View File
@@ -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;
+131
View File
@@ -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<string, unknown> | null;
ip?: string | null;
createdAt?: string | null;
};
function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
let details: Record<string, unknown> | null = null;
if (row.details_json) {
try {
details = JSON.parse(row.details_json) as Record<string, unknown>;
} 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);
}
+1
View File
@@ -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";
+20
View File
@@ -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,
};