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
|
* Reads unforwarded audit log entries and delivers them to the configured
|
||||||
* SIEM endpoint via the webhook delivery module. Implements a circuit
|
* 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>;
|
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
|
// Generate TOTP secret
|
||||||
const totp = createTotp(user.username);
|
const totp = createTotp(user.username);
|
||||||
const uri = totp.toString();
|
const uri = totp.toString();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const REDACTED_KEYS = new Set([
|
|||||||
"siem_consecutive_failures",
|
"siem_consecutive_failures",
|
||||||
"audit_archival_state",
|
"audit_archival_state",
|
||||||
"backup_last_completed",
|
"backup_last_completed",
|
||||||
|
"webhook_destinations",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const importSchema = z.object({
|
const importSchema = z.object({
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
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 type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, schema } from "../../db/index.js";
|
import { db, schema } from "../../db/index.js";
|
||||||
|
import { requestCancel } from "../../jobs/cancel.js";
|
||||||
import { getQueue } from "../../jobs/queues.js";
|
import { getQueue } from "../../jobs/queues.js";
|
||||||
import { SYSTEM_JOBS } from "../../jobs/system-jobs.js";
|
import { SYSTEM_JOBS } from "../../jobs/system-jobs.js";
|
||||||
import { auditFromRequest } from "../../lib/audit.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));
|
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
|
await db
|
||||||
.update(schema.auditLog)
|
.update(schema.auditLog)
|
||||||
.set({ actorUsername: "[redacted]", ipAddress: null, details: {} })
|
.set({ actorUsername: "[redacted]", ipAddress: null, details: {} })
|
||||||
.where(eq(schema.auditLog.actorId, userId));
|
.where(eq(schema.auditLog.actorId, userId));
|
||||||
|
|
||||||
// f. Delete sessions
|
// g. Delete sessions
|
||||||
await db.delete(schema.sessions).where(eq(schema.sessions.userId, userId));
|
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));
|
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));
|
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));
|
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 { and, eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { db, schema } from "../../db/index.js";
|
import { db, schema } from "../../db/index.js";
|
||||||
|
import { sharedRedis } from "../../jobs/connection.js";
|
||||||
import { auditLog } from "../../lib/audit.js";
|
import { auditLog } from "../../lib/audit.js";
|
||||||
import { getSettingString } from "../../lib/settings-helpers.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 ────────────────────────────────────────────
|
// ── SCIM Error Format ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,6 +40,16 @@ async function scimAuth(request: FastifyRequest, reply: FastifyReply): Promise<b
|
|||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +179,70 @@ function scimListResponse(
|
|||||||
|
|
||||||
// ── Route Registration ───────────────────────────────────────────
|
// ── 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> {
|
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) ─────────────────────
|
// ── Discovery Endpoints (no auth required) ─────────────────────
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
|
|||||||
Reference in New Issue
Block a user