diff --git a/src/comments/mutations/addComment.ts b/src/comments/mutations/addComment.ts index 4b205314..390677d8 100644 --- a/src/comments/mutations/addComment.ts +++ b/src/comments/mutations/addComment.ts @@ -4,6 +4,10 @@ import db from "db" import sendNotification from "src/notifications/mutations/sendNotification" import { z } from "zod" +import { + requireTaskLogParticipant, + requireOwnMember, +} from "src/projectprivileges/utils/requireAccess" const AddCommentSchema = z.object({ taskLogId: z.number(), projectMemberId: z.number(), @@ -14,6 +18,8 @@ export default resolver.pipe( resolver.zod(AddCommentSchema), resolver.authorize(), async ({ taskLogId, projectMemberId, content }, ctx) => { + await requireTaskLogParticipant(ctx, taskLogId) + await requireOwnMember(ctx, projectMemberId) const userId = ctx.session.userId if (!userId) throw new Error("User not authenticated") diff --git a/src/comments/mutations/markAsRead.ts b/src/comments/mutations/markAsRead.ts index 539282eb..f0fbafcd 100644 --- a/src/comments/mutations/markAsRead.ts +++ b/src/comments/mutations/markAsRead.ts @@ -1,6 +1,7 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { z } from "zod" +import { requireOwnMember } from "src/projectprivileges/utils/requireAccess" const markAsRead = z.object({ commentIds: z.array(z.number()), @@ -9,7 +10,9 @@ const markAsRead = z.object({ export default resolver.pipe( resolver.zod(markAsRead), + resolver.authorize(), async ({ commentIds, projectMemberId }, ctx) => { + await requireOwnMember(ctx, projectMemberId) await Promise.all( commentIds.map((commentId) => db.commentReadStatus.upsert({ diff --git a/src/contributors/mutations/deleteContributor.ts b/src/contributors/mutations/deleteContributor.ts index 8481fbc2..e75c086f 100644 --- a/src/contributors/mutations/deleteContributor.ts +++ b/src/contributors/mutations/deleteContributor.ts @@ -3,10 +3,12 @@ import db from "db" import { DeleteContributorSchema } from "../schemas" import countProjectManagers from "src/projectmembers/queries/countProjectManagers" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(DeleteContributorSchema), resolver.authorize(), async ({ id }, ctx) => { + await requireManagerOf(ctx, "projectMember", [id]) // Find the contributor to be deleted const contributorToDelete = await db.projectMember.findUnique({ where: { id }, diff --git a/src/core/utils/mutationAccess.test.ts b/src/core/utils/mutationAccess.test.ts new file mode 100644 index 00000000..1cce07f8 --- /dev/null +++ b/src/core/utils/mutationAccess.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// A stand-in database that records every write. Reads answer as if every row belongs to project 99 +// and to someone else (user 500), so anything the checks let through would show up as a write. +const writes: string[] = [] +let privileges: Array<{ projectId: number; privilege: string }> = [] + +const WRITE_METHODS = [ + "create", + "createMany", + "update", + "updateMany", + "delete", + "deleteMany", + "upsert", +] + +const someoneElsesRow = (id = 1) => ({ + id, + projectId: 99, + userId: 500, + email: "someone.else@example.com", + task: { projectId: 99 }, + form: { userId: 500 }, + assignedTo: { users: [{ id: 500 }] }, + users: [{ id: 500 }], + versions: [], + tasks: [], + projects: [], +}) + +vi.mock("db", () => { + const model = (name: string) => + new Proxy( + {}, + { + get: (_target, method: string) => async (args: any) => { + if (WRITE_METHODS.includes(method)) { + writes.push(`${name}.${method}`) + return { count: 0, ...someoneElsesRow() } + } + if (method === "findMany") { + if (name === "projectPrivilege") return privileges + const ids: number[] = args?.where?.id?.in ?? [] + return ids.map((id) => someoneElsesRow(id)) + } + if (method === "findFirst" || method === "findUnique") { + // "is this row mine?" lookups (filtered by the caller's own id) find nothing + const where = JSON.stringify(args?.where ?? {}) + if (where.includes('"some":{"id":7}') || where.includes('"userId":7')) return null + return someoneElsesRow(args?.where?.id) + } + return null + }, + } + ) + const db: any = new Proxy( + {}, + { + get: (_t, prop: string) => { + if (prop === "$transaction") { + return async (arg: any) => { + writes.push("$transaction") + return typeof arg === "function" ? arg(db) : arg + } + } + return model(prop) + }, + } + ) + return { + default: db, + Prisma: { JsonNull: "JsonNull" }, + MemberPrivileges: { PROJECT_MANAGER: "PROJECT_MANAGER", CONTRIBUTOR: "CONTRIBUTOR" }, + Status: { COMPLETED: "COMPLETED", NOT_COMPLETED: "NOT_COMPLETED" }, + CompletedAs: { TEAM: "TEAM", INDIVIDUAL: "INDIVIDUAL" }, + AutoAssignNew: { NONE: "NONE", ALL: "ALL" }, + NoteVisibility: { PRIVATE: "PRIVATE", CONTRIBUTORS: "CONTRIBUTORS" }, + } +}) + +const ctx = (): any => ({ + session: { + userId: 7, + $isAuthorized: () => true, + $authorize: () => undefined, + $setPublicData: vi.fn(), + }, +}) + +type Case = { name: string; load: () => Promise; input: any; managerInput?: any } + +// Everything here changes data in a project (or a person's own forms, folders and roles). Each +// entry is called with the smallest valid input, aimed at project 99, user 7 being no part of it. +const ids = (...n: number[]) => n +const CASES: Case[] = [ + { + name: "updateProject", + load: () => import("src/projects/mutations/updateProject"), + input: { id: 99, name: "x" }, + }, + { + name: "deleteProject", + load: () => import("src/projects/mutations/deleteProject"), + input: { id: 99 }, + }, + { + name: "copyProject", + load: () => import("src/projects/mutations/copyProject"), + input: { id: 99 }, + }, + { + name: "createMilestone", + load: () => import("src/milestones/mutations/createMilestone"), + input: { name: "x", projectId: 99 }, + }, + { + name: "deleteMilestone", + load: () => import("src/milestones/mutations/deleteMilestone"), + input: { id: 1 }, + }, + { + name: "updateMilestone", + load: () => import("src/milestones/mutations/updateMilestone"), + input: { id: 1, name: "x" }, + }, + { + name: "updateMilestoneDates", + load: () => import("src/milestones/mutations/updateMilestoneDates"), + input: { id: 1 }, + }, + { + name: "createColumn", + load: () => import("src/tasks/mutations/createColumn"), + input: { projectId: 99, name: "x" }, + }, + { + name: "deleteColumn", + load: () => import("src/tasks/mutations/deleteColumn"), + input: { id: 1 }, + }, + { + name: "updateColumn", + load: () => import("src/tasks/mutations/updateColumn"), + input: { id: 1, name: "x" }, + }, + { + name: "updateColumnOrder", + load: () => import("src/tasks/mutations/updateColumnOrder"), + input: { containerIds: ids(1, 2) }, + }, + { name: "deleteTask", load: () => import("src/tasks/mutations/deleteTask"), input: { id: 1 } }, + { + name: "updateTaskDates", + load: () => import("src/tasks/mutations/updateTaskDates"), + input: { id: 1 }, + }, + { + name: "updateTaskStatus", + load: () => import("src/tasks/mutations/updateTaskStatus"), + input: { id: 1, status: "COMPLETED" }, + }, + { + name: "updateTaskOrder", + load: () => import("src/tasks/mutations/updateTaskOrder"), + input: { tasks: [{ taskId: 1, containerId: 1, containerTaskOrder: 0 }] }, + }, + { + name: "updateTaskRole", + load: () => import("src/tasks/mutations/updateTaskRole"), + input: { tasksId: ids(1), rolesId: ids(1), disconnect: false }, + }, + { + name: "updateTasksForMilestone", + load: () => import("src/tasks/mutations/updateTasksForMilestone"), + input: { milestoneId: 1, taskIds: ids(1) }, + }, + { + name: "createTeam", + load: () => import("src/teams/mutations/createTeam"), + input: { projectId: 99, name: "x", userIds: [] }, + }, + { name: "deleteTeam", load: () => import("src/teams/mutations/deleteTeam"), input: { id: 1 } }, + { + name: "updateTeam", + load: () => import("src/teams/mutations/updateTeam"), + input: { id: 1, name: "x", userIds: [] }, + }, + { + name: "updateProjectMember (self-promotion)", + load: () => import("src/projectmembers/mutations/updateProjectMember"), + input: { id: 1, projectId: 99, privilege: "PROJECT_MANAGER", userId: 7 }, + // a manager changing someone else's role in their own project + managerInput: { id: 1, projectId: 99, privilege: "CONTRIBUTOR", userId: 20 }, + }, + { + name: "updateProjectMemberRole", + load: () => import("src/projectmembers/mutations/updateProjectMemberRole"), + input: { projectMembersId: ids(1), rolesId: ids(1), disconnect: false }, + }, + { + name: "deleteContributor", + load: () => import("src/contributors/mutations/deleteContributor"), + input: { id: 1 }, + }, + { + name: "createInvite", + load: () => import("src/invites/mutations/createInvite"), + input: { projectId: 99, email: "a@b.co", privilege: "CONTRIBUTOR", addedBy: "x" }, + }, + { + name: "createAnnouncement", + load: () => import("src/notifications/mutations/createAnnouncement"), + input: { announcementText: "x", projectId: 99 }, + }, + { + name: "addProjectManagerWidgets", + load: () => import("src/widgets/mutations/addProjectManagerWidgets"), + input: { userId: 7, projectId: 99 }, + }, + { + name: "removeProjectManagerWidgets", + load: () => import("src/widgets/mutations/removeProjectManagerWidgets"), + input: { userId: 7, projectId: 99 }, + }, + { + name: "updateApproval", + load: () => import("src/tasklogs/mutations/updateApproval"), + input: { id: 1, approved: true, completedById: 1 }, + }, +] + +describe("write endpoints refuse people who have no business changing the data", () => { + beforeEach(() => { + writes.length = 0 + }) + + for (const role of ["outsider", "contributor"] as const) { + describe(`as a ${ + role === "outsider" + ? "signed-in user with no part in the project" + : "plain contributor in the project" + }`, () => { + beforeEach(() => { + privileges = role === "contributor" ? [{ projectId: 99, privilege: "CONTRIBUTOR" }] : [] + }) + + for (const { name, load, input } of CASES) { + test(`${name}`, async () => { + const handler = (await load()).default + const error: any = await handler(input, ctx()).then( + () => null, + (e: unknown) => e + ) + expect(error?.name, `${name} should refuse (got: ${error?.message ?? "no error"})`).toBe( + "NotFoundError" + ) + expect(writes).toEqual([]) + }) + } + }) + } + + describe("and the same calls from a project manager of that project are not refused", () => { + beforeEach(() => { + privileges = [{ projectId: 99, privilege: "PROJECT_MANAGER" }] + }) + + for (const { name, load, input, managerInput } of CASES) { + test(`${name}`, async () => { + const handler = (await load()).default + const error: any = await handler(managerInput ?? input, ctx()).then( + () => null, + (e: unknown) => e + ) + // it may still stop on something else (this database is only a stand-in), but not on access + expect(error?.name, `${name}: ${error?.message}`).not.toBe("NotFoundError") + }) + } + }) +}) diff --git a/src/folders/mutations/deleteFolder.ts b/src/folders/mutations/deleteFolder.ts index d557c17d..89b3670b 100644 --- a/src/folders/mutations/deleteFolder.ts +++ b/src/folders/mutations/deleteFolder.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import { DeleteFolderSchema } from "src/forms/schemas" import db from "db" +import { requireFolderOwner } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(DeleteFolderSchema), resolver.authorize(), - async ({ id }) => { + async ({ id }, ctx) => { + await requireFolderOwner(ctx, id) // Unassign all forms in this folder before deleting await db.form.updateMany({ where: { folderId: id }, diff --git a/src/folders/mutations/renameFolder.ts b/src/folders/mutations/renameFolder.ts index a6429c41..2ea6f5b0 100644 --- a/src/folders/mutations/renameFolder.ts +++ b/src/folders/mutations/renameFolder.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import { RenameFolderSchema } from "src/forms/schemas" import db from "db" +import { requireFolderOwner } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(RenameFolderSchema), resolver.authorize(), - async ({ id, name }) => { + async ({ id, name }, ctx) => { + await requireFolderOwner(ctx, id) return db.folder.update({ where: { id }, data: { name } }) } ) diff --git a/src/forms/mutations/archiveForm.ts b/src/forms/mutations/archiveForm.ts index 7bac4c94..4333ec02 100644 --- a/src/forms/mutations/archiveForm.ts +++ b/src/forms/mutations/archiveForm.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import { ArchiveFormSchema } from "../schemas" import db from "db" +import { requireFormOwner } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(ArchiveFormSchema), resolver.authorize(), - async ({ formId, archived }) => { + async ({ formId, archived }, ctx) => { + await requireFormOwner(ctx, [formId]) await db.formVersion.updateMany({ where: { formId }, data: { archived }, diff --git a/src/forms/mutations/createForm.ts b/src/forms/mutations/createForm.ts index 3deac07b..8aa55cc9 100644 --- a/src/forms/mutations/createForm.ts +++ b/src/forms/mutations/createForm.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import { CreateFormSchema } from "../schemas" import db, { Prisma } from "db" +import { requireSelf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(CreateFormSchema), resolver.authorize(), - async ({ userId, schema, uiSchema, semantics }) => { + async ({ userId, schema, uiSchema, semantics }, ctx) => { + requireSelf(ctx, userId) const newSchema = schema != null ? schema : Prisma.JsonNull // Safely extracting the title/name of the schema from the schema const schemaName = diff --git a/src/forms/mutations/deleteForm.ts b/src/forms/mutations/deleteForm.ts index 3fb7782b..55a8123b 100644 --- a/src/forms/mutations/deleteForm.ts +++ b/src/forms/mutations/deleteForm.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { z } from "zod" +import { requireFormOwner } from "src/projectprivileges/utils/requireAccess" const DeleteFormSchema = z.object({ formId: z.coerce.number(), }) @@ -9,7 +10,8 @@ const DeleteFormSchema = z.object({ export default resolver.pipe( resolver.zod(DeleteFormSchema), resolver.authorize(), - async ({ formId }) => { + async ({ formId }, ctx) => { + await requireFormOwner(ctx, [formId]) const versions = await db.formVersion.findMany({ where: { formId }, select: { diff --git a/src/forms/mutations/deleteFormVersion.ts b/src/forms/mutations/deleteFormVersion.ts index 315c1b21..ed9c0574 100644 --- a/src/forms/mutations/deleteFormVersion.ts +++ b/src/forms/mutations/deleteFormVersion.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { z } from "zod" +import { requireFormVersionOwner } from "src/projectprivileges/utils/requireAccess" type DeleteFormVersionResult = | { action: "deleted" } | { @@ -16,7 +17,8 @@ const DeleteFormVersionSchema = z.object({ export default resolver.pipe( resolver.zod(DeleteFormVersionSchema), resolver.authorize(), - async ({ id }) => { + async ({ id }, ctx) => { + await requireFormVersionOwner(ctx, [id]) const version = await db.formVersion.findUnique({ where: { id }, select: { diff --git a/src/forms/mutations/updateForm.ts b/src/forms/mutations/updateForm.ts index 2b44a7f4..19604356 100644 --- a/src/forms/mutations/updateForm.ts +++ b/src/forms/mutations/updateForm.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db, { Prisma } from "db" import { EditFormSchema } from "../schemas" +import { requireFormOwner } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(EditFormSchema), resolver.authorize(), - async ({ id, schema, uiSchema, semantics }) => { + async ({ id, schema, uiSchema, semantics }, ctx) => { + await requireFormOwner(ctx, [id]) const newSchema = schema != null ? schema : Prisma.JsonNull const newUi = uiSchema != null ? uiSchema : Prisma.JsonNull // `undefined` (field omitted) means "caller doesn't know about semantics, diff --git a/src/forms/mutations/updateFormMeta.ts b/src/forms/mutations/updateFormMeta.ts index 2ed23fa7..25526bd2 100644 --- a/src/forms/mutations/updateFormMeta.ts +++ b/src/forms/mutations/updateFormMeta.ts @@ -2,10 +2,13 @@ import { resolver } from "@blitzjs/rpc" import { UpdateFormMetaSchema } from "../schemas" import db from "db" +import { requireFormOwner, requireFolderOwner } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateFormMetaSchema), resolver.authorize(), - async ({ id, tags, folderId }) => { + async ({ id, tags, folderId }, ctx) => { + await requireFormOwner(ctx, [id]) + if (folderId) await requireFolderOwner(ctx, folderId) const data: Record = {} if (tags !== undefined) data.tags = tags if (folderId !== undefined) data.folderId = folderId diff --git a/src/forms/mutations/updateFormVersionArchive.ts b/src/forms/mutations/updateFormVersionArchive.ts index 11dcdaed..9de72c23 100644 --- a/src/forms/mutations/updateFormVersionArchive.ts +++ b/src/forms/mutations/updateFormVersionArchive.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { z } from "zod" +import { requireFormVersionOwner } from "src/projectprivileges/utils/requireAccess" const UpdateFormVersionArchiveSchema = z.object({ id: z.number(), archived: z.boolean(), @@ -10,7 +11,8 @@ const UpdateFormVersionArchiveSchema = z.object({ export default resolver.pipe( resolver.zod(UpdateFormVersionArchiveSchema), resolver.authorize(), - async ({ id, archived }) => { + async ({ id, archived }, ctx) => { + await requireFormVersionOwner(ctx, [id]) const version = await db.formVersion.update({ where: { id }, data: { archived }, diff --git a/src/invites/mutations/acceptInvite.ts b/src/invites/mutations/acceptInvite.ts index 6d6964f5..a6202265 100644 --- a/src/invites/mutations/acceptInvite.ts +++ b/src/invites/mutations/acceptInvite.ts @@ -5,6 +5,7 @@ import sendNotification from "src/notifications/mutations/sendNotification" import { getPrivilegeText } from "src/core/utils/getPrivilegeText" import { Routes } from "@blitzjs/next" +import { requireSelf, requireInviteAccess } from "src/projectprivileges/utils/requireAccess" const parseFormerTeamIds = (value: unknown): number[] => { if (!Array.isArray(value)) return [] return value @@ -20,6 +21,8 @@ export default resolver.pipe( resolver.zod(AcceptInviteSchema), resolver.authorize(), async ({ id, userId }, ctx) => { + requireSelf(ctx, userId) + await requireInviteAccess(ctx, id, { allowInvitee: true, allowManager: false }) // Find the invitation and related roles const invite = await db.invitation.findUnique({ where: { id }, diff --git a/src/invites/mutations/createInvite.ts b/src/invites/mutations/createInvite.ts index 74fc33e8..c784abb5 100644 --- a/src/invites/mutations/createInvite.ts +++ b/src/invites/mutations/createInvite.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { CreateInviteSchema } from "../schemas" +import { requireProjectManager } from "src/projectprivileges/utils/requireAccess" function generateToken(n) { var chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" var token = "" @@ -14,7 +15,8 @@ function generateToken(n) { export default resolver.pipe( resolver.zod(CreateInviteSchema), resolver.authorize(), - async (input) => { + async (input, ctx) => { + await requireProjectManager(ctx, input.projectId) input.email = input.email.toLowerCase() let textResult diff --git a/src/invites/mutations/declineInvite.ts b/src/invites/mutations/declineInvite.ts index 27acd40a..f10cdf52 100644 --- a/src/invites/mutations/declineInvite.ts +++ b/src/invites/mutations/declineInvite.ts @@ -2,11 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { DeleteInviteSchema } from "../schemas" +import { requireInviteAccess } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(DeleteInviteSchema), resolver.authorize(), - async ({ id }) => { - // TODO: in multi-tenant app, you must add validation to ensure correct tenant + async ({ id }, ctx) => { + await requireInviteAccess(ctx, id, { allowInvitee: true, allowManager: true }) const invite = await db.invitation.deleteMany({ where: { id } }) return invite diff --git a/src/milestones/mutations/createMilestone.ts b/src/milestones/mutations/createMilestone.ts index f6f3f329..d289c6ca 100644 --- a/src/milestones/mutations/createMilestone.ts +++ b/src/milestones/mutations/createMilestone.ts @@ -2,11 +2,13 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { CreateMilestoneSchema } from "../schemas" +import { requireProjectManager, requireInProject } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(CreateMilestoneSchema), resolver.authorize(), - async ({ projectId, name, description, taskIds, startDate, endDate, tags }) => { - // TODO: in multi-tenant app, you must add validation to ensure correct tenant + async ({ projectId, name, description, taskIds, startDate, endDate, tags }, ctx) => { + await requireProjectManager(ctx, projectId) + if (taskIds?.length) await requireInProject("task", taskIds, projectId) const milestone = await db.milestone.create({ data: { name, diff --git a/src/milestones/mutations/deleteMilestone.ts b/src/milestones/mutations/deleteMilestone.ts index 743c168c..54dffa58 100644 --- a/src/milestones/mutations/deleteMilestone.ts +++ b/src/milestones/mutations/deleteMilestone.ts @@ -2,11 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { DeleteMilestoneSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(DeleteMilestoneSchema), resolver.authorize(), - async ({ id }) => { - // TODO: in multi-tenant app, you must add validation to ensure correct tenant + async ({ id }, ctx) => { + await requireManagerOf(ctx, "milestone", [id]) const milestone = await db.milestone.deleteMany({ where: { id } }) return milestone diff --git a/src/milestones/mutations/updateMilestone.ts b/src/milestones/mutations/updateMilestone.ts index 4bd1a6df..39557fca 100644 --- a/src/milestones/mutations/updateMilestone.ts +++ b/src/milestones/mutations/updateMilestone.ts @@ -2,10 +2,13 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateMilestoneSchema } from "../schemas" +import { requireManagerOf, requireInProject } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateMilestoneSchema), resolver.authorize(), - async ({ id, taskIds, tags, ...rest }) => { + async ({ id, taskIds, tags, ...rest }, ctx) => { + const [projectId] = await requireManagerOf(ctx, "milestone", [id]) + if (taskIds?.length) await requireInProject("task", taskIds, projectId!) const milestone = await db.milestone.update({ where: { id }, data: { diff --git a/src/milestones/mutations/updateMilestoneDates.ts b/src/milestones/mutations/updateMilestoneDates.ts index b1c9ff8c..4eaa8973 100644 --- a/src/milestones/mutations/updateMilestoneDates.ts +++ b/src/milestones/mutations/updateMilestoneDates.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateMilestoneDatesSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateMilestoneDatesSchema), resolver.authorize(), - async ({ id, ...data }) => { + async ({ id, ...data }, ctx) => { + await requireManagerOf(ctx, "milestone", [id]) const milestone = await db.milestone.update({ where: { id }, data, diff --git a/src/notifications/mutations/createAnnouncement.ts b/src/notifications/mutations/createAnnouncement.ts index e2802314..2275e08e 100644 --- a/src/notifications/mutations/createAnnouncement.ts +++ b/src/notifications/mutations/createAnnouncement.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import { z } from "zod" import db from "db" +import { requireProjectManager, requireInProject } from "src/projectprivileges/utils/requireAccess" const createAnnouncementSchema = z.object({ announcementText: z.string(), projectId: z.number(), @@ -12,7 +13,11 @@ const createAnnouncementSchema = z.object({ export default resolver.pipe( resolver.zod(createAnnouncementSchema), resolver.authorize(), - async ({ announcementText, projectId, projectMembersId = [], teamsId = [] }) => { + async ({ announcementText, projectId, projectMembersId = [], teamsId = [] }, ctx) => { + await requireProjectManager(ctx, projectId) + if (projectMembersId.length + teamsId.length > 0) { + await requireInProject("projectMember", [...projectMembersId, ...teamsId], projectId) + } const selectedIds = [...projectMembersId, ...teamsId] let finalRecipients: number[] = [] diff --git a/src/projectmembers/mutations/updateProjectMember.ts b/src/projectmembers/mutations/updateProjectMember.ts index 3f9e69b0..89fd33b7 100644 --- a/src/projectmembers/mutations/updateProjectMember.ts +++ b/src/projectmembers/mutations/updateProjectMember.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateProjectMemberSchema } from "../schemas" +import { requireManagerOfMemberUser } from "src/projectprivileges/utils/requireAccess" async function connectRoles(projectMemberId, rolesId) { await db.$transaction(async (prisma) => { await db.projectMember.update({ @@ -27,8 +28,8 @@ async function connectRoles(projectMemberId, rolesId) { export default resolver.pipe( resolver.zod(UpdateProjectMemberSchema), resolver.authorize(), - async ({ id, rolesId = [], privilege, projectId, userId, ...data }) => { - // TODO: in multi-tenant app, you must add validation to ensure correct tenant + async ({ id, rolesId = [], privilege, projectId, userId, ...data }, ctx) => { + await requireManagerOfMemberUser(ctx, id, projectId, userId) const projectMember = await db.projectMember.update({ where: { id }, data: { diff --git a/src/projectmembers/mutations/updateProjectMemberRole.ts b/src/projectmembers/mutations/updateProjectMemberRole.ts index b0b1e630..03d83b29 100644 --- a/src/projectmembers/mutations/updateProjectMemberRole.ts +++ b/src/projectmembers/mutations/updateProjectMemberRole.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateProjectMemberRoleSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" async function updateProjectMember(id, rolesId, disconnect) { let r await db.$transaction(async (prisma) => { @@ -31,7 +32,8 @@ async function updateProjectMember(id, rolesId, disconnect) { export default resolver.pipe( resolver.zod(UpdateProjectMemberRoleSchema), resolver.authorize(), - async ({ projectMembersId, rolesId = [], disconnect, ...data }) => { + async ({ projectMembersId, rolesId = [], disconnect, ...data }, ctx) => { + await requireManagerOf(ctx, "projectMember", projectMembersId) let c = null projectMembersId.forEach(async (id) => { c = await updateProjectMember(id, rolesId, disconnect) diff --git a/src/projectprivileges/utils/requireAccess.test.ts b/src/projectprivileges/utils/requireAccess.test.ts new file mode 100644 index 00000000..023947b8 --- /dev/null +++ b/src/projectprivileges/utils/requireAccess.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +// vi.mock is hoisted above these, so the mock reaches them lazily at call time +const privileges = vi.fn() +const db = { + task: { findMany: vi.fn() }, + milestone: { findMany: vi.fn() }, + projectMember: { findMany: vi.fn(), findFirst: vi.fn() }, + taskLog: { findMany: vi.fn(), findUnique: vi.fn() }, + invitation: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() }, + form: { findMany: vi.fn() }, + formVersion: { findMany: vi.fn() }, + folder: { findFirst: vi.fn() }, + role: { findMany: vi.fn() }, +} +vi.mock("db", () => ({ + default: new Proxy( + {}, + { + get: (_t, model: string) => + model === "projectPrivilege" + ? { findMany: (...a: unknown[]) => privileges(...a) } + : new Proxy( + {}, + { + get: + (_m, method: string) => + (...a: unknown[]) => + (db as any)[model][method](...a), + } + ), + } + ), + MemberPrivileges: { PROJECT_MANAGER: "PROJECT_MANAGER", CONTRIBUTOR: "CONTRIBUTOR" }, +})) + +import { + requireFolderOwner, + requireFormOwner, + requireFormVersionOwner, + requireInProject, + requireInviteAccess, + requireManagerOf, + requireManagerOfMemberUser, + requireManagerOrSelf, + requireOwnMember, + requireProjectManager, + requireProjectMember, + requireRoleAccess, + requireSelf, + requireTaskLogParticipant, +} from "./requireAccess" + +const ME = 7 +const ctx: any = { session: { userId: ME } } +const signedOut: any = { session: { userId: null } } +// I manage project 1 and am a contributor in project 2; project 3 is not mine +const MINE = [ + { projectId: 1, privilege: "PROJECT_MANAGER" }, + { projectId: 2, privilege: "CONTRIBUTOR" }, +] + +describe("requireAccess", () => { + beforeEach(() => { + vi.clearAllMocks() + privileges.mockResolvedValue(MINE) + }) + + test("everything refuses when nobody is signed in", async () => { + await expect(requireProjectManager(signedOut, 1)).rejects.toThrow() + expect(() => requireSelf(signedOut, ME)).toThrow() + }) + + test("project membership and management", async () => { + await expect(requireProjectManager(ctx, 1)).resolves.toBeUndefined() + await expect(requireProjectManager(ctx, 2)).rejects.toThrow() // contributor only + await expect(requireProjectManager(ctx, 3)).rejects.toThrow() + await expect(requireProjectMember(ctx, 2)).resolves.toBeUndefined() + await expect(requireProjectMember(ctx, 3)).rejects.toThrow() + }) + + test("requireSelf only accepts the signed-in user's own id", () => { + expect(() => requireSelf(ctx, ME)).not.toThrow() + expect(() => requireSelf(ctx, 8)).toThrow() + }) + + test("a project manager, or the person themselves once they're in the project", async () => { + await expect(requireManagerOrSelf(ctx, 1, 99)).resolves.toBeUndefined() // I manage it + await expect(requireManagerOrSelf(ctx, 2, ME)).resolves.toBeUndefined() // my own, I'm a member + await expect(requireManagerOrSelf(ctx, 2, 99)).rejects.toThrow() // someone else's, not a manager + await expect(requireManagerOrSelf(ctx, 3, ME)).rejects.toThrow() // not in the project + }) + + test("requireManagerOf needs every id to exist and sit in a project I manage", async () => { + db.task.findMany.mockResolvedValue([{ projectId: 1 }, { projectId: 1 }]) + await expect(requireManagerOf(ctx, "task", [10, 11])).resolves.toEqual([1]) + + db.task.findMany.mockResolvedValue([{ projectId: 1 }, { projectId: 3 }]) + await expect(requireManagerOf(ctx, "task", [10, 11])).rejects.toThrow() // one is elsewhere + + db.task.findMany.mockResolvedValue([{ projectId: 1 }]) + await expect(requireManagerOf(ctx, "task", [10, 11])).rejects.toThrow() // one doesn't exist + + db.task.findMany.mockResolvedValue([{ projectId: 2 }]) + await expect(requireManagerOf(ctx, "task", [10])).rejects.toThrow() // I'm only a contributor there + }) + + test("requireInProject stops attaching another project's things", async () => { + db.projectMember.findMany.mockResolvedValue([{ projectId: 1 }]) + await expect(requireInProject("projectMember", [5], 1)).resolves.toBeUndefined() + await expect(requireInProject("projectMember", [5], 2)).rejects.toThrow() + }) + + test("a member row must really be mine", async () => { + db.projectMember.findFirst.mockResolvedValue({ projectId: 2 }) + await expect(requireOwnMember(ctx, 40)).resolves.toBe(2) + expect(db.projectMember.findFirst.mock.calls[0]![0].where).toEqual({ + id: 40, + users: { some: { id: ME } }, + }) + db.projectMember.findFirst.mockResolvedValue(null) + await expect(requireOwnMember(ctx, 41)).rejects.toThrow() + }) + + test("a task log is for the project's managers and the people it is assigned to", async () => { + const log = (projectId: number, userIds: number[]) => ({ + task: { projectId }, + assignedTo: { users: userIds.map((id) => ({ id })) }, + }) + db.taskLog.findUnique.mockResolvedValue(log(1, [99])) // I manage it + await expect(requireTaskLogParticipant(ctx, 1)).resolves.toEqual({ + projectId: 1, + isManager: true, + }) + db.taskLog.findUnique.mockResolvedValue(log(2, [ME])) // assigned to me + await expect(requireTaskLogParticipant(ctx, 2)).resolves.toEqual({ + projectId: 2, + isManager: false, + }) + db.taskLog.findUnique.mockResolvedValue(log(2, [99])) // someone else's, I'm only a contributor + await expect(requireTaskLogParticipant(ctx, 3)).rejects.toThrow() + db.taskLog.findUnique.mockResolvedValue(null) + await expect(requireTaskLogParticipant(ctx, 4)).rejects.toThrow() + }) + + describe("invitations", () => { + beforeEach(() => { + db.user.findUnique.mockResolvedValue({ email: "Me@Example.com" }) + }) + const invite = (projectId: number, email: string) => + db.invitation.findUnique.mockResolvedValue({ projectId, email }) + + test("the person it is for can act on it, whatever the email's capitalisation", async () => { + invite(3, "me@example.com") + await expect( + requireInviteAccess(ctx, 1, { allowInvitee: true, allowManager: true }) + ).resolves.toBeUndefined() + }) + + test("someone else's invitation is refused, even though ids are guessable", async () => { + invite(3, "somebody.else@example.com") + await expect( + requireInviteAccess(ctx, 1, { allowInvitee: true, allowManager: true }) + ).rejects.toThrow() + }) + + test("a manager can manage their project's invitations but can't accept them for someone", async () => { + invite(1, "somebody.else@example.com") + await expect( + requireInviteAccess(ctx, 1, { allowInvitee: true, allowManager: true }) + ).resolves.toBeUndefined() + await expect( + requireInviteAccess(ctx, 1, { allowInvitee: true, allowManager: false }) + ).rejects.toThrow() + }) + }) + + test("changing a member's privilege needs a manager, and the user must be that member", async () => { + db.projectMember.findFirst.mockResolvedValue({ id: 5 }) + await expect(requireManagerOfMemberUser(ctx, 5, 1, 20)).resolves.toBeUndefined() + await expect(requireManagerOfMemberUser(ctx, 5, 2, 20)).rejects.toThrow() // contributor, not manager + db.projectMember.findFirst.mockResolvedValue(null) + await expect(requireManagerOfMemberUser(ctx, 5, 1, 21)).rejects.toThrow() // user isn't that member + }) + + test("forms and folders belong to their owner", async () => { + db.form.findMany.mockResolvedValue([{ userId: ME }]) + await expect(requireFormOwner(ctx, [1])).resolves.toBeUndefined() + db.form.findMany.mockResolvedValue([{ userId: 99 }]) + await expect(requireFormOwner(ctx, [1])).rejects.toThrow() + db.formVersion.findMany.mockResolvedValue([{ form: { userId: 99 } }]) + await expect(requireFormVersionOwner(ctx, [1])).rejects.toThrow() + db.folder.findFirst.mockResolvedValue(null) + await expect(requireFolderOwner(ctx, 1)).rejects.toThrow() + expect(db.folder.findFirst.mock.calls[0]![0].where).toEqual({ id: 1, userId: ME }) + }) + + test("a role is its maker's, or its project's managers'", async () => { + db.role.findMany.mockResolvedValue([{ userId: ME, projectId: null }]) + await expect(requireRoleAccess(ctx, [1])).resolves.toBeUndefined() + db.role.findMany.mockResolvedValue([{ userId: 99, projectId: 1 }]) // someone else's, my project + await expect(requireRoleAccess(ctx, [1])).resolves.toBeUndefined() + db.role.findMany.mockResolvedValue([{ userId: 99, projectId: 2 }]) // contributor project + await expect(requireRoleAccess(ctx, [1])).rejects.toThrow() + db.role.findMany.mockResolvedValue([{ userId: 99, projectId: null }]) + await expect(requireRoleAccess(ctx, [1])).rejects.toThrow() + }) +}) diff --git a/src/projectprivileges/utils/requireAccess.ts b/src/projectprivileges/utils/requireAccess.ts new file mode 100644 index 00000000..89a4e71b --- /dev/null +++ b/src/projectprivileges/utils/requireAccess.ts @@ -0,0 +1,239 @@ +import { NotFoundError } from "blitz" +import db from "db" +import { getProjectAccess } from "./getProjectAccess" + +// Server-side access checks for queries and mutations. The browser can't be trusted to ask +// only for what it may touch (hiding a button is not protection), so every endpoint that acts +// on a project's data calls one of these first. Anything the user may not reach is reported as +// "not found", so ids can't be probed for existence. + +type SessionCtx = { session: { userId?: number | null } } + +const deny = () => new NotFoundError() + +const currentUserId = (ctx: SessionCtx): number => { + const userId = ctx.session.userId + if (!userId) throw deny() + return userId +} + +// The user being acted on must be the signed-in user (not someone the browser names) +export function requireSelf(ctx: SessionCtx, userId: number): void { + if (currentUserId(ctx) !== userId) throw deny() +} + +export async function requireProjectMember(ctx: SessionCtx, projectId: number): Promise { + const access = await getProjectAccess(currentUserId(ctx)) + if (!access.memberProjectIds.includes(projectId)) throw deny() +} + +export async function requireProjectManager(ctx: SessionCtx, projectId: number): Promise { + const access = await getProjectAccess(currentUserId(ctx)) + if (!access.managerProjectIds.includes(projectId)) throw deny() +} + +// A project manager, or the person themselves (e.g. setting up their own widgets) +export async function requireManagerOrSelf( + ctx: SessionCtx, + projectId: number, + userId: number +): Promise { + const me = currentUserId(ctx) + const access = await getProjectAccess(me) + if (access.managerProjectIds.includes(projectId)) return + if (me === userId && access.memberProjectIds.includes(projectId)) return + throw deny() +} + +// Things that live inside a project, and how to find the project of each +export type ProjectEntity = + | "task" + | "milestone" + | "column" + | "projectMember" + | "taskLog" + | "invitation" + +const projectIdsOf = async (entity: ProjectEntity, ids: number[]): Promise => { + const where = { id: { in: ids } } + switch (entity) { + case "task": + return (await db.task.findMany({ where, select: { projectId: true } })).map( + (r) => r.projectId + ) + case "milestone": + return (await db.milestone.findMany({ where, select: { projectId: true } })).map( + (r) => r.projectId + ) + case "column": + return (await db.kanbanBoard.findMany({ where, select: { projectId: true } })).map( + (r) => r.projectId + ) + case "projectMember": + return (await db.projectMember.findMany({ where, select: { projectId: true } })).map( + (r) => r.projectId + ) + case "taskLog": + return ( + await db.taskLog.findMany({ where, select: { task: { select: { projectId: true } } } }) + ).map((r) => r.task.projectId) + case "invitation": + return (await db.invitation.findMany({ where, select: { projectId: true } })).map( + (r) => r.projectId + ) + } +} + +// Every id must exist; returns the (de-duplicated) projects they belong to +const resolveProjects = async (entity: ProjectEntity, ids: number[]): Promise => { + const unique = Array.from(new Set(ids)) + if (unique.length === 0) return [] + const found = await projectIdsOf(entity, unique) + if (found.length !== unique.length) throw deny() + return Array.from(new Set(found)) +} + +// The user manages the project of every one of these things. Returns those project ids. +export async function requireManagerOf( + ctx: SessionCtx, + entity: ProjectEntity, + ids: number[] +): Promise { + const access = await getProjectAccess(currentUserId(ctx)) + const projectIds = await resolveProjects(entity, ids) + if (projectIds.some((projectId) => !access.managerProjectIds.includes(projectId))) throw deny() + return projectIds +} + +// These things must all live in the given project (stops attaching another project's data) +export async function requireInProject( + entity: ProjectEntity, + ids: number[], + projectId: number +): Promise { + const projectIds = await resolveProjects(entity, ids) + if (projectIds.some((id) => id !== projectId)) throw deny() +} + +// A project member row (a person, or a team) that the signed-in user is part of. Returns its +// project. Used where the browser names "who is doing this" and it has to be the caller. +export async function requireOwnMember(ctx: SessionCtx, projectMemberId: number): Promise { + const member = await db.projectMember.findFirst({ + where: { id: projectMemberId, users: { some: { id: currentUserId(ctx) } } }, + select: { projectId: true }, + }) + if (!member) throw deny() + return member.projectId +} + +// A task log is for its project's managers and for the people it is assigned to +export async function requireTaskLogParticipant( + ctx: SessionCtx, + taskLogId: number +): Promise<{ projectId: number; isManager: boolean }> { + const userId = currentUserId(ctx) + const access = await getProjectAccess(userId) + const log = await db.taskLog.findUnique({ + where: { id: taskLogId }, + select: { + task: { select: { projectId: true } }, + assignedTo: { select: { users: { select: { id: true } } } }, + }, + }) + if (!log) throw deny() + const projectId = log.task.projectId + const isManager = access.managerProjectIds.includes(projectId) + const isAssignee = log.assignedTo.users.some((user) => user.id === userId) + if (!isManager && !isAssignee) throw deny() + return { projectId, isManager } +} + +// An invitation can be accepted or declined by the person it is for (matched by email, as the +// invites list does), and managed by the managers of its project +export async function requireInviteAccess( + ctx: SessionCtx, + invitationId: number, + options: { allowInvitee: boolean; allowManager: boolean } +): Promise { + const userId = currentUserId(ctx) + const access = await getProjectAccess(userId) + const invitation = await db.invitation.findUnique({ + where: { id: invitationId }, + select: { projectId: true, email: true }, + }) + if (!invitation) throw deny() + if (options.allowManager && access.managerProjectIds.includes(invitation.projectId)) return + if (options.allowInvitee) { + const me = await db.user.findUnique({ where: { id: userId }, select: { email: true } }) + if (me && me.email.toLowerCase() === invitation.email.toLowerCase()) return + } + throw deny() +} + +// Changing a member's role in a project: the caller manages the project, the member belongs to +// that project, and the user named is really one of that member's users +export async function requireManagerOfMemberUser( + ctx: SessionCtx, + projectMemberId: number, + projectId: number, + userId: number +): Promise { + const access = await getProjectAccess(currentUserId(ctx)) + if (!access.managerProjectIds.includes(projectId)) throw deny() + const member = await db.projectMember.findFirst({ + where: { id: projectMemberId, projectId, users: { some: { id: userId } } }, + select: { id: true }, + }) + if (!member) throw deny() +} + +// Forms, form versions and folders belong to a person, not a project +export async function requireFormOwner(ctx: SessionCtx, formIds: number[]): Promise { + const userId = currentUserId(ctx) + const unique = Array.from(new Set(formIds)) + const forms = await db.form.findMany({ + where: { id: { in: unique } }, + select: { userId: true }, + }) + if (forms.length !== unique.length || forms.some((form) => form.userId !== userId)) throw deny() +} + +export async function requireFormVersionOwner( + ctx: SessionCtx, + versionIds: number[] +): Promise { + const userId = currentUserId(ctx) + const unique = Array.from(new Set(versionIds)) + const versions = await db.formVersion.findMany({ + where: { id: { in: unique } }, + select: { form: { select: { userId: true } } }, + }) + if (versions.length !== unique.length || versions.some((v) => v.form.userId !== userId)) { + throw deny() + } +} + +export async function requireFolderOwner(ctx: SessionCtx, folderId: number): Promise { + const userId = currentUserId(ctx) + const folder = await db.folder.findFirst({ + where: { id: folderId, userId }, + select: { id: true }, + }) + if (!folder) throw deny() +} + +// A role belongs to the person who made it; a role tied to a project can also be managed by +// that project's managers +export async function requireRoleAccess(ctx: SessionCtx, roleIds: number[]): Promise { + const userId = currentUserId(ctx) + const access = await getProjectAccess(userId) + const unique = Array.from(new Set(roleIds)) + const roles = await db.role.findMany({ + where: { id: { in: unique } }, + select: { userId: true, projectId: true }, + }) + const allowed = (role: { userId: number; projectId: number | null }) => + role.userId === userId || + (role.projectId !== null && access.managerProjectIds.includes(role.projectId)) + if (roles.length !== unique.length || !roles.every(allowed)) throw deny() +} diff --git a/src/projects/mutations/copyProject.ts b/src/projects/mutations/copyProject.ts index 11f90a45..65d98ff5 100644 --- a/src/projects/mutations/copyProject.ts +++ b/src/projects/mutations/copyProject.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { CopyProjectSchema } from "../schemas" +import { requireProjectManager } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(CopyProjectSchema), resolver.authorize(), async ({ id }, ctx) => { + await requireProjectManager(ctx, id) const userId = ctx.session.userId const original = await db.project.findFirstOrThrow({ diff --git a/src/projects/mutations/deleteProject.ts b/src/projects/mutations/deleteProject.ts index ef25cee2..235b149e 100644 --- a/src/projects/mutations/deleteProject.ts +++ b/src/projects/mutations/deleteProject.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { DeleteProjectSchema } from "../schemas" +import { requireProjectManager } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(DeleteProjectSchema), resolver.authorize(), - async ({ id }) => { + async ({ id }, ctx) => { + await requireProjectManager(ctx, id) // Delete related ProjectWidgets if the parent project is deleted await db.projectWidget.deleteMany({ where: { projectId: id } }) // Delete tasks if the parent project is deleted diff --git a/src/projects/mutations/updateProject.ts b/src/projects/mutations/updateProject.ts index b8463ea2..21f6f8d9 100644 --- a/src/projects/mutations/updateProject.ts +++ b/src/projects/mutations/updateProject.ts @@ -2,11 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateProjectSchema } from "../schemas" +import { requireProjectManager } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateProjectSchema), resolver.authorize(), - async ({ id, ...data }) => { - // TODO: in multi-tenant app, you must add validation to ensure correct tenant + async ({ id, ...data }, ctx) => { + await requireProjectManager(ctx, id) const project = await db.project.update({ where: { id }, include: { diff --git a/src/roles/mutations/createRole.ts b/src/roles/mutations/createRole.ts index d5172356..d79d42bb 100644 --- a/src/roles/mutations/createRole.ts +++ b/src/roles/mutations/createRole.ts @@ -2,11 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { CreateRoleSchema } from "../schemas" +import { requireSelf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(CreateRoleSchema), resolver.authorize(), - async (input) => { - // TODO: in multi-tenant app, you must add validation to ensure correct tenant + async (input, ctx) => { + requireSelf(ctx, input.userId) const role = await db.role.create({ data: input }) diff --git a/src/roles/mutations/deleteRole.ts b/src/roles/mutations/deleteRole.ts index 3d9f8f41..ee9c0bb1 100644 --- a/src/roles/mutations/deleteRole.ts +++ b/src/roles/mutations/deleteRole.ts @@ -2,11 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { DeleteRoleSchema } from "../schemas" +import { requireRoleAccess } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(DeleteRoleSchema), resolver.authorize(), - async ({ id }) => { - // TODO: in multi-tenant app, you must add validation to ensure correct tenant + async ({ id }, ctx) => { + await requireRoleAccess(ctx, [id]) const role = await db.role.deleteMany({ where: { id } }) return role diff --git a/src/roles/mutations/importDefaultRoles.ts b/src/roles/mutations/importDefaultRoles.ts index 9d025ff7..9985a695 100644 --- a/src/roles/mutations/importDefaultRoles.ts +++ b/src/roles/mutations/importDefaultRoles.ts @@ -3,6 +3,7 @@ import db from "db" import { z } from "zod" import { defaultRoleTemplates } from "../templates/defaultRoles" +import { requireSelf } from "src/projectprivileges/utils/requireAccess" const roleSystemIds = defaultRoleTemplates.map((r) => r.id) as [string, ...string[]] const ImportRolesSchema = z.object({ @@ -13,7 +14,8 @@ const ImportRolesSchema = z.object({ export default resolver.pipe( resolver.zod(ImportRolesSchema), resolver.authorize(), - async ({ system, userId }) => { + async ({ system, userId }, ctx) => { + requireSelf(ctx, userId) // system is now an array of ids const allTemplates = system.flatMap((sysId) => { const matched = defaultRoleTemplates.find((r) => r.id === sysId) diff --git a/src/roles/mutations/updateRole.ts b/src/roles/mutations/updateRole.ts index d10f424a..b04eef7a 100644 --- a/src/roles/mutations/updateRole.ts +++ b/src/roles/mutations/updateRole.ts @@ -2,11 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateRoleSchema } from "../schemas" +import { requireRoleAccess } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateRoleSchema), resolver.authorize(), - async ({ id, ...data }) => { - // TODO: in multi-tenant app, you must add validation to ensure correct tenant + async ({ id, userId: _ignoredOwner, ...data }, ctx) => { + await requireRoleAccess(ctx, [id]) const role = await db.role.update({ where: { id }, data }) return role diff --git a/src/tasklogs/mutations/updateApproval.ts b/src/tasklogs/mutations/updateApproval.ts index 143419e8..e9036005 100644 --- a/src/tasklogs/mutations/updateApproval.ts +++ b/src/tasklogs/mutations/updateApproval.ts @@ -4,6 +4,7 @@ import db from "db" import sendNotification from "src/notifications/mutations/sendNotification" import { z } from "zod" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export const UpdateTaskLogApprovalSchema = z.object({ id: z.number(), approved: z.boolean().nullable(), @@ -14,6 +15,7 @@ export default resolver.pipe( resolver.zod(UpdateTaskLogApprovalSchema), resolver.authorize(), async ({ id, approved, completedById }, ctx) => { + await requireManagerOf(ctx, "taskLog", [id]) //get the tasklog and update it const currentTaskLog = await db.taskLog.findUnique({ where: { id: id } }) if (!currentTaskLog) { diff --git a/src/tasklogs/mutations/updateTaskLog.ts b/src/tasklogs/mutations/updateTaskLog.ts index 3a1989c0..20427baa 100644 --- a/src/tasklogs/mutations/updateTaskLog.ts +++ b/src/tasklogs/mutations/updateTaskLog.ts @@ -5,10 +5,16 @@ import sendNotification from "src/notifications/mutations/sendNotification" import { getStatusText } from "src/core/utils/getStatusText" import { Routes } from "@blitzjs/next" +import { + requireTaskLogParticipant, + requireOwnMember, +} from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(CreateTaskLogSchema), resolver.authorize(), async ({ id, metadata, status, completedById, completedAs }, ctx) => { + await requireTaskLogParticipant(ctx, id) + await requireOwnMember(ctx, completedById) // Get current taskLog to be updated const currentTaskLog = await db.taskLog.findUnique({ where: { id: id } }) diff --git a/src/tasks/mutations/createColumn.ts b/src/tasks/mutations/createColumn.ts index 0164830b..97634dae 100644 --- a/src/tasks/mutations/createColumn.ts +++ b/src/tasks/mutations/createColumn.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { CreateColumnSchema } from "../schemas" +import { requireProjectManager } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(CreateColumnSchema), resolver.authorize(), - async ({ projectId, name }) => { + async ({ projectId, name }, ctx) => { + await requireProjectManager(ctx, projectId) // Make sure it is not possible to create a new column called Done if (name.trim().toLowerCase() === "done") { throw new Error("You cannot manually create a column named 'Done'.") diff --git a/src/tasks/mutations/createTask.ts b/src/tasks/mutations/createTask.ts index 0a16b8b3..1762c2e2 100644 --- a/src/tasks/mutations/createTask.ts +++ b/src/tasks/mutations/createTask.ts @@ -4,6 +4,7 @@ import { CreateTaskSchema } from "../schemas" import sendNotification from "src/notifications/mutations/sendNotification" import { Routes } from "@blitzjs/next" +import { requireProjectManager, requireInProject } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(CreateTaskSchema), resolver.authorize(), @@ -27,6 +28,12 @@ export default resolver.pipe( }, ctx ) => { + await requireProjectManager(ctx, projectId) + await requireInProject("column", [containerId], projectId) + const referencedMembers = [...(projectMembersId ?? []), ...(teamsId ?? [])] + if (referencedMembers.length) + await requireInProject("projectMember", referencedMembers, projectId) + if (milestoneId) await requireInProject("milestone", [milestoneId], projectId) // Get number of tasks for the column inside the project const containerTaskOrder = await db.task.count({ where: { diff --git a/src/tasks/mutations/deleteColumn.ts b/src/tasks/mutations/deleteColumn.ts index d7751d8a..f9a87722 100644 --- a/src/tasks/mutations/deleteColumn.ts +++ b/src/tasks/mutations/deleteColumn.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { DeleteColumnSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(DeleteColumnSchema), resolver.authorize(), - async ({ id }) => { + async ({ id }, ctx) => { + await requireManagerOf(ctx, "column", [id]) const column = await db.kanbanBoard.findUnique({ where: { id: id }, include: { tasks: true }, diff --git a/src/tasks/mutations/deleteTask.ts b/src/tasks/mutations/deleteTask.ts index fff12e62..9ae77ef7 100644 --- a/src/tasks/mutations/deleteTask.ts +++ b/src/tasks/mutations/deleteTask.ts @@ -2,11 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { DeleteTaskSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(DeleteTaskSchema), resolver.authorize(), - async ({ id }) => { - // TODO: in multi-tenant app, you must add validation to ensure correct tenant + async ({ id }, ctx) => { + await requireManagerOf(ctx, "task", [id]) // Delete assignments if the parent task is deleted await db.taskLog.deleteMany({ where: { taskId: id } }) // Delete the task from the task table diff --git a/src/tasks/mutations/updateColumn.ts b/src/tasks/mutations/updateColumn.ts index 22db7a51..8aa88004 100644 --- a/src/tasks/mutations/updateColumn.ts +++ b/src/tasks/mutations/updateColumn.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateColumnSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateColumnSchema), resolver.authorize(), - async ({ id, name }) => { + async ({ id, name }, ctx) => { + await requireManagerOf(ctx, "column", [id]) const updated = await db.kanbanBoard.update({ where: { id: id }, data: { name }, diff --git a/src/tasks/mutations/updateColumnOrder.ts b/src/tasks/mutations/updateColumnOrder.ts index 0dbf189a..b1aa58b6 100644 --- a/src/tasks/mutations/updateColumnOrder.ts +++ b/src/tasks/mutations/updateColumnOrder.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateColumnOrderSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateColumnOrderSchema), resolver.authorize(), - async ({ containerIds }) => { + async ({ containerIds }, ctx) => { + await requireManagerOf(ctx, "column", containerIds) // Perform updates within a transaction const updatedColumns = await db.$transaction(async (prisma) => { const updates = containerIds.map((containerId, index) => { diff --git a/src/tasks/mutations/updateTask.ts b/src/tasks/mutations/updateTask.ts index ef51e25c..6ca711e8 100644 --- a/src/tasks/mutations/updateTask.ts +++ b/src/tasks/mutations/updateTask.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db, { CompletedAs } from "db" import { UpdateTaskSchema } from "../schemas" +import { requireManagerOf, requireInProject } from "src/projectprivileges/utils/requireAccess" async function manageRoles(taskId, rolesId) { await db.$transaction(async (prisma) => { await db.task.update({ @@ -80,7 +81,14 @@ async function manageAssignedMembers(taskId: number, currentIds: number[], newId export default resolver.pipe( resolver.zod(UpdateTaskSchema), resolver.authorize(), - async ({ id, projectMembersId = [], teamsId = [], rolesId = [], ...data }) => { + async ({ id, projectMembersId = [], teamsId = [], rolesId = [], ...data }, ctx) => { + const [projectId] = await requireManagerOf(ctx, "task", [id]) + const referencedMembers = [...(projectMembersId ?? []), ...((teamsId ?? []) as number[])] + if (referencedMembers.length) { + await requireInProject("projectMember", referencedMembers, projectId!) + } + await requireInProject("column", [data.containerId], projectId!) + if (data.milestoneId) await requireInProject("milestone", [data.milestoneId], projectId!) // TODO: later we have to clean up the logic for undefined an nullable values const safeProjectMembersId: number[] = projectMembersId || [] const safeTeamsId: number[] = teamsId || [] diff --git a/src/tasks/mutations/updateTaskDates.ts b/src/tasks/mutations/updateTaskDates.ts index 0cef12c2..32587e6c 100644 --- a/src/tasks/mutations/updateTaskDates.ts +++ b/src/tasks/mutations/updateTaskDates.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateTaskDatesSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateTaskDatesSchema), resolver.authorize(), - async ({ id, ...data }) => { + async ({ id, ...data }, ctx) => { + await requireManagerOf(ctx, "task", [id]) const task = await db.task.update({ where: { id }, data, diff --git a/src/tasks/mutations/updateTaskOrder.ts b/src/tasks/mutations/updateTaskOrder.ts index e7713187..e00305c5 100644 --- a/src/tasks/mutations/updateTaskOrder.ts +++ b/src/tasks/mutations/updateTaskOrder.ts @@ -2,10 +2,21 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateTaskOrderSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateTaskOrderSchema), resolver.authorize(), - async ({ tasks }) => { + async ({ tasks }, ctx) => { + await requireManagerOf( + ctx, + "task", + tasks.map((task) => task.taskId) + ) + await requireManagerOf( + ctx, + "column", + tasks.map((task) => task.containerId) + ) return await Promise.all( tasks.map(async (task) => { const updatedTask = await db.task.update({ diff --git a/src/tasks/mutations/updateTaskRole.ts b/src/tasks/mutations/updateTaskRole.ts index 16f03ba9..a00fc4fe 100644 --- a/src/tasks/mutations/updateTaskRole.ts +++ b/src/tasks/mutations/updateTaskRole.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateTaskRoleSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" async function updateTask(taskId, rolesId, disconnect) { let task1 await db.$transaction(async (prisma) => { @@ -31,7 +32,8 @@ async function updateTask(taskId, rolesId, disconnect) { export default resolver.pipe( resolver.zod(UpdateTaskRoleSchema), resolver.authorize(), - async ({ tasksId, rolesId = [], disconnect, ...data }) => { + async ({ tasksId, rolesId = [], disconnect, ...data }, ctx) => { + await requireManagerOf(ctx, "task", tasksId) let task1 tasksId.forEach(async (taskId) => { task1 = await updateTask(taskId, rolesId, disconnect) diff --git a/src/tasks/mutations/updateTaskStatus.ts b/src/tasks/mutations/updateTaskStatus.ts index b31ba51c..fab5b6ef 100644 --- a/src/tasks/mutations/updateTaskStatus.ts +++ b/src/tasks/mutations/updateTaskStatus.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateTaskStatusSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateTaskStatusSchema), resolver.authorize(), - async ({ id, status }) => { + async ({ id, status }, ctx) => { + await requireManagerOf(ctx, "task", [id]) const task = await db.task.update({ where: { id }, data: { status } }) return task diff --git a/src/tasks/mutations/updateTasksForMilestone.ts b/src/tasks/mutations/updateTasksForMilestone.ts index 2da59d82..fa2bb41c 100644 --- a/src/tasks/mutations/updateTasksForMilestone.ts +++ b/src/tasks/mutations/updateTasksForMilestone.ts @@ -2,10 +2,13 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { UpdateTasksForMilestoneSchema } from "../schemas" +import { requireManagerOf, requireInProject } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateTasksForMilestoneSchema), resolver.authorize(), - async ({ milestoneId, taskIds }) => { + async ({ milestoneId, taskIds }, ctx) => { + const [projectId] = await requireManagerOf(ctx, "milestone", [milestoneId]) + await requireInProject("task", taskIds, projectId!) const updatedTasks = await db.$transaction(async (prisma) => { // Disassociate tasks that are not in the taskIds array await prisma.task.updateMany({ diff --git a/src/teams/mutations/createTeam.ts b/src/teams/mutations/createTeam.ts index d7b3214a..3ece80ce 100644 --- a/src/teams/mutations/createTeam.ts +++ b/src/teams/mutations/createTeam.ts @@ -4,10 +4,12 @@ import { CreateTeamSchema } from "../schemas" import { Routes } from "@blitzjs/next" import sendNotification from "src/notifications/mutations/sendNotification" +import { requireProjectManager } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(CreateTeamSchema), resolver.authorize(), async ({ projectId, name, userIds, invitationIds, tags }, ctx) => { + await requireProjectManager(ctx, projectId) const team = await db.projectMember.create({ data: { name, diff --git a/src/teams/mutations/deleteTeam.ts b/src/teams/mutations/deleteTeam.ts index 6eebf2c0..db60e531 100644 --- a/src/teams/mutations/deleteTeam.ts +++ b/src/teams/mutations/deleteTeam.ts @@ -2,10 +2,12 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { DeleteTeamSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(DeleteTeamSchema), resolver.authorize(), - async ({ id }) => { + async ({ id }, ctx) => { + await requireManagerOf(ctx, "projectMember", [id]) const team = await db.projectMember.update({ where: { id }, data: { deleted: true } }) return team diff --git a/src/teams/mutations/updateTeam.ts b/src/teams/mutations/updateTeam.ts index 6dbbd87b..38805ab0 100644 --- a/src/teams/mutations/updateTeam.ts +++ b/src/teams/mutations/updateTeam.ts @@ -4,10 +4,12 @@ import { Routes } from "@blitzjs/next" import sendNotification from "src/notifications/mutations/sendNotification" import { UpdateTeamSchema } from "../schemas" +import { requireManagerOf } from "src/projectprivileges/utils/requireAccess" export default resolver.pipe( resolver.zod(UpdateTeamSchema), resolver.authorize(), async ({ id, name, userIds, invitationIds, tags }, ctx) => { + await requireManagerOf(ctx, "projectMember", [id]) // Fetch existing users and projectId for this team to compute newly added users const existing = await db.projectMember.findFirst({ where: { id }, diff --git a/src/updates/queries/20250101_01_defaultform.ts b/src/updates/queries/20250101_01_defaultform.ts index 83af6edb..aec7518b 100644 --- a/src/updates/queries/20250101_01_defaultform.ts +++ b/src/updates/queries/20250101_01_defaultform.ts @@ -3,7 +3,7 @@ import db from "db" import { getDefaultSchemaLists } from "src/forms/utils/getDefaultSchemaList" export default async function createDefaultFormsForUsers(_: unknown, ctx: Ctx) { - ctx.session.$authorize() // Authorize the user + ctx.session.$authorize("ADMIN") // one-off maintenance: administrators only try { console.log("Fetching users...") diff --git a/src/updates/queries/20250101_02_linkdefaultform.ts b/src/updates/queries/20250101_02_linkdefaultform.ts index ad4907bc..b4bac459 100644 --- a/src/updates/queries/20250101_02_linkdefaultform.ts +++ b/src/updates/queries/20250101_02_linkdefaultform.ts @@ -2,7 +2,7 @@ import { Ctx } from "@blitzjs/next" import db from "db" export default async function linkDefaultFormToProjects(_: unknown, ctx: Ctx) { - ctx.session.$authorize() // Authorize the user + ctx.session.$authorize("ADMIN") // one-off maintenance: administrators only const projects = await db.project.findMany({ include: { diff --git a/src/updates/queries/20250101_03_createmetadata.ts b/src/updates/queries/20250101_03_createmetadata.ts index 9ceeea70..3371d304 100644 --- a/src/updates/queries/20250101_03_createmetadata.ts +++ b/src/updates/queries/20250101_03_createmetadata.ts @@ -2,7 +2,7 @@ import db from "db" import { Ctx } from "@blitzjs/next" export default async function migrateColumnsToMetadata(_: unknown, ctx: Ctx) { - ctx.session.$authorize() // Authorize the user + ctx.session.$authorize("ADMIN") // one-off maintenance: administrators only const projects = await db.project.findMany() for (const project of projects) { diff --git a/src/updates/queries/20250319_01_dashboardwidgets.ts b/src/updates/queries/20250319_01_dashboardwidgets.ts index 32f72853..51bc3f66 100644 --- a/src/updates/queries/20250319_01_dashboardwidgets.ts +++ b/src/updates/queries/20250319_01_dashboardwidgets.ts @@ -1,9 +1,9 @@ import { Ctx } from "blitz" -import initializeWidgets from "src/widgets/mutations/initializeWidgets" +import { createDefaultWidgets } from "src/widgets/utils/createDefaultWidgets" import db from "db" export default async function migrateNewDashboardWidgets(_: unknown, ctx: Ctx) { - ctx.session.$authorize() // Authorize the user + ctx.session.$authorize("ADMIN") // one-off maintenance: administrators only // Get all users const users = await db.user.findMany({ @@ -19,7 +19,7 @@ export default async function migrateNewDashboardWidgets(_: unknown, ctx: Ctx) { }) console.log(`Initializing new widgets for user: ${user.id}`) - await initializeWidgets(user.id, ctx) + await createDefaultWidgets(user.id) } console.log("Widgets migration completed for all users.") diff --git a/src/updates/queries/20250529_01_milestonestoelements.ts b/src/updates/queries/20250529_01_milestonestoelements.ts index bad9d2a0..d626746a 100644 --- a/src/updates/queries/20250529_01_milestonestoelements.ts +++ b/src/updates/queries/20250529_01_milestonestoelements.ts @@ -2,7 +2,7 @@ import { Ctx } from "blitz" import db from "db" export default async function migrateElementsToMilestones(_: unknown, ctx: Ctx) { - ctx.session.$authorize() // Authorize the user + ctx.session.$authorize("ADMIN") // one-off maintenance: administrators only const elements = await db.element.findMany() diff --git a/src/updates/queries/updateQueriesAreAdminOnly.test.ts b/src/updates/queries/updateQueriesAreAdminOnly.test.ts new file mode 100644 index 00000000..3a729c54 --- /dev/null +++ b/src/updates/queries/updateQueriesAreAdminOnly.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test, vi } from "vitest" + +// Any write that gets past the check shows up here +const writes: string[] = [] +vi.mock("db", () => { + const model = (name: string) => + new Proxy( + {}, + { + get: (_t, method: string) => async () => { + if (/^(create|update|delete|upsert)/.test(method)) writes.push(`${name}.${method}`) + return [] + }, + } + ) + return { default: new Proxy({}, { get: (_t, prop: string) => model(prop) }), WidgetSize: {} } +}) + +// These maintenance queries change data for every user, so only administrators may run them +// (the Updates page hiding them from others is not protection on its own). +const UPDATES = [ + ["20250101_01_defaultform", () => import("./20250101_01_defaultform")], + ["20250101_02_linkdefaultform", () => import("./20250101_02_linkdefaultform")], + ["20250101_03_createmetadata", () => import("./20250101_03_createmetadata")], + ["20250319_01_dashboardwidgets", () => import("./20250319_01_dashboardwidgets")], + ["20250529_01_milestonestoelements", () => import("./20250529_01_milestonestoelements")], +] as const + +describe("one-off update queries", () => { + for (const [name, load] of UPDATES) { + test(`${name} requires an administrator and does nothing for anyone else`, async () => { + writes.length = 0 + const authorize = vi.fn((..._roles: string[]) => { + throw new Error("not an administrator") + }) + const ctx: any = { session: { userId: 7, $authorize: authorize } } + + await expect((await load()).default({}, ctx)).rejects.toThrow("not an administrator") + expect(authorize).toHaveBeenCalledWith("ADMIN") + expect(writes).toEqual([]) + }) + } +}) diff --git a/src/widgets/mutations/addProjectManagerWidgets.ts b/src/widgets/mutations/addProjectManagerWidgets.ts index 6686d60d..6c4ef7f9 100644 --- a/src/widgets/mutations/addProjectManagerWidgets.ts +++ b/src/widgets/mutations/addProjectManagerWidgets.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db, { MemberPrivileges, WidgetSize } from "db" import { z } from "zod" +import { requireProjectManager } from "src/projectprivileges/utils/requireAccess" const addProjectManagerWidgetsProps = z.object({ userId: z.number(), projectId: z.number(), @@ -10,7 +11,8 @@ const addProjectManagerWidgetsProps = z.object({ export default resolver.pipe( resolver.zod(addProjectManagerWidgetsProps), resolver.authorize(), - async ({ userId, projectId }) => { + async ({ userId, projectId }, ctx) => { + await requireProjectManager(ctx, projectId) // Check if the user already has the PROJECT_MANAGER privilege const existingPrivilege = await db.projectPrivilege.findFirst({ where: { diff --git a/src/widgets/mutations/initializeProjectWidgets.ts b/src/widgets/mutations/initializeProjectWidgets.ts index ccf35a0e..e5ca8e6f 100644 --- a/src/widgets/mutations/initializeProjectWidgets.ts +++ b/src/widgets/mutations/initializeProjectWidgets.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db, { MemberPrivileges, WidgetSize } from "db" import { z } from "zod" +import { requireManagerOrSelf } from "src/projectprivileges/utils/requireAccess" const SetProjectWidgets = z.object({ userId: z.number(), projectId: z.number(), @@ -11,7 +12,8 @@ const SetProjectWidgets = z.object({ export default resolver.pipe( resolver.zod(SetProjectWidgets), resolver.authorize(), - async ({ userId, projectId, privilege }) => { + async ({ userId, projectId, privilege }, ctx) => { + await requireManagerOrSelf(ctx, projectId, userId) const widgetData = [ { userId, diff --git a/src/widgets/mutations/initializeWidgets.test.ts b/src/widgets/mutations/initializeWidgets.test.ts new file mode 100644 index 00000000..7d564167 --- /dev/null +++ b/src/widgets/mutations/initializeWidgets.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +const createMany = vi.fn() +const findMany = vi.fn() +vi.mock("db", () => ({ + default: { + widget: { + createMany: (...a: unknown[]) => createMany(...a), + findMany: (...a: unknown[]) => findMany(...a), + }, + }, + WidgetSize: { SMALL: "SMALL", LARGE: "LARGE" }, +})) + +import initializeWidgets from "./initializeWidgets" + +const ctx: any = { session: { userId: 7, $isAuthorized: () => true, $authorize: () => undefined } } + +describe("initializeWidgets", () => { + beforeEach(() => { + vi.clearAllMocks() + findMany.mockResolvedValue([]) + }) + + test("sets up the signed-in user's own dashboard", async () => { + await initializeWidgets(7, ctx) + expect(createMany).toHaveBeenCalledOnce() + expect(createMany.mock.calls[0]![0].data.every((w: any) => w.userId === 7)).toBe(true) + }) + + test("refuses to set up (or reset) someone else's dashboard", async () => { + await expect(initializeWidgets(8, ctx)).rejects.toThrow() + expect(createMany).not.toHaveBeenCalled() + }) +}) diff --git a/src/widgets/mutations/initializeWidgets.ts b/src/widgets/mutations/initializeWidgets.ts index 6c54b3c5..eb3e458e 100644 --- a/src/widgets/mutations/initializeWidgets.ts +++ b/src/widgets/mutations/initializeWidgets.ts @@ -1,44 +1,8 @@ import { resolver } from "@blitzjs/rpc" -import db, { WidgetSize } from "db" +import { requireSelf } from "src/projectprivileges/utils/requireAccess" +import { createDefaultWidgets } from "../utils/createDefaultWidgets" -export default resolver.pipe(resolver.authorize(), async (userId: number) => { - // Adding main dashboard default widgets - const widgetTypes = ["LastProject", "OverdueTask", "UpcomingTask", "Notifications"] - // New small widgets - const smallWidgetTypes = [ - "AllTaskTotal", - "TotalContributors", - "TotalForms", - "TotalInvites", - "TotalProjects", - "TotalRoles", - ] - const widgets = widgetTypes.map((type, index) => ({ - userId: userId, - type: type, - show: true, - position: smallWidgetTypes.length + index + 1, - size: WidgetSize.LARGE, - })) - - const smallWidgets = smallWidgetTypes.map((type, index) => ({ - userId: userId, - type: type, - show: true, - position: index + 1, - size: WidgetSize.SMALL, - })) - - // Create the widgets - await db.widget.createMany({ - data: [...smallWidgets, ...widgets], - }) - - // Retrieve the created widgets - const createdWidgets = await db.widget.findMany({ - where: { userId }, - orderBy: { position: "asc" }, - }) - - return createdWidgets +export default resolver.pipe(resolver.authorize(), async (userId: number, ctx) => { + requireSelf(ctx, userId) + return createDefaultWidgets(userId) }) diff --git a/src/widgets/mutations/removeProjectManagerWidgets.ts b/src/widgets/mutations/removeProjectManagerWidgets.ts index b130b209..7295f37a 100644 --- a/src/widgets/mutations/removeProjectManagerWidgets.ts +++ b/src/widgets/mutations/removeProjectManagerWidgets.ts @@ -2,6 +2,7 @@ import { resolver } from "@blitzjs/rpc" import db from "db" import { z } from "zod" +import { requireProjectManager } from "src/projectprivileges/utils/requireAccess" const removeProjectManagerWidgetsProps = z.object({ userId: z.number(), projectId: z.number(), @@ -10,7 +11,8 @@ const removeProjectManagerWidgetsProps = z.object({ export default resolver.pipe( resolver.zod(removeProjectManagerWidgetsProps), resolver.authorize(), - async ({ userId, projectId }) => { + async ({ userId, projectId }, ctx) => { + await requireProjectManager(ctx, projectId) await db.projectWidget.deleteMany({ where: { userId, diff --git a/src/widgets/utils/createDefaultWidgets.ts b/src/widgets/utils/createDefaultWidgets.ts new file mode 100644 index 00000000..faaedb10 --- /dev/null +++ b/src/widgets/utils/createDefaultWidgets.ts @@ -0,0 +1,44 @@ +import db, { WidgetSize } from "db" + +// The default main-dashboard widgets for a user. Not an endpoint: callers decide who may do this. +export async function createDefaultWidgets(userId: number) { + // Adding main dashboard default widgets + const widgetTypes = ["LastProject", "OverdueTask", "UpcomingTask", "Notifications"] + // New small widgets + const smallWidgetTypes = [ + "AllTaskTotal", + "TotalContributors", + "TotalForms", + "TotalInvites", + "TotalProjects", + "TotalRoles", + ] + const widgets = widgetTypes.map((type, index) => ({ + userId: userId, + type: type, + show: true, + position: smallWidgetTypes.length + index + 1, + size: WidgetSize.LARGE, + })) + + const smallWidgets = smallWidgetTypes.map((type, index) => ({ + userId: userId, + type: type, + show: true, + position: index + 1, + size: WidgetSize.SMALL, + })) + + // Create the widgets + await db.widget.createMany({ + data: [...smallWidgets, ...widgets], + }) + + // Retrieve the created widgets + const createdWidgets = await db.widget.findMany({ + where: { userId }, + orderBy: { position: "asc" }, + }) + + return createdWidgets +}