fix(enterprise): SCIM token generation, GDPR job cancellation, MFA replay, config redaction, encryption validation, SCIM rate limit

- Add POST/DELETE /api/v1/enterprise/scim/token for SCIM bearer token management
- Cancel active BullMQ jobs via requestCancel() before GDPR purge deletes DB rows
- Reject duplicate MFA enrollment when a pending (unverified) secret exists
- Add webhook_destinations to config export REDACTED_KEYS (contains auth headers)
- Validate DATA_ENCRYPTION_KEY and DATA_ENCRYPTION_KEY_PREVIOUS are 64-char hex at startup
- Add 1000 req/min Redis counter rate limit to SCIM auth middleware
- Document SIEM/webhook system coexistence in siem-forward.ts
This commit is contained in:
SnapOtter
2026-06-14 14:03:47 +08:00
parent d86e4585e4
commit 29dd675f42
6 changed files with 138 additions and 10 deletions
+5 -1
View File
@@ -1,5 +1,9 @@
/**
* SIEM forwarding job.
* SIEM forwarding via the legacy siem_config settings key.
* Phase 4 introduced a unified webhook system (webhook_destinations),
* which also supports type: "siem" destinations. Currently these coexist --
* a future release will migrate siem_config to the unified system.
* For now, admins configure SIEM in either system (not both).
*
* Reads unforwarded audit log entries and delivers them to the configured
* SIEM endpoint via the webhook delivery module. Implements a circuit
+18
View File
@@ -197,6 +197,24 @@ const envSchema = z
});
}
}
if (data.DATA_ENCRYPTION_KEY) {
if (!/^[0-9a-fA-F]{64}$/.test(data.DATA_ENCRYPTION_KEY)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["DATA_ENCRYPTION_KEY"],
message: "DATA_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)",
});
}
}
if (data.DATA_ENCRYPTION_KEY_PREVIOUS) {
if (!/^[0-9a-fA-F]{64}$/.test(data.DATA_ENCRYPTION_KEY_PREVIOUS)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["DATA_ENCRYPTION_KEY_PREVIOUS"],
message: "DATA_ENCRYPTION_KEY_PREVIOUS must be a 64-character hex string (32 bytes)",
});
}
}
});
export type Env = z.infer<typeof envSchema>;
+9
View File
@@ -142,6 +142,15 @@ export async function registerMfa(app: FastifyInstance): Promise<void> {
});
}
// Check if there's already a pending (unverified) enrollment
if (dbUser.totpSecret && !dbUser.totpEnabled) {
return reply.status(409).send({
error:
"MFA enrollment already pending. Complete verification first or contact an admin to reset.",
code: "MFA_ENROLLMENT_PENDING",
});
}
// Generate TOTP secret
const totp = createTotp(user.username);
const uri = totp.toString();
+1
View File
@@ -19,6 +19,7 @@ const REDACTED_KEYS = new Set([
"siem_consecutive_failures",
"audit_archival_state",
"backup_last_completed",
"webhook_destinations",
]);
const importSchema = z.object({
+28 -7
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db, schema } from "../../db/index.js";
import { requestCancel } from "../../jobs/cancel.js";
import { getQueue } from "../../jobs/queues.js";
import { SYSTEM_JOBS } from "../../jobs/system-jobs.js";
import { auditFromRequest } from "../../lib/audit.js";
@@ -52,25 +53,45 @@ async function purgeUserData(userId: string): Promise<void> {
}
}
// d. Delete jobs rows
// d. Cancel any active BullMQ jobs before deleting DB rows
const activeJobs = await db
.select({ id: schema.jobs.id })
.from(schema.jobs)
.where(
and(eq(schema.jobs.userId, userId), inArray(schema.jobs.status, ["queued", "processing"])),
);
if (activeJobs.length > 0) {
for (const job of activeJobs) {
try {
await requestCancel(job.id);
} catch {
// Best-effort cancellation
}
}
// Wait briefly for cancellation to propagate
await new Promise((r) => setTimeout(r, 500));
}
// e. Delete jobs rows
await db.delete(schema.jobs).where(eq(schema.jobs.userId, userId));
// e. Redact audit log entries (preserve structure, remove PII)
// f. Redact audit log entries (preserve structure, remove PII)
await db
.update(schema.auditLog)
.set({ actorUsername: "[redacted]", ipAddress: null, details: {} })
.where(eq(schema.auditLog.actorId, userId));
// f. Delete sessions
// g. Delete sessions
await db.delete(schema.sessions).where(eq(schema.sessions.userId, userId));
// g. Delete apiKeys
// h. Delete apiKeys
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, userId));
// h. Delete userPreferences
// i. Delete userPreferences
await db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId));
// i. Delete user row (also cascades pipelines)
// j. Delete user row (also cascades pipelines)
await db.delete(schema.users).where(eq(schema.users.id, userId));
}
+77 -2
View File
@@ -1,10 +1,12 @@
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../../db/index.js";
import { sharedRedis } from "../../jobs/connection.js";
import { auditLog } from "../../lib/audit.js";
import { getSettingString } from "../../lib/settings-helpers.js";
import { verifyPassword } from "../../plugins/auth.js";
import { requirePermission } from "../../permissions.js";
import { hashPassword, verifyPassword } from "../../plugins/auth.js";
// ── SCIM Error Format ────────────────────────────────────────────
@@ -38,6 +40,16 @@ async function scimAuth(request: FastifyRequest, reply: FastifyReply): Promise<b
return false;
}
// Rate limit: 1000 req/min per SCIM token
const redis = sharedRedis();
const rateLimitKey = `ratelimit:scim:${tokenHash.slice(0, 16)}`;
const count = await redis.incr(rateLimitKey);
if (count === 1) await redis.expire(rateLimitKey, 60);
if (count > 1000) {
reply.status(429).send(scimError(429, "SCIM rate limit exceeded (1000 req/min)"));
return false;
}
return true;
}
@@ -167,7 +179,70 @@ function scimListResponse(
// ── Route Registration ───────────────────────────────────────────
async function upsertSetting(key: string, value: string): Promise<void> {
const [existing] = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
if (existing) {
await db
.update(schema.settings)
.set({ value, updatedAt: new Date() })
.where(eq(schema.settings.key, key));
} else {
await db.insert(schema.settings).values({ key, value });
}
}
export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// ── Token Management Endpoints ────────────────────────────────
// POST /api/v1/enterprise/scim/token -- generate a SCIM bearer token
app.post(
"/api/v1/enterprise/scim/token",
async (request: FastifyRequest, reply: FastifyReply) => {
const user = await requirePermission("users:manage")(request, reply);
if (!user) return;
if (!(await requireScimFeature(reply))) return;
const token = randomBytes(32).toString("hex");
const hash = await hashPassword(token);
await upsertSetting("scim_token_hash", hash);
await auditLog(
request.log,
"SETTINGS_UPDATED",
{ setting: "scim_token" },
request.ip,
request.id,
);
return reply.status(201).send({
token,
message: "Save this token -- it cannot be retrieved again",
});
},
);
// DELETE /api/v1/enterprise/scim/token -- revoke the SCIM bearer token
app.delete(
"/api/v1/enterprise/scim/token",
async (request: FastifyRequest, reply: FastifyReply) => {
const user = await requirePermission("users:manage")(request, reply);
if (!user) return;
if (!(await requireScimFeature(reply))) return;
await db.delete(schema.settings).where(eq(schema.settings.key, "scim_token_hash"));
await auditLog(
request.log,
"SETTINGS_UPDATED",
{ setting: "scim_token", action: "revoked" },
request.ip,
request.id,
);
return reply.status(204).send();
},
);
// ── Discovery Endpoints (no auth required) ─────────────────────
app.get(