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
+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(