This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -2,20 +2,23 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ACL management contract", () => {
|
||||
it("uses normalized ACL persistence and the permissions.manage guard", () => {
|
||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||
expect(source).toContain("adminAction");
|
||||
expect(source).toContain("aclModelPermission");
|
||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
||||
expect(source).not.toContain("websiteTeams");
|
||||
});
|
||||
it("uses normalized ACL persistence and the permissions.manage guard", () => {
|
||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||
expect(source).toContain("adminAction");
|
||||
expect(source).toContain("aclModelPermission");
|
||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
||||
expect(source).not.toContain("websiteTeams");
|
||||
});
|
||||
|
||||
it("ships an idempotent ACL completion migration", () => {
|
||||
const sql = readFileSync("prisma/migrations/0014_complete_acl_and_import_permissions.sql", "utf8");
|
||||
expect(sql).toContain("admin.assets.import");
|
||||
expect(sql).toContain("rank_");
|
||||
expect(sql).toContain("'Role'");
|
||||
expect(sql).toContain("'User'");
|
||||
});
|
||||
it("ships an idempotent ACL completion migration", () => {
|
||||
const sql = readFileSync(
|
||||
"prisma/migrations/0014_complete_acl_and_import_permissions.sql",
|
||||
"utf8",
|
||||
);
|
||||
expect(sql).toContain("admin.assets.import");
|
||||
expect(sql).toContain("rank_");
|
||||
expect(sql).toContain("'Role'");
|
||||
expect(sql).toContain("'User'");
|
||||
});
|
||||
});
|
||||
@@ -4,24 +4,29 @@ import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
describe("production ACL migration", () => {
|
||||
const migrationPath = resolve(process.cwd(), "prisma/migrations/0012_seed_acl_permissions.sql");
|
||||
const migrationPath = resolve(
|
||||
process.cwd(),
|
||||
"prisma/migrations/0012_seed_acl_permissions.sql",
|
||||
);
|
||||
|
||||
it("seeds every permission used by the application", () => {
|
||||
const sql = readFileSync(migrationPath, "utf8");
|
||||
it("seeds every permission used by the application", () => {
|
||||
const sql = readFileSync(migrationPath, "utf8");
|
||||
|
||||
for (const slug of Object.values(PERMS)) {
|
||||
expect(sql, `missing ACL seed for ${slug}`).toContain(`'${slug}'`);
|
||||
}
|
||||
});
|
||||
for (const slug of Object.values(PERMS)) {
|
||||
expect(sql, `missing ACL seed for ${slug}`).toContain(`'${slug}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns dashboard access after seeding permissions", () => {
|
||||
const sql = readFileSync(migrationPath, "utf8");
|
||||
const seedPosition = sql.indexOf("INSERT INTO `acl_permissions`");
|
||||
const assignmentPosition = sql.indexOf("INSERT INTO `acl_model_permissions`");
|
||||
it("assigns dashboard access after seeding permissions", () => {
|
||||
const sql = readFileSync(migrationPath, "utf8");
|
||||
const seedPosition = sql.indexOf("INSERT INTO `acl_permissions`");
|
||||
const assignmentPosition = sql.indexOf(
|
||||
"INSERT INTO `acl_model_permissions`",
|
||||
);
|
||||
|
||||
expect(seedPosition).toBeGreaterThanOrEqual(0);
|
||||
expect(assignmentPosition).toBeGreaterThan(seedPosition);
|
||||
expect(sql).toContain("ap.slug = 'admin.dashboard'");
|
||||
expect(sql).toContain("ap.slug = 'mod.dashboard'");
|
||||
});
|
||||
expect(seedPosition).toBeGreaterThanOrEqual(0);
|
||||
expect(assignmentPosition).toBeGreaterThan(seedPosition);
|
||||
expect(sql).toContain("ap.slug = 'admin.dashboard'");
|
||||
expect(sql).toContain("ap.slug = 'mod.dashboard'");
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,14 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("authorization source contract", () => {
|
||||
it("contains no fixed numeric rank threshold in central authorization files", () => {
|
||||
for (const file of ["src/lib/permissions.ts", "src/lib/proxy-access.ts", "src/lib/admin/guard.ts"]) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
expect(source, file).not.toMatch(/rank\s*[<>]=?\s*\d+/i);
|
||||
}
|
||||
});
|
||||
it("contains no fixed numeric rank threshold in central authorization files", () => {
|
||||
for (const file of [
|
||||
"src/lib/permissions.ts",
|
||||
"src/lib/proxy-access.ts",
|
||||
"src/lib/admin/guard.ts",
|
||||
]) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
expect(source, file).not.toMatch(/rank\s*[<>]=?\s*\d+/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,37 @@
|
||||
export interface AuthorizationEvent {
|
||||
kind: "permission.denied" | "permission.load_error";
|
||||
userId: number;
|
||||
username?: string;
|
||||
rank: number;
|
||||
permission?: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
error?: unknown;
|
||||
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);
|
||||
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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -2,29 +2,29 @@ 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("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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,24 @@
|
||||
import {
|
||||
type AuthorizationEvent,
|
||||
authorizationActivity,
|
||||
} from "@/lib/admin/authorization-event";
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,40 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decideAuthorization, isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import {
|
||||
decideAuthorization,
|
||||
isDynamicSuperAdmin,
|
||||
} from "@/lib/admin/authorization-policy";
|
||||
|
||||
describe("isDynamicSuperAdmin", () => {
|
||||
it.each([
|
||||
[7, 7],
|
||||
[11, 11],
|
||||
[2000, 2000],
|
||||
])("accepts highest rank %i", (rank, highest) => {
|
||||
expect(isDynamicSuperAdmin(rank, highest)).toBe(true);
|
||||
});
|
||||
it("demotes the previous highest rank", () => expect(isDynamicSuperAdmin(2000, 2001)).toBe(false));
|
||||
it("fails closed without ranks", () => expect(isDynamicSuperAdmin(1, null)).toBe(false));
|
||||
it.each([
|
||||
[7, 7],
|
||||
[11, 11],
|
||||
[2000, 2000],
|
||||
])("accepts highest rank %i", (rank, highest) => {
|
||||
expect(isDynamicSuperAdmin(rank, highest)).toBe(true);
|
||||
});
|
||||
it("demotes the previous highest rank", () =>
|
||||
expect(isDynamicSuperAdmin(2000, 2001)).toBe(false));
|
||||
it("fails closed without ranks", () =>
|
||||
expect(isDynamicSuperAdmin(1, null)).toBe(false));
|
||||
});
|
||||
|
||||
describe("decideAuthorization", () => {
|
||||
const actor = { id: 1, username: "admin", rank: 11 };
|
||||
it("allows the dynamically highest rank", () =>
|
||||
expect(
|
||||
decideAuthorization({ actor, highestRank: 11, permission: "admin.any", hasPermission: false }).allowed,
|
||||
).toBe(true));
|
||||
it("allows explicit ACL permission below highest", () =>
|
||||
expect(
|
||||
decideAuthorization({ actor, highestRank: 12, permission: "admin.news.view", hasPermission: true })
|
||||
.allowed,
|
||||
).toBe(true));
|
||||
it("denies invalid ranks", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor: { ...actor, rank: 0 },
|
||||
highestRank: 11,
|
||||
permission: "admin.any",
|
||||
hasPermission: true,
|
||||
}),
|
||||
).toMatchObject({ allowed: false, reason: "invalid_rank" }));
|
||||
it("denies missing permission", () =>
|
||||
expect(
|
||||
decideAuthorization({ actor, highestRank: 12, permission: "admin.any", hasPermission: false }),
|
||||
).toMatchObject({ allowed: false, reason: "permission_denied" }));
|
||||
const actor = { id: 1, username: "admin", rank: 11 };
|
||||
it("allows the dynamically highest rank", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor,
|
||||
highestRank: 11,
|
||||
permission: "admin.any",
|
||||
hasPermission: false,
|
||||
}).allowed,
|
||||
).toBe(true));
|
||||
it("allows explicit ACL permission below highest", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor,
|
||||
highestRank: 12,
|
||||
permission: "admin.news.view",
|
||||
hasPermission: true,
|
||||
}).allowed,
|
||||
).toBe(true));
|
||||
it("denies invalid ranks", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor: { ...actor, rank: 0 },
|
||||
highestRank: 11,
|
||||
permission: "admin.any",
|
||||
hasPermission: true,
|
||||
}),
|
||||
).toMatchObject({ allowed: false, reason: "invalid_rank" }));
|
||||
it("denies missing permission", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor,
|
||||
highestRank: 12,
|
||||
permission: "admin.any",
|
||||
hasPermission: false,
|
||||
}),
|
||||
).toMatchObject({ allowed: false, reason: "permission_denied" }));
|
||||
});
|
||||
@@ -1,26 +1,40 @@
|
||||
export interface AuthorizationActor {
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
}
|
||||
export type AuthorizationDenialReason = "invalid_rank" | "permission_denied" | "no_ranks";
|
||||
export type AuthorizationDenialReason =
|
||||
| "invalid_rank"
|
||||
| "permission_denied"
|
||||
| "no_ranks";
|
||||
export type AuthorizationDecision =
|
||||
{ allowed: true; superAdmin: boolean } | { allowed: false; reason: AuthorizationDenialReason };
|
||||
| { allowed: true; superAdmin: boolean }
|
||||
| { allowed: false; reason: AuthorizationDenialReason };
|
||||
|
||||
export function isDynamicSuperAdmin(rank: number, highestRank: number | null): boolean {
|
||||
return Number.isInteger(rank) && rank > 0 && highestRank !== null && rank === highestRank;
|
||||
export function isDynamicSuperAdmin(
|
||||
rank: number,
|
||||
highestRank: number | null,
|
||||
): boolean {
|
||||
return (
|
||||
Number.isInteger(rank) &&
|
||||
rank > 0 &&
|
||||
highestRank !== null &&
|
||||
rank === highestRank
|
||||
);
|
||||
}
|
||||
|
||||
export function decideAuthorization(input: {
|
||||
actor: AuthorizationActor;
|
||||
highestRank: number | null;
|
||||
permission?: string;
|
||||
hasPermission: boolean;
|
||||
actor: AuthorizationActor;
|
||||
highestRank: number | null;
|
||||
permission?: string;
|
||||
hasPermission: boolean;
|
||||
}): AuthorizationDecision {
|
||||
if (!Number.isInteger(input.actor.rank) || input.actor.rank <= 0)
|
||||
return { allowed: false, reason: "invalid_rank" };
|
||||
if (input.highestRank === null) return { allowed: false, reason: "no_ranks" };
|
||||
if (isDynamicSuperAdmin(input.actor.rank, input.highestRank)) return { allowed: true, superAdmin: true };
|
||||
if (!input.permission || input.hasPermission) return { allowed: true, superAdmin: false };
|
||||
return { allowed: false, reason: "permission_denied" };
|
||||
if (!Number.isInteger(input.actor.rank) || input.actor.rank <= 0)
|
||||
return { allowed: false, reason: "invalid_rank" };
|
||||
if (input.highestRank === null) return { allowed: false, reason: "no_ranks" };
|
||||
if (isDynamicSuperAdmin(input.actor.rank, input.highestRank))
|
||||
return { allowed: true, superAdmin: true };
|
||||
if (!input.permission || input.hasPermission)
|
||||
return { allowed: true, superAdmin: false };
|
||||
return { allowed: false, reason: "permission_denied" };
|
||||
}
|
||||
+17
-12
@@ -1,26 +1,31 @@
|
||||
import { isStaff } from "@/lib/admin/is-staff";
|
||||
import { redirectSafe } from "@/lib/foundation/security";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { redirectSafe } from "@/lib/foundation/security";
|
||||
|
||||
export { isStaff };
|
||||
|
||||
export interface StaffUser {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export async function requireStaff(): Promise<StaffUser> {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) redirectSafe("/", "/");
|
||||
return { id: session.user.id, rank: session.user.rank, username: session.user.username };
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||
redirectSafe("/", "/");
|
||||
return {
|
||||
id: session.user.id,
|
||||
rank: session.user.rank,
|
||||
username: session.user.username,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
return staff;
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
return staff;
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { isStaff } from "./is-staff";
|
||||
|
||||
describe("isStaff", () => {
|
||||
it("is true at or above the min staff rank", () => {
|
||||
expect(isStaff(7, 7)).toBe(true);
|
||||
expect(isStaff(10, 7)).toBe(true);
|
||||
});
|
||||
it("is true at or above the min staff rank", () => {
|
||||
expect(isStaff(7, 7)).toBe(true);
|
||||
expect(isStaff(10, 7)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false below the min staff rank", () => {
|
||||
expect(isStaff(6, 7)).toBe(false);
|
||||
expect(isStaff(1, 7)).toBe(false);
|
||||
});
|
||||
it("is false below the min staff rank", () => {
|
||||
expect(isStaff(6, 7)).toBe(false);
|
||||
expect(isStaff(1, 7)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/** AtomCMS housekeeping gate: staff are users with rank >= min_staff_rank. */
|
||||
export function isStaff(rank: number, minStaffRank: number): boolean {
|
||||
return rank >= minStaffRank;
|
||||
return rank >= minStaffRank;
|
||||
}
|
||||
@@ -2,14 +2,21 @@ 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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export interface StaffActivityFilters {
|
||||
q?: string;
|
||||
staffId?: number | null;
|
||||
action?: string | null;
|
||||
authorizationOnly?: boolean;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -2,23 +2,23 @@ import { describe, expect, it } from "vitest";
|
||||
import { adminMutationNotice } from "@/lib/admin/notice";
|
||||
|
||||
describe("adminMutationNotice", () => {
|
||||
it("maps a successful redirect to a safe notice", () => {
|
||||
expect(adminMutationNotice({ saved: "1" })).toEqual({
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
});
|
||||
});
|
||||
it("maps a successful redirect to a safe notice", () => {
|
||||
expect(adminMutationNotice({ saved: "1" })).toEqual({
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps an error code without reflecting arbitrary query text", () => {
|
||||
expect(adminMutationNotice({ error: "<script>" })).toEqual({
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
});
|
||||
});
|
||||
it("maps an error code without reflecting arbitrary query text", () => {
|
||||
expect(adminMutationNotice({ error: "<script>" })).toEqual({
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when there is no mutation result", () => {
|
||||
expect(adminMutationNotice({})).toBeNull();
|
||||
});
|
||||
it("returns null when there is no mutation result", () => {
|
||||
expect(adminMutationNotice({})).toBeNull();
|
||||
});
|
||||
});
|
||||
+22
-19
@@ -1,23 +1,26 @@
|
||||
export interface AdminMutationNotice {
|
||||
tone: "ok" | "danger";
|
||||
label: "Saved" | "Error";
|
||||
message: string;
|
||||
tone: "ok" | "danger";
|
||||
label: "Saved" | "Error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function adminMutationNotice(params: { saved?: string; error?: string }): AdminMutationNotice | null {
|
||||
if (params.error) {
|
||||
return {
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
};
|
||||
}
|
||||
if (params.saved === "1") {
|
||||
return {
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
export function adminMutationNotice(params: {
|
||||
saved?: string;
|
||||
error?: string;
|
||||
}): AdminMutationNotice | null {
|
||||
if (params.error) {
|
||||
return {
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
};
|
||||
}
|
||||
if (params.saved === "1") {
|
||||
return {
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,24 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAuthorizationState } from "@/lib/admin/rank-authority";
|
||||
|
||||
function db(user: { id: number; username: string; rank: number } | null, highest: number | null) {
|
||||
return {
|
||||
user: { findUnique: async () => user },
|
||||
highestRank: async () => highest,
|
||||
};
|
||||
function db(
|
||||
user: { id: number; username: string; rank: number } | null,
|
||||
highest: number | null,
|
||||
) {
|
||||
return {
|
||||
user: { findUnique: async () => user },
|
||||
highestRank: async () => highest,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveAuthorizationState", () => {
|
||||
it("uses current database rank and highest rank 2000", async () => {
|
||||
await expect(
|
||||
resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 2000 }, 2000)),
|
||||
).resolves.toEqual({ actor: { id: 7, username: "root", rank: 2000 }, highestRank: 2000 });
|
||||
});
|
||||
it("returns null for a deleted user", async () =>
|
||||
expect(resolveAuthorizationState(7, db(null, 2000))).resolves.toBeNull());
|
||||
it("fails closed when there are no ranks", async () =>
|
||||
expect(resolveAuthorizationState(7, db({ id: 7, username: "root", rank: 1 }, null))).resolves.toEqual({
|
||||
actor: { id: 7, username: "root", rank: 1 },
|
||||
highestRank: null,
|
||||
}));
|
||||
it("uses current database rank and highest rank 2000", async () => {
|
||||
await expect(
|
||||
resolveAuthorizationState(
|
||||
7,
|
||||
db({ id: 7, username: "root", rank: 2000 }, 2000),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
actor: { id: 7, username: "root", rank: 2000 },
|
||||
highestRank: 2000,
|
||||
});
|
||||
});
|
||||
it("returns null for a deleted user", async () =>
|
||||
expect(resolveAuthorizationState(7, db(null, 2000))).resolves.toBeNull());
|
||||
it("fails closed when there are no ranks", async () =>
|
||||
expect(
|
||||
resolveAuthorizationState(
|
||||
7,
|
||||
db({ id: 7, username: "root", rank: 1 }, null),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
actor: { id: 7, username: "root", rank: 1 },
|
||||
highestRank: null,
|
||||
}));
|
||||
});
|
||||
@@ -1,23 +1,26 @@
|
||||
import type { AuthorizationActor } from "@/lib/admin/authorization-policy";
|
||||
|
||||
export interface RankAuthorityDb {
|
||||
user: {
|
||||
findUnique(args: {
|
||||
where: { id: number };
|
||||
select: { id: true; username: true; rank: true };
|
||||
}): Promise<{ id: number; username: string; rank: number } | null>;
|
||||
};
|
||||
highestRank(): Promise<number | null>;
|
||||
user: {
|
||||
findUnique(args: {
|
||||
where: { id: number };
|
||||
select: { id: true; username: true; rank: true };
|
||||
}): Promise<{ id: number; username: string; rank: number } | null>;
|
||||
};
|
||||
highestRank(): Promise<number | null>;
|
||||
}
|
||||
|
||||
export async function resolveAuthorizationState(
|
||||
userId: number,
|
||||
db: RankAuthorityDb,
|
||||
userId: number,
|
||||
db: RankAuthorityDb,
|
||||
): Promise<{ actor: AuthorizationActor; highestRank: number | null } | null> {
|
||||
const [user, highestRank] = await Promise.all([
|
||||
db.user.findUnique({ where: { id: userId }, select: { id: true, username: true, rank: true } }),
|
||||
db.highestRank(),
|
||||
]);
|
||||
if (!user) return null;
|
||||
return { actor: user, highestRank };
|
||||
const [user, highestRank] = await Promise.all([
|
||||
db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, username: true, rank: true },
|
||||
}),
|
||||
db.highestRank(),
|
||||
]);
|
||||
if (!user) return null;
|
||||
return { actor: user, highestRank };
|
||||
}
|
||||
@@ -2,19 +2,19 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveStaffUser } from "@/lib/admin/staff-user";
|
||||
|
||||
describe("resolveStaffUser", () => {
|
||||
it("rejects a session whose database rank has been revoked", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 1, username: "Alice" });
|
||||
it("rejects a session whose database rank has been revoked", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 1, username: "Alice" });
|
||||
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toBeNull();
|
||||
});
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns the current database identity instead of stale JWT values", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 8, username: "CurrentName" });
|
||||
it("returns the current database identity instead of stale JWT values", async () => {
|
||||
const findUser = async () => ({ id: 7, rank: 8, username: "CurrentName" });
|
||||
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toEqual({
|
||||
id: 7,
|
||||
rank: 8,
|
||||
username: "CurrentName",
|
||||
});
|
||||
});
|
||||
await expect(resolveStaffUser("7", 7, findUser)).resolves.toEqual({
|
||||
id: 7,
|
||||
rank: 8,
|
||||
username: "CurrentName",
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-10
@@ -1,21 +1,21 @@
|
||||
import { isStaff } from "@/lib/admin/is-staff";
|
||||
|
||||
export interface StaffUserRecord {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export type FindStaffUser = (id: number) => Promise<StaffUserRecord | null>;
|
||||
|
||||
export async function resolveStaffUser(
|
||||
sessionUserId: string,
|
||||
minStaffRank: number,
|
||||
findUser: FindStaffUser,
|
||||
sessionUserId: string,
|
||||
minStaffRank: number,
|
||||
findUser: FindStaffUser,
|
||||
): Promise<StaffUserRecord | null> {
|
||||
const id = Number(sessionUserId);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
const id = Number(sessionUserId);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
|
||||
const user = await findUser(id);
|
||||
return user && isStaff(user.rank, minStaffRank) ? user : null;
|
||||
const user = await findUser(id);
|
||||
return user && isStaff(user.rank, minStaffRank) ? user : null;
|
||||
}
|
||||
Reference in new issue
Block a user