fix: enforce public contrast and audit rank errors

This commit is contained in:
Simo committed 2026-07-11 22:06:45 +02:00
1 parent e1381c0f40
commit b695a33ead
12 files changed
+184 -29

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
export interface AuthorizationEvent {
kind: "permission.denied" | "permission.load_error";
userId: number;
username?: string;
rank: number;
permission?: string;
source: string;
reason: string;
error?: unknown;
}
const clean = (value: string) => value.replace(/(token|password|secret|cookie|authorization|select|insert|update|delete)[^\s]*/gi, "[REDACTED]").slice(0, 160);
export function authorizationActivity(event: AuthorizationEvent) {
const description = [
`user=${clean(event.username ?? String(event.userId))}`,
`rank=${event.rank}`,
event.permission ? `permission=${clean(event.permission)}` : null,
`source=${clean(event.source)}`,
`reason=${clean(event.reason)}`,
].filter(Boolean).join("; ");
return { staffId: event.userId, action: event.kind, description, targetType: "user", targetId: event.userId };
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { authorizationActivity } from "@/lib/admin/authorization-event";
describe("authorizationActivity", () => {
it("creates a safe rank-aware denial record", () => {
const record = authorizationActivity({ kind: "permission.denied", userId: 42, username: "admin", rank: 11, permission: "admin.logs.view", source: "/admin/logs", reason: "missing permission" });
expect(record.action).toBe("permission.denied");
expect(record.description).toContain("rank=11");
expect(record.description).toContain("permission=admin.logs.view");
});
it("redacts secrets and technical details", () => {
const record = authorizationActivity({ kind: "permission.load_error", userId: 42, rank: 11, source: "permissions", reason: "token=abc password=hunter2 SELECT * FROM users" });
expect(record.description).not.toMatch(/abc|hunter2|SELECT/i);
});
});
+9
View File
@@ -0,0 +1,9 @@
import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { authorizationActivity, type AuthorizationEvent } from "@/lib/admin/authorization-event";
export async function logAuthorizationEvent(event: AuthorizationEvent): Promise<void> {
const correlationId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
await logStaffActivity({ ...authorizationActivity(event), description: `${authorizationActivity(event).description}; correlation=${correlationId}` });
if (event.error) logServerError(event.kind, event.error, { correlationId, userId: event.userId, rank: event.rank, permission: event.permission ?? null, source: event.source });
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
describe("buildStaffActivityWhere", () => {
it("filters authorization events by prefix", () => {
expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({ action: { startsWith: "permission." } });
});
it("combines staff and search filters", () => {
const result = buildStaffActivityWhere({ q: "rank", staffId: 11, authorizationOnly: true });
expect(result).toMatchObject({ userId: 11n, action: { startsWith: "permission." } });
expect(result.OR).toHaveLength(3);
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { Prisma } from "@/generated/prisma/client";
export interface StaffActivityFilters { q?: string; staffId?: number | null; action?: string | null; authorizationOnly?: boolean }
export function buildStaffActivityWhere(filters: StaffActivityFilters): Prisma.StaffActivitiesWhereInput {
const where: Prisma.StaffActivitiesWhereInput = {};
if (filters.q?.trim()) where.OR = [
{ action: { contains: filters.q.trim() } },
{ description: { contains: filters.q.trim() } },
{ ipAddress: { contains: filters.q.trim() } },
];
if (filters.staffId) where.userId = BigInt(filters.staffId);
if (filters.authorizationOnly) where.action = { startsWith: "permission." };
else if (filters.action) where.action = { contains: filters.action };
return where;
}
+3 -1
View File
@@ -4,6 +4,7 @@ import { cache } from 'react'
import { auth } from './auth'
import { sessionUserId } from './auth/session-user'
import { prisma } from './prisma'
import { logAuthorizationEvent } from './admin/authorization-events'
// Re-export PERMS from the standalone file (safe for client components)
export { PERMS } from './permission-slugs'
@@ -82,7 +83,8 @@ export const loadUserPermissions = cache(async function loadUserPermissions(
hasAll: (...perms: string[]) => perms.every((p) => slugs.has(p)),
isSuperAdmin: false,
}
} catch {
} catch (error) {
await logAuthorizationEvent({ kind: 'permission.load_error', userId, rank, source: 'loadUserPermissions', reason: 'ACL query failed', error })
// Fail-closed: return empty set on any error
return createEmptySet()
}
+6
View File
@@ -2,6 +2,7 @@ import type { z } from 'zod'
import { auth } from '@/lib/auth'
import { canAccess, getApiAdminContext } from '@/lib/permissions'
import { type ActionResult, actionError, handleActionError } from '@/lib/safe-action-shared'
import { logAuthorizationEvent } from '@/lib/admin/authorization-events'
export type { ActionResult }
@@ -42,6 +43,11 @@ export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
if (options.permission) {
if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) {
await logAuthorizationEvent({
kind: 'permission.denied', userId: apiCtx.session.user.id,
username: apiCtx.session.user.name ?? undefined, rank: apiCtx.session.user.rank,
permission: options.permission, source: 'adminAction', reason: 'Permission check denied',
})
return actionError('Unauthorized')
}
}
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { contrastRatio, readableColor } from "@/lib/theme-contrast";
import { contrastRatio, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
import { PRESETS } from "@/lib/theme-presets";
describe("contrastRatio", () => {
@@ -64,3 +64,27 @@ describe("readableColor", () => {
expect(readableColor("rgb(1, 2, 3)", ["#ffffff"], 4.5)).toBe("rgb(1, 2, 3)");
});
});
describe("derivePublicForegrounds", () => {
it.each(Object.entries(PRESETS))("makes every semantic pair readable for %s", (_name, preset) => {
const result = derivePublicForegrounds(preset);
for (const pair of result.pairs) {
expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
}
});
it("repairs hostile white-on-white custom colors", () => {
const result = derivePublicForegrounds({
color_background: "#ffffff", color_surface: "#ffffff", color_dropdown: "#ffffff",
color_navbar: "#ffffff", color_navbar_text: "#ffffff", color_text: "#ffffff",
color_text_muted: "#ffffff", color_primary: "#ffffff", color_accent: "#ffffff",
button_primary_color: "#ffffff", button_text_color: "#ffffff",
button_secondary_color: "#ffffff", button_secondary_text_color: "#ffffff",
button_danger_color: "#ffffff", button_danger_text_color: "#ffffff",
button_outline_color: "#ffffff", button_outline_text_color: "#ffffff",
link_color: "#ffffff", link_hover_color: "#ffffff",
});
expect(result.pairs.length).toBeGreaterThan(10);
for (const pair of result.pairs) expect(contrastRatio(pair.foreground, pair.background), pair.name).toBeGreaterThanOrEqual(4.5);
});
});
+35
View File
@@ -1,5 +1,40 @@
type Rgb = readonly [number, number, number];
export type PublicThemeColors = Record<string, string>;
export interface ContrastPair { name: string; foreground: string; background: string }
export function derivePublicForegrounds(c: PublicThemeColors) {
const value = (key: string, fallback: string) => c[key] ?? fallback;
const background = value("color_background", "#f8fafc");
const surface = value("color_surface", "#ffffff");
const dropdown = value("color_dropdown", surface);
const navbar = value("color_navbar", surface);
const primary = value("color_primary", "#f59e0b");
const accent = value("color_accent", "#10b981");
const make = (name: string, preferred: string, bg: string): ContrastPair => ({
name, foreground: readableColor(preferred, [bg]), background: bg,
});
const pairs = [
make("body", value("color_text", "#0f172a"), background),
make("surface", value("color_text", "#0f172a"), surface),
make("muted", value("color_text_muted", "#64748b"), surface),
make("link", value("link_color", primary), surface),
make("link-hover", value("link_hover_color", primary), surface),
make("navbar", value("color_navbar_text", "#1e293b"), navbar),
make("dropdown", value("color_text", "#0f172a"), dropdown),
make("primary", value("button_text_color", "#1e293b"), primary),
make("accent", "#ffffff", accent),
make("button-primary", value("button_text_color", "#1e293b"), value("button_primary_color", primary)),
make("button-secondary", value("button_secondary_text_color", "#ffffff"), value("button_secondary_color", "#22c55e")),
make("button-danger", value("button_danger_text_color", "#ffffff"), value("button_danger_color", "#ef4444")),
make("button-outline", value("button_outline_text_color", "#1a1a2e"), surface),
make("pill", value("color_text", "#0f172a"), surface),
make("badge", value("button_text_color", "#1e293b"), primary),
make("overlay", "#ffffff", "#111827"),
];
return { pairs, byName: Object.fromEntries(pairs.map((pair) => [pair.name, pair.foreground])) };
}
export function contrastRatio(foreground: string, background: string): number | null {
const fg = parseHex(foreground);
const bg = parseHex(background);