feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
+231 -9
View File
@@ -15,14 +15,126 @@ import { createAuditEntry } from "../lib/audit.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { logger } from "../lib/logger.js";
import type { TRPCContext } from "../trpc.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES = new Set<VacationType>([VacationType.ANNUAL, VacationType.OTHER]);
type VacationReadContext = Pick<TRPCContext, "db" | "dbUser">;
function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean {
const role = ctx.dbUser?.systemRole;
return role === "ADMIN" || role === "MANAGER";
}
function runVacationBackgroundEffect(
effectName: string,
execute: () => unknown,
metadata: Record<string, unknown> = {},
): void {
void Promise.resolve()
.then(execute)
.catch((error) => {
logger.error(
{ err: error, effectName, ...metadata },
"Vacation background side effect failed",
);
});
}
function notifyVacationStatusInBackground(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
vacationId: string,
resourceId: string,
newStatus: VacationStatus,
rejectionReason?: string | null,
): void {
runVacationBackgroundEffect(
"notifyVacationStatus",
() => notifyVacationStatus(db, vacationId, resourceId, newStatus, rejectionReason),
{ vacationId, resourceId, newStatus },
);
}
function dispatchVacationWebhookInBackground(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
event: string,
payload: Record<string, unknown>,
): void {
runVacationBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
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;
}
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",
});
}
}
function isSameUtcDay(left: Date, right: Date): boolean {
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
}
function mapTeamOverlapDetail(params: {
resource: { displayName: string; chapter: string | null };
startDate: Date;
endDate: Date;
overlaps: Array<{
type: VacationType;
status: VacationStatus;
startDate: Date;
endDate: Date;
resource: { displayName: string };
}>;
}) {
return {
resource: params.resource.displayName,
chapter: params.resource.chapter,
period: `${params.startDate.toISOString().slice(0, 10)} to ${params.endDate.toISOString().slice(0, 10)}`,
overlappingVacations: params.overlaps.map((vacation) => ({
resource: vacation.resource.displayName,
type: vacation.type,
status: vacation.status,
start: vacation.startDate.toISOString().slice(0, 10),
end: vacation.endDate.toISOString().slice(0, 10),
})),
overlapCount: params.overlaps.length,
};
}
const PreviewVacationRequestSchema = z.object({
resourceId: z.string(),
type: z.nativeEnum(VacationType),
@@ -224,9 +336,25 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
let resourceIdFilter = input.resourceId;
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;
}
const vacations = await ctx.db.vacation.findMany({
where: {
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(resourceIdFilter ? { resourceId: resourceIdFilter } : {}),
...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}),
...(input.type ? { type: input.type } : {}),
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
@@ -254,15 +382,38 @@ export const vacationRouter = createTRPCRouter({
ctx.db.vacation.findUnique({
where: { id: input.id },
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
resource: { select: { ...RESOURCE_BRIEF_SELECT, userId: true } },
requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } },
},
}),
"Vacation",
);
if (!canManageVacationReads(ctx)) {
const isOwnVacation = vacation.resource?.userId === ctx.dbUser?.id || vacation.requestedById === ctx.dbUser?.id;
if (!isOwnVacation) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view your own vacation data",
});
}
}
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeVacationRecord(vacation, directory);
const anonymized = anonymizeVacationRecord(vacation, directory);
return {
...anonymized,
resource: anonymized.resource
? {
id: anonymized.resource.id,
displayName: anonymized.resource.displayName,
eid: anonymized.resource.eid,
lcrCents: anonymized.resource.lcrCents,
chapter: anonymized.resource.chapter,
}
: null,
};
}),
/**
@@ -475,7 +626,7 @@ export const vacationRouter = createTRPCRouter({
summary: `Approved vacation (was ${existing.status})`,
});
void dispatchWebhooks(ctx.db, "vacation.approved", {
dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", {
id: updated.id,
resourceId: updated.resourceId,
startDate: updated.startDate.toISOString(),
@@ -497,7 +648,7 @@ export const vacationRouter = createTRPCRouter({
});
if (existing.status === VacationStatus.PENDING) {
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
}
return { ...updated, warnings: conflictResult.warnings };
@@ -558,7 +709,13 @@ export const vacationRouter = createTRPCRouter({
summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
});
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
notifyVacationStatusInBackground(
ctx.db,
updated.id,
updated.resourceId,
VacationStatus.REJECTED,
input.rejectionReason,
);
return updated;
}),
@@ -599,7 +756,7 @@ export const vacationRouter = createTRPCRouter({
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
notifyVacationStatusInBackground(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
void createAuditEntry({
db: ctx.db,
@@ -668,7 +825,13 @@ export const vacationRouter = createTRPCRouter({
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
notifyVacationStatusInBackground(
ctx.db,
v.id,
v.resourceId,
VacationStatus.REJECTED,
input.rejectionReason,
);
void createAuditEntry({
db: ctx.db,
@@ -773,6 +936,8 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
await assertCanReadVacationResource(ctx, input.resourceId);
return ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
@@ -798,7 +963,7 @@ export const vacationRouter = createTRPCRouter({
return ctx.db.vacation.findMany({
where: { status: VacationStatus.PENDING },
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
resource: { select: { ...RESOURCE_BRIEF_SELECT, chapter: true } },
requestedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { startDate: "asc" },
@@ -818,6 +983,8 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
await assertCanReadVacationResource(ctx, input.resourceId);
// Find the chapter of the requesting resource
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
@@ -842,6 +1009,61 @@ export const vacationRouter = createTRPCRouter({
});
}),
getTeamOverlapDetail: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
await assertCanReadVacationResource(ctx, input.resourceId);
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { displayName: true, chapter: true },
});
if (!resource) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
if (!resource.chapter) {
return mapTeamOverlapDetail({
resource,
startDate: input.startDate,
endDate: input.endDate,
overlaps: [],
});
}
const overlaps = await ctx.db.vacation.findMany({
where: {
resource: { chapter: resource.chapter },
resourceId: { not: input.resourceId },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
},
orderBy: { startDate: "asc" },
take: 20,
});
return mapTeamOverlapDetail({
resource,
startDate: input.startDate,
endDate: input.endDate,
overlaps,
});
}),
/**
* Batch-create public holidays for all resources (or a chapter) for a given year+state.
* Admin-only. Creates as APPROVED automatically.