mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(enterprise): add configuration export/import with dry-run and dependency validation
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db, schema } from "../../db/index.js";
|
||||
import { auditFromRequest } from "../../lib/audit.js";
|
||||
import { requirePermission } from "../../permissions.js";
|
||||
|
||||
const CONFIG_SCHEMA_VERSION = 1;
|
||||
|
||||
const REDACTED_KEYS = new Set([
|
||||
"cookie_secret",
|
||||
"instance_id",
|
||||
"siem_config",
|
||||
"scim_token_hash",
|
||||
"oidc_client_secret",
|
||||
"saml_idp_certificate",
|
||||
"siem_last_forwarded_at",
|
||||
"siem_consecutive_failures",
|
||||
"audit_archival_state",
|
||||
"backup_last_completed",
|
||||
]);
|
||||
|
||||
const importSchema = z.object({
|
||||
dryRun: z.boolean().default(false),
|
||||
config: z.object({
|
||||
configSchemaVersion: z.number(),
|
||||
settings: z.record(z.string()).optional(),
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
permissions: z.array(z.string()),
|
||||
toolPermissions: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
teams: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
storageQuota: z.number().nullable().optional(),
|
||||
retentionHours: z.number().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export async function registerConfigRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/v1/enterprise/config/export
|
||||
app.get(
|
||||
"/api/v1/enterprise/config/export",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = await requirePermission("system:health")(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
// Enterprise feature gate
|
||||
let featureEnabled = false;
|
||||
try {
|
||||
const { isFeatureEnabled } = await import("@snapotter/enterprise");
|
||||
featureEnabled = isFeatureEnabled("config_export_import");
|
||||
} catch {
|
||||
// Enterprise package not available
|
||||
}
|
||||
if (!featureEnabled) {
|
||||
return reply.status(403).send({
|
||||
error:
|
||||
"Configuration export requires an enterprise license with the config_export_import feature",
|
||||
});
|
||||
}
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
configSchemaVersion: CONFIG_SCHEMA_VERSION,
|
||||
appVersion: process.env.APP_VERSION || "unknown",
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings: {} as Record<string, string>,
|
||||
roles: [] as Array<Record<string, unknown>>,
|
||||
teams: [] as Array<Record<string, unknown>>,
|
||||
};
|
||||
|
||||
// Read all settings, redact sensitive keys
|
||||
const allSettings = await db.select().from(schema.settings);
|
||||
const settingsMap = config.settings as Record<string, string>;
|
||||
for (const s of allSettings) {
|
||||
if (!REDACTED_KEYS.has(s.key)) {
|
||||
settingsMap[s.key] = s.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Export custom roles (not built-in)
|
||||
const customRoles = await db
|
||||
.select()
|
||||
.from(schema.roles)
|
||||
.where(eq(schema.roles.isBuiltin, false));
|
||||
config.roles = customRoles.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
permissions: r.permissions,
|
||||
toolPermissions: r.toolPermissions,
|
||||
}));
|
||||
|
||||
// Export teams
|
||||
const allTeams = await db.select().from(schema.teams);
|
||||
config.teams = allTeams.map((t) => ({
|
||||
name: t.name,
|
||||
storageQuota: t.storageQuota,
|
||||
retentionHours: t.retentionHours,
|
||||
}));
|
||||
|
||||
await auditFromRequest(request)("CONFIG_EXPORTED", {
|
||||
adminId: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
reply.header("content-type", "application/json");
|
||||
reply.header("content-disposition", 'attachment; filename="snapotter-config.json"');
|
||||
return config;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/v1/enterprise/config/import
|
||||
app.post(
|
||||
"/api/v1/enterprise/config/import",
|
||||
async (request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) => {
|
||||
const user = await requirePermission("system:health")(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
// Enterprise feature gate
|
||||
let featureEnabled = false;
|
||||
try {
|
||||
const { isFeatureEnabled } = await import("@snapotter/enterprise");
|
||||
featureEnabled = isFeatureEnabled("config_export_import");
|
||||
} catch {
|
||||
// Enterprise package not available
|
||||
}
|
||||
if (!featureEnabled) {
|
||||
return reply.status(403).send({
|
||||
error:
|
||||
"Configuration import requires an enterprise license with the config_export_import feature",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = importSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid import payload", details: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { dryRun, config } = parsed.data;
|
||||
|
||||
// Reject future schema versions
|
||||
if (config.configSchemaVersion > CONFIG_SCHEMA_VERSION) {
|
||||
return reply.status(400).send({
|
||||
error: `Unsupported config schema version ${config.configSchemaVersion} (current: ${CONFIG_SCHEMA_VERSION})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Dependency validation
|
||||
const validationErrors: string[] = [];
|
||||
|
||||
if (config.settings) {
|
||||
// SSO enforcement requires OIDC or SAML to be configured
|
||||
if (config.settings.ssoEnforcement === "true") {
|
||||
const hasOidc = config.settings.oidcIssuer || config.settings.oidcClientId;
|
||||
const hasSaml = config.settings.samlIdpUrl || config.settings.samlEntityId;
|
||||
if (!hasOidc && !hasSaml) {
|
||||
validationErrors.push(
|
||||
"ssoEnforcement is enabled but no OIDC or SAML provider is configured in the import",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// IP allowlist must have at least one CIDR
|
||||
if (config.settings.ipAllowlist) {
|
||||
try {
|
||||
const cidrs = JSON.parse(config.settings.ipAllowlist);
|
||||
if (!Array.isArray(cidrs) || cidrs.length === 0) {
|
||||
validationErrors.push("ipAllowlist is set but contains no CIDR entries");
|
||||
}
|
||||
} catch {
|
||||
validationErrors.push("ipAllowlist contains invalid JSON");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return reply.status(400).send({
|
||||
error: "Dependency validation failed",
|
||||
validationErrors,
|
||||
});
|
||||
}
|
||||
|
||||
// Build a change summary
|
||||
const changes = {
|
||||
settings: 0,
|
||||
roles: 0,
|
||||
teams: 0,
|
||||
};
|
||||
|
||||
// Dry-run: describe what would change
|
||||
const settingsToUpdate: Array<{ key: string; action: string }> = [];
|
||||
const rolesToUpsert: Array<{ name: string; action: string }> = [];
|
||||
const teamsToUpsert: Array<{ name: string; action: string }> = [];
|
||||
|
||||
if (config.settings) {
|
||||
const existingSettings = await db.select().from(schema.settings);
|
||||
const existingKeys = new Set(existingSettings.map((s) => s.key));
|
||||
|
||||
for (const key of Object.keys(config.settings)) {
|
||||
// Skip redacted keys in import as well
|
||||
if (REDACTED_KEYS.has(key)) continue;
|
||||
settingsToUpdate.push({
|
||||
key,
|
||||
action: existingKeys.has(key) ? "update" : "create",
|
||||
});
|
||||
}
|
||||
changes.settings = settingsToUpdate.length;
|
||||
}
|
||||
|
||||
if (config.roles) {
|
||||
const existingRoles = await db
|
||||
.select()
|
||||
.from(schema.roles)
|
||||
.where(eq(schema.roles.isBuiltin, false));
|
||||
const existingRoleNames = new Set(existingRoles.map((r) => r.name));
|
||||
|
||||
for (const role of config.roles) {
|
||||
rolesToUpsert.push({
|
||||
name: role.name,
|
||||
action: existingRoleNames.has(role.name) ? "update" : "create",
|
||||
});
|
||||
}
|
||||
changes.roles = rolesToUpsert.length;
|
||||
}
|
||||
|
||||
if (config.teams) {
|
||||
const existingTeams = await db.select().from(schema.teams);
|
||||
const existingTeamNames = new Set(existingTeams.map((t) => t.name));
|
||||
|
||||
for (const team of config.teams) {
|
||||
teamsToUpsert.push({
|
||||
name: team.name,
|
||||
action: existingTeamNames.has(team.name) ? "update" : "create",
|
||||
});
|
||||
}
|
||||
changes.teams = teamsToUpsert.length;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return reply.send({
|
||||
dryRun: true,
|
||||
changes,
|
||||
details: {
|
||||
settings: settingsToUpdate,
|
||||
roles: rolesToUpsert,
|
||||
teams: teamsToUpsert,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
const now = new Date();
|
||||
|
||||
// Upsert settings
|
||||
if (config.settings) {
|
||||
const existingSettings = await db.select().from(schema.settings);
|
||||
const existingKeys = new Set(existingSettings.map((s) => s.key));
|
||||
|
||||
for (const [key, value] of Object.entries(config.settings)) {
|
||||
if (REDACTED_KEYS.has(key)) continue;
|
||||
|
||||
if (existingKeys.has(key)) {
|
||||
await db
|
||||
.update(schema.settings)
|
||||
.set({ value, updatedAt: now })
|
||||
.where(eq(schema.settings.key, key));
|
||||
} else {
|
||||
await db.insert(schema.settings).values({ key, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert custom roles
|
||||
if (config.roles) {
|
||||
const existingRoles = await db
|
||||
.select()
|
||||
.from(schema.roles)
|
||||
.where(eq(schema.roles.isBuiltin, false));
|
||||
const existingRoleMap = new Map(existingRoles.map((r) => [r.name, r]));
|
||||
|
||||
for (const role of config.roles) {
|
||||
const existing = existingRoleMap.get(role.name);
|
||||
if (existing) {
|
||||
await db
|
||||
.update(schema.roles)
|
||||
.set({
|
||||
description: role.description ?? "",
|
||||
permissions: role.permissions,
|
||||
toolPermissions: role.toolPermissions ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.roles.id, existing.id));
|
||||
} else {
|
||||
await db.insert(schema.roles).values({
|
||||
id: randomUUID(),
|
||||
name: role.name,
|
||||
description: role.description ?? "",
|
||||
permissions: role.permissions,
|
||||
toolPermissions: role.toolPermissions ?? null,
|
||||
isBuiltin: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert teams
|
||||
if (config.teams) {
|
||||
const existingTeams = await db.select().from(schema.teams);
|
||||
const existingTeamMap = new Map(existingTeams.map((t) => [t.name, t]));
|
||||
|
||||
for (const team of config.teams) {
|
||||
const existing = existingTeamMap.get(team.name);
|
||||
if (existing) {
|
||||
await db
|
||||
.update(schema.teams)
|
||||
.set({
|
||||
storageQuota: team.storageQuota ?? null,
|
||||
retentionHours: team.retentionHours ?? null,
|
||||
})
|
||||
.where(eq(schema.teams.id, existing.id));
|
||||
} else {
|
||||
await db.insert(schema.teams).values({
|
||||
id: randomUUID(),
|
||||
name: team.name,
|
||||
storageQuota: team.storageQuota ?? null,
|
||||
retentionHours: team.retentionHours ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await auditFromRequest(request)("CONFIG_IMPORTED", {
|
||||
adminId: user.id,
|
||||
username: user.username,
|
||||
dryRun: false,
|
||||
changes,
|
||||
});
|
||||
|
||||
return reply.send({ applied: true, changes });
|
||||
},
|
||||
);
|
||||
|
||||
app.log.info("Enterprise config export/import routes registered");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { registerAuditExport } from "./audit-export.js";
|
||||
import { registerConfigRoutes } from "./config.js";
|
||||
import { registerGdprRoutes } from "./gdpr.js";
|
||||
import { registerIpAllowlistRoutes } from "./ip-allowlist.js";
|
||||
import { registerLegalHoldRoutes } from "./legal-hold.js";
|
||||
@@ -9,6 +10,7 @@ import { registerUpgradeRoutes } from "./upgrade.js";
|
||||
|
||||
export async function registerEnterpriseRoutes(app: FastifyInstance) {
|
||||
await registerAuditExport(app);
|
||||
await registerConfigRoutes(app);
|
||||
await registerGdprRoutes(app);
|
||||
await registerIpAllowlistRoutes(app);
|
||||
await registerLegalHoldRoutes(app);
|
||||
|
||||
Reference in New Issue
Block a user