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 type { FastifyInstance } from "fastify";
|
||||||
import { registerAuditExport } from "./audit-export.js";
|
import { registerAuditExport } from "./audit-export.js";
|
||||||
|
import { registerConfigRoutes } from "./config.js";
|
||||||
import { registerGdprRoutes } from "./gdpr.js";
|
import { registerGdprRoutes } from "./gdpr.js";
|
||||||
import { registerIpAllowlistRoutes } from "./ip-allowlist.js";
|
import { registerIpAllowlistRoutes } from "./ip-allowlist.js";
|
||||||
import { registerLegalHoldRoutes } from "./legal-hold.js";
|
import { registerLegalHoldRoutes } from "./legal-hold.js";
|
||||||
@@ -9,6 +10,7 @@ import { registerUpgradeRoutes } from "./upgrade.js";
|
|||||||
|
|
||||||
export async function registerEnterpriseRoutes(app: FastifyInstance) {
|
export async function registerEnterpriseRoutes(app: FastifyInstance) {
|
||||||
await registerAuditExport(app);
|
await registerAuditExport(app);
|
||||||
|
await registerConfigRoutes(app);
|
||||||
await registerGdprRoutes(app);
|
await registerGdprRoutes(app);
|
||||||
await registerIpAllowlistRoutes(app);
|
await registerIpAllowlistRoutes(app);
|
||||||
await registerLegalHoldRoutes(app);
|
await registerLegalHoldRoutes(app);
|
||||||
|
|||||||
Reference in New Issue
Block a user