mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -19,6 +19,7 @@ const REDACTED_KEYS = new Set([
|
||||
"siem_consecutive_failures",
|
||||
"audit_archival_state",
|
||||
"backup_last_completed",
|
||||
"webhook_destinations",
|
||||
]);
|
||||
|
||||
const importSchema = z.object({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user