mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: resolve 6 bugs from enterprise audit (SIEM cursor, legal hold join, SCIM role, GDPR self-purge, export OOM, quota check)
This commit is contained in:
@@ -7,10 +7,10 @@
|
||||
* cursor-based approach to avoid re-sending events.
|
||||
*
|
||||
* State keys in the settings table:
|
||||
* - siem_last_forwarded_id: cursor (last successfully forwarded audit log ID)
|
||||
* - siem_last_forwarded_at: cursor (last successfully forwarded createdAt timestamp)
|
||||
* - siem_consecutive_failures: circuit breaker counter
|
||||
*/
|
||||
import { asc, eq, gt } from "drizzle-orm";
|
||||
import { asc, eq, gte } from "drizzle-orm";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { decrypt, isEncrypted } from "../lib/encryption.js";
|
||||
@@ -19,7 +19,7 @@ import { readSiemConfig } from "../routes/enterprise/siem.js";
|
||||
|
||||
const BATCH_LIMIT = 500;
|
||||
const CIRCUIT_BREAKER_THRESHOLD = 5;
|
||||
const CURSOR_KEY = "siem_last_forwarded_id";
|
||||
const CURSOR_KEY = "siem_last_forwarded_at";
|
||||
const FAILURES_KEY = "siem_consecutive_failures";
|
||||
|
||||
async function readSettingValue(key: string): Promise<string | null> {
|
||||
@@ -31,10 +31,7 @@ async function readSettingValue(key: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, key));
|
||||
const [existing] = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
@@ -59,7 +56,7 @@ export async function runSiemForward(): Promise<{ forwarded: number } | void> {
|
||||
if (failureCount >= CIRCUIT_BREAKER_THRESHOLD) {
|
||||
console.warn(
|
||||
`SIEM forwarding circuit breaker open: ${failureCount} consecutive failures. ` +
|
||||
"Reset siem_consecutive_failures to 0 in settings to re-enable.",
|
||||
"Reset siem_consecutive_failures to 0 in settings to re-enable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -67,8 +64,10 @@ export async function runSiemForward(): Promise<{ forwarded: number } | void> {
|
||||
// 3. Read cursor
|
||||
const cursor = await readSettingValue(CURSOR_KEY);
|
||||
|
||||
// 4. Query audit_log for new rows
|
||||
const conditions = cursor ? gt(schema.auditLog.id, cursor) : undefined;
|
||||
// 4. Query audit_log for new rows (createdAt-based cursor; may re-deliver
|
||||
// boundary events on restart, but SIEMs handle idempotent ingestion)
|
||||
const cursorDate = cursor ? new Date(cursor) : null;
|
||||
const conditions = cursorDate ? gte(schema.auditLog.createdAt, cursorDate) : undefined;
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.auditLog)
|
||||
@@ -104,8 +103,8 @@ export async function runSiemForward(): Promise<{ forwarded: number } | void> {
|
||||
|
||||
// 8. Update state based on result
|
||||
if (result.success) {
|
||||
const lastId = rows[rows.length - 1].id;
|
||||
await upsertSetting(CURSOR_KEY, lastId);
|
||||
const lastRow = rows[rows.length - 1];
|
||||
await upsertSetting(CURSOR_KEY, lastRow.createdAt.toISOString());
|
||||
if (failureCount > 0) {
|
||||
await upsertSetting(FAILURES_KEY, "0");
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ async function storageTtlSweep(): Promise<{ removed: number; failed: number }> {
|
||||
const heldUserIds = new Set(heldUserRows.map((r) => r.id));
|
||||
|
||||
const heldTeamRows = await db
|
||||
.select({ name: schema.teams.name })
|
||||
.select({ id: schema.teams.id })
|
||||
.from(schema.teams)
|
||||
.where(eq(schema.teams.legalHold, true));
|
||||
if (heldTeamRows.length > 0) {
|
||||
@@ -155,7 +155,7 @@ async function storageTtlSweep(): Promise<{ removed: number; failed: number }> {
|
||||
.where(
|
||||
inArray(
|
||||
schema.users.team,
|
||||
heldTeamRows.map((r) => r.name),
|
||||
heldTeamRows.map((r) => r.id),
|
||||
),
|
||||
);
|
||||
for (const u of teamUsers) heldUserIds.add(u.id);
|
||||
@@ -264,7 +264,7 @@ async function retentionSweep(): Promise<void> {
|
||||
// Subquery to find users under legal hold (direct or via team)
|
||||
const heldUsersSubquery = sql`(
|
||||
SELECT u.id FROM users u
|
||||
LEFT JOIN teams t ON u.team = t.name
|
||||
LEFT JOIN teams t ON u.team = t.id
|
||||
WHERE u.legal_hold = true OR t.legal_hold = true
|
||||
)`;
|
||||
|
||||
|
||||
@@ -50,14 +50,16 @@ export async function registerAuditExport(app: FastifyInstance): Promise<void> {
|
||||
// Enterprise package not available
|
||||
}
|
||||
if (!featureEnabled) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Audit export requires an enterprise license with the audit_export feature" });
|
||||
return reply.status(403).send({
|
||||
error: "Audit export requires an enterprise license with the audit_export feature",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = querySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Invalid query parameters", details: parsed.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid query parameters", details: parsed.error.issues });
|
||||
}
|
||||
const { format, from, to, action, actorId, targetType, targetId } = parsed.data;
|
||||
|
||||
@@ -84,11 +86,17 @@ export async function registerAuditExport(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const EXPORT_LIMIT = 100_000;
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(schema.auditLog)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.auditLog.createdAt));
|
||||
.orderBy(desc(schema.auditLog.createdAt))
|
||||
.limit(EXPORT_LIMIT);
|
||||
|
||||
if (entries.length === EXPORT_LIMIT) {
|
||||
reply.header("X-Truncated", "true");
|
||||
}
|
||||
|
||||
const rows = entries.map((e) => ({
|
||||
id: e.id,
|
||||
|
||||
@@ -223,6 +223,11 @@ export async function registerGdprRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const targetUserId = request.params.id;
|
||||
|
||||
// Guard: admin cannot purge themselves
|
||||
if (targetUserId === user.id) {
|
||||
return reply.status(400).send({ error: "Cannot purge your own account" });
|
||||
}
|
||||
|
||||
// Check target user exists
|
||||
const [targetUser] = await db
|
||||
.select({
|
||||
|
||||
@@ -100,7 +100,7 @@ function toScimUser(
|
||||
id: user.id,
|
||||
userName: user.username,
|
||||
...(user.externalId ? { externalId: user.externalId } : {}),
|
||||
active: user.role !== "disabled",
|
||||
active: user.role !== "disabled" && !user.role.startsWith("disabled:"),
|
||||
emails: user.email ? [{ value: user.email, primary: true }] : [],
|
||||
name: { formatted: user.username },
|
||||
groups: user.team ? [{ value: user.team, display: teamName ?? user.team }] : [],
|
||||
@@ -454,11 +454,13 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
|
||||
updates.email = email;
|
||||
}
|
||||
|
||||
// Handle active/deactivation
|
||||
if (active && existing.role === "disabled") {
|
||||
updates.role = "user";
|
||||
} else if (!active && existing.role !== "disabled") {
|
||||
updates.role = "disabled";
|
||||
// Handle active/deactivation (preserve original role through disable/enable cycle)
|
||||
if (active && existing.role.startsWith("disabled:")) {
|
||||
updates.role = existing.role.slice("disabled:".length);
|
||||
} else if (active && existing.role === "disabled") {
|
||||
updates.role = "user"; // fallback when no previous role stored
|
||||
} else if (!active && !existing.role.startsWith("disabled")) {
|
||||
updates.role = `disabled:${existing.role}`;
|
||||
// Revoke all sessions on deactivation
|
||||
await db.delete(schema.sessions).where(eq(schema.sessions.userId, id));
|
||||
}
|
||||
@@ -527,10 +529,12 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
|
||||
const activeVal =
|
||||
op.path === "active" ? op.value : (op.value as Record<string, unknown>).active;
|
||||
const active = activeVal === true || activeVal === "true" || activeVal === "True";
|
||||
if (active && existing.role === "disabled") {
|
||||
updates.role = "user";
|
||||
} else if (!active && existing.role !== "disabled") {
|
||||
updates.role = "disabled";
|
||||
if (active && existing.role.startsWith("disabled:")) {
|
||||
updates.role = existing.role.slice("disabled:".length);
|
||||
} else if (active && existing.role === "disabled") {
|
||||
updates.role = "user"; // fallback when no previous role stored
|
||||
} else if (!active && !existing.role.startsWith("disabled")) {
|
||||
updates.role = `disabled:${existing.role}`;
|
||||
await db.delete(schema.sessions).where(eq(schema.sessions.userId, id));
|
||||
}
|
||||
}
|
||||
@@ -605,11 +609,11 @@ export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.status(404).send(scimError(404, "User not found"));
|
||||
}
|
||||
|
||||
// Soft-delete: set role to disabled, revoke sessions, clear password
|
||||
// Soft-delete: preserve original role so reactivation can restore it
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({
|
||||
role: "disabled",
|
||||
role: `disabled:${user.role}`,
|
||||
passwordHash: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -261,6 +261,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
|
||||
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
|
||||
|
||||
// Re-check quota with actual file size before persisting
|
||||
try {
|
||||
await checkStorageQuota(userId, safeBuffer.length);
|
||||
} catch (err) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
|
||||
return reply.status(statusCode).send({ error: (err as Error).message });
|
||||
}
|
||||
|
||||
const safeName = sanitizeFilename(part.filename ?? "upload");
|
||||
const mimeType = formatToMime(validation.format);
|
||||
|
||||
@@ -699,6 +707,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Sanitize SVG results to prevent XXE, SSRF, and script injection
|
||||
const safeResultBuffer = isSvgBuffer(fileBuffer) ? sanitizeSvg(fileBuffer) : fileBuffer;
|
||||
|
||||
// Re-check quota with actual file size before persisting
|
||||
try {
|
||||
await checkStorageQuota(userId, safeResultBuffer.length);
|
||||
} catch (err) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
|
||||
return reply.status(statusCode).send({ error: (err as Error).message });
|
||||
}
|
||||
|
||||
// Persist to disk
|
||||
const storedName = await saveFile(safeResultBuffer, resultName);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user