refactor(api): share owned resource read access

This commit is contained in:
2026-04-01 07:35:34 +02:00
parent a0c98cf24d
commit 41916a4e46
7 changed files with 336 additions and 118 deletions
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import {
assertCanReadOwnedResource,
canManageOwnedResourceReads,
findOwnedReadResourceId,
resolveOwnedResourceReadFilter,
} from "../router/resource-owned-read-access.js";
function createContext(options: {
role?: string | null;
userId?: string | null;
resourceFindFirst?: ReturnType<typeof vi.fn>;
} = {}) {
return {
dbUser: options.userId === null
? null
: {
id: options.userId ?? "user_1",
systemRole: options.role ?? "USER",
},
db: {
resource: options.resourceFindFirst
? { findFirst: options.resourceFindFirst }
: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
},
};
}
describe("resource-owned-read-access", () => {
it("treats admins and managers as broad readers", () => {
expect(canManageOwnedResourceReads(createContext({ role: "ADMIN" }))).toBe(true);
expect(canManageOwnedResourceReads(createContext({ role: "MANAGER" }))).toBe(true);
expect(canManageOwnedResourceReads(createContext({ role: "USER" }))).toBe(false);
});
it("finds the signed-in user's owned resource id", async () => {
const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_123" });
await expect(findOwnedReadResourceId(createContext({ resourceFindFirst }))).resolves.toBe("res_123");
expect(resourceFindFirst).toHaveBeenCalledWith({
where: { userId: "user_1" },
select: { id: true },
});
});
it("scopes regular readers to their own resource filter", async () => {
await expect(
resolveOwnedResourceReadFilter(
createContext({ role: "USER" }),
undefined,
"forbidden",
),
).resolves.toBe("res_own");
});
it("preserves the requested filter for managers", async () => {
await expect(
resolveOwnedResourceReadFilter(
createContext({ role: "MANAGER" }),
"res_other",
"forbidden",
),
).resolves.toBe("res_other");
});
it("rejects access to another resource for regular readers", async () => {
await expect(
assertCanReadOwnedResource(
createContext({ role: "USER" }),
"res_other",
"forbidden",
),
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "forbidden",
});
});
});
@@ -1,48 +1,20 @@
import { PreviewResolvedHolidaysSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { protectedProcedure } from "../trpc.js";
import { type HolidayReadContext } from "./holiday-calendar-shared.js";
function canManageHolidayResourceReads(ctx: HolidayReadContext): boolean {
const role = ctx.dbUser?.systemRole;
return role === "ADMIN" || role === "MANAGER";
}
async function findOwnedHolidayResourceId(ctx: HolidayReadContext): Promise<string | null> {
if (!ctx.dbUser?.id) {
return null;
}
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
return null;
}
const resource = await ctx.db.resource.findFirst({
where: { userId: ctx.dbUser.id },
select: { id: true },
});
return resource?.id ?? null;
}
import { assertCanReadOwnedResource } from "./resource-owned-read-access.js";
async function assertCanReadHolidayResource(
ctx: HolidayReadContext,
resourceId: string,
): Promise<void> {
if (canManageHolidayResourceReads(ctx)) {
return;
}
const ownedResourceId = await findOwnedHolidayResourceId(ctx);
if (!ownedResourceId || ownedResourceId !== resourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view holiday data for your own resource",
});
}
await assertCanReadOwnedResource(
ctx,
resourceId,
"You can only view holiday data for your own resource",
);
}
function formatResolvedHolidayDetail(holiday: {
@@ -0,0 +1,66 @@
import { TRPCError } from "@trpc/server";
import type { TRPCContext } from "../trpc.js";
export type OwnedResourceReadContext = Pick<TRPCContext, "db" | "dbUser">;
export function canManageOwnedResourceReads(ctx: { dbUser: { systemRole: string } | null }): boolean {
const role = ctx.dbUser?.systemRole;
return role === "ADMIN" || role === "MANAGER";
}
export async function findOwnedReadResourceId(
ctx: OwnedResourceReadContext,
): Promise<string | null> {
if (!ctx.dbUser?.id) {
return null;
}
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
return null;
}
const resource = await ctx.db.resource.findFirst({
where: { userId: ctx.dbUser.id },
select: { id: true },
});
return resource?.id ?? null;
}
export async function assertCanReadOwnedResource(
ctx: OwnedResourceReadContext,
resourceId: string,
forbiddenMessage: string,
): Promise<void> {
if (canManageOwnedResourceReads(ctx)) {
return;
}
const ownedResourceId = await findOwnedReadResourceId(ctx);
if (!ownedResourceId || ownedResourceId !== resourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: forbiddenMessage,
});
}
}
export async function resolveOwnedResourceReadFilter(
ctx: OwnedResourceReadContext,
requestedResourceId: string | undefined,
forbiddenMessage: string,
): Promise<string | null | undefined> {
if (canManageOwnedResourceReads(ctx)) {
return requestedResourceId;
}
const ownedResourceId = await findOwnedReadResourceId(ctx);
if (requestedResourceId && requestedResourceId !== ownedResourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: forbiddenMessage,
});
}
return ownedResourceId;
}
+18 -45
View File
@@ -11,48 +11,27 @@ import {
findVacationResourceChapter,
listChapterVacationOverlaps,
} from "./vacation-read-support.js";
import {
assertCanReadOwnedResource,
canManageOwnedResourceReads,
resolveOwnedResourceReadFilter,
} from "./resource-owned-read-access.js";
type VacationReadContext = Pick<TRPCContext, "db" | "dbUser">;
export function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean {
const role = ctx.dbUser?.systemRole;
return role === "ADMIN" || role === "MANAGER";
}
export async function findOwnedResourceId(
ctx: VacationReadContext,
): Promise<string | null> {
if (!ctx.dbUser?.id) {
return null;
}
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
return null;
}
const resource = await ctx.db.resource.findFirst({
where: { userId: ctx.dbUser.id },
select: { id: true },
});
return resource?.id ?? null;
return canManageOwnedResourceReads(ctx);
}
export async function assertCanReadVacationResource(
ctx: VacationReadContext,
resourceId: string,
): Promise<void> {
if (canManageVacationReads(ctx)) {
return;
}
const ownedResourceId = await findOwnedResourceId(ctx);
if (!ownedResourceId || ownedResourceId !== resourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view vacation data for your own resource",
});
}
await assertCanReadOwnedResource(
ctx,
resourceId,
"You can only view vacation data for your own resource",
);
}
export function isSameUtcDay(left: Date, right: Date): boolean {
@@ -159,20 +138,14 @@ export const vacationReadProcedures = {
}),
)
.query(async ({ ctx, input }) => {
let resourceIdFilter = input.resourceId;
const resourceIdFilter = await resolveOwnedResourceReadFilter(
ctx,
input.resourceId,
"You can only view vacation data for your own resource",
);
if (!canManageVacationReads(ctx)) {
const ownedResourceId = await findOwnedResourceId(ctx);
if (input.resourceId && input.resourceId !== ownedResourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view vacation data for your own resource",
});
}
if (!ownedResourceId) {
return [];
}
resourceIdFilter = ownedResourceId;
if (!canManageVacationReads(ctx) && !resourceIdFilter) {
return [];
}
const vacations = await ctx.db.vacation.findMany({