diff --git a/apps/dashboard/src/modals/AddDashboard.tsx b/apps/dashboard/src/modals/AddDashboard.tsx index b5cef7ff..23fc1d5e 100644 --- a/apps/dashboard/src/modals/AddDashboard.tsx +++ b/apps/dashboard/src/modals/AddDashboard.tsx @@ -50,7 +50,6 @@ export default function AddDashboard() { mutation.mutate({ name, projectId, - organizationSlug, }); })} > diff --git a/apps/dashboard/src/modals/SaveReport.tsx b/apps/dashboard/src/modals/SaveReport.tsx index 5d733f3a..f5df533f 100644 --- a/apps/dashboard/src/modals/SaveReport.tsx +++ b/apps/dashboard/src/modals/SaveReport.tsx @@ -114,7 +114,6 @@ export default function SaveReport({ report }: SaveReportProps) { dashboardMutation.mutate({ projectId, name: value, - organizationSlug, }); }} /> diff --git a/packages/trpc/src/access.ts b/packages/trpc/src/access.ts index d851cf73..32162d25 100644 --- a/packages/trpc/src/access.ts +++ b/packages/trpc/src/access.ts @@ -1,7 +1,7 @@ import { db, getProjectById } from '@openpanel/db'; import { cacheable } from '@openpanel/redis'; -export const getProjectAccessCached = cacheable(getProjectAccess, 60 * 60); +export const getProjectAccessCached = cacheable(getProjectAccess, 60 * 5); export async function getProjectAccess({ userId, projectId, @@ -16,14 +16,26 @@ export async function getProjectAccess({ return false; } - const member = await db.member.findFirst({ - where: { - organizationId: project.organizationSlug, - userId, - }, - }); + const [projectAccess, member] = await Promise.all([ + db.projectAccess.findMany({ + where: { + userId, + organizationId: project.organizationSlug, + }, + }), + db.member.findFirst({ + where: { + organizationId: project.organizationSlug, + userId, + }, + }), + ]); - return member; + if (projectAccess.length === 0 && member) { + return true; + } + + return projectAccess.find((item) => item.projectId === projectId); } catch (err) { return false; } @@ -31,7 +43,7 @@ export async function getProjectAccess({ export const getOrganizationAccessCached = cacheable( getOrganizationAccess, - 60 * 60 + 60 * 5 ); export async function getOrganizationAccess({ userId, @@ -47,3 +59,35 @@ export async function getOrganizationAccess({ }, }); } + +export const getClientAccessCached = cacheable(getClientAccess, 60 * 5); +export async function getClientAccess({ + userId, + clientId, +}: { + userId: string; + clientId: string; +}) { + const client = await db.client.findFirst({ + where: { + id: clientId, + }, + }); + + if (!client) { + return false; + } + + if (client.projectId) { + return getProjectAccess({ userId, projectId: client.projectId }); + } + + if (client.organizationId) { + return getOrganizationAccess({ + userId, + organizationId: client.organizationId, + }); + } + + return false; +} diff --git a/packages/trpc/src/errors.ts b/packages/trpc/src/errors.ts index 382537e5..e7a8ee74 100644 --- a/packages/trpc/src/errors.ts +++ b/packages/trpc/src/errors.ts @@ -5,3 +5,9 @@ export const TRPCAccessError = (message: string) => code: 'UNAUTHORIZED', message, }); + +export const TRPCNotFoundError = (message: string) => + new TRPCError({ + code: 'NOT_FOUND', + message, + }); diff --git a/packages/trpc/src/routers/client.ts b/packages/trpc/src/routers/client.ts index 660e2dec..21cd8e0f 100644 --- a/packages/trpc/src/routers/client.ts +++ b/packages/trpc/src/routers/client.ts @@ -5,6 +5,8 @@ import { hashPassword, stripTrailingSlash } from '@openpanel/common'; import type { Prisma } from '@openpanel/db'; import { db } from '@openpanel/db'; +import { getClientAccess } from '../access'; +import { TRPCAccessError } from '../errors'; import { createTRPCRouter, protectedProcedure } from '../trpc'; export const clientRouter = createTRPCRouter({ @@ -17,7 +19,16 @@ export const clientRouter = createTRPCRouter({ crossDomain: z.boolean().optional(), }) ) - .mutation(({ input }) => { + .mutation(async ({ input, ctx }) => { + const access = await getClientAccess({ + userId: ctx.session.userId, + clientId: input.id, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this client'); + } + return db.client.update({ where: { id: input.id, @@ -66,7 +77,16 @@ export const clientRouter = createTRPCRouter({ id: z.string(), }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + const access = await getClientAccess({ + userId: ctx.session.userId, + clientId: input.id, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this client'); + } + await db.client.delete({ where: { id: input.id, diff --git a/packages/trpc/src/routers/dashboard.ts b/packages/trpc/src/routers/dashboard.ts index 4cdd2713..266c31c2 100644 --- a/packages/trpc/src/routers/dashboard.ts +++ b/packages/trpc/src/routers/dashboard.ts @@ -1,9 +1,16 @@ import { PrismaError } from 'prisma-error-enum'; import { z } from 'zod'; -import { db, getDashboardsByProjectId, getId } from '@openpanel/db'; +import { + db, + getDashboardsByProjectId, + getId, + getProjectById, +} from '@openpanel/db'; import type { Prisma } from '@openpanel/db'; +import { getProjectAccess } from '../access'; +import { TRPCAccessError, TRPCNotFoundError } from '../errors'; import { createTRPCRouter, protectedProcedure } from '../trpc'; export const dashboardRouter = createTRPCRouter({ @@ -13,7 +20,7 @@ export const dashboardRouter = createTRPCRouter({ projectId: z.string(), }) ) - .query(async ({ input }) => { + .query(({ input }) => { return getDashboardsByProjectId(input.projectId); }), create: protectedProcedure @@ -21,17 +28,31 @@ export const dashboardRouter = createTRPCRouter({ z.object({ name: z.string(), projectId: z.string(), - organizationSlug: z.string(), }) ) - .mutation(async ({ input: { organizationSlug, projectId, name } }) => { + .mutation(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + const project = await getProjectById(input.projectId); + + if (!project) { + throw TRPCNotFoundError('Project not found'); + } + return db.dashboard.create({ data: { - id: await getId('dashboard', name), - projectId: projectId, - organizationSlug: organizationSlug, - organizationId: organizationSlug, - name, + id: await getId('dashboard', input.name), + projectId: input.projectId, + organizationSlug: project.organizationId!, + organizationId: project.organizationId, + name: input.name, }, }); }), @@ -42,7 +63,22 @@ export const dashboardRouter = createTRPCRouter({ name: z.string(), }) ) - .mutation(({ input }) => { + .mutation(async ({ input, ctx }) => { + const dashboard = await db.dashboard.findUniqueOrThrow({ + where: { + id: input.id, + }, + }); + + const access = await getProjectAccess({ + projectId: dashboard.projectId, + userId: ctx.session.userId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this dashboard'); + } + return db.dashboard.update({ where: { id: input.id, @@ -59,18 +95,33 @@ export const dashboardRouter = createTRPCRouter({ forceDelete: z.boolean().optional(), }) ) - .mutation(async ({ input: { id, forceDelete } }) => { + .mutation(async ({ input, ctx }) => { + const dashboard = await db.dashboard.findUniqueOrThrow({ + where: { + id: input.id, + }, + }); + + const access = await getProjectAccess({ + projectId: dashboard.projectId, + userId: ctx.session.userId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this dashboard'); + } + try { - if (forceDelete) { + if (input.forceDelete) { await db.report.deleteMany({ where: { - dashboardId: id, + dashboardId: input.id, }, }); } await db.dashboard.delete({ where: { - id, + id: input.id, }, }); } catch (e) { diff --git a/packages/trpc/src/routers/event.ts b/packages/trpc/src/routers/event.ts index 1bb184b3..084ba703 100644 --- a/packages/trpc/src/routers/event.ts +++ b/packages/trpc/src/routers/event.ts @@ -27,18 +27,20 @@ export const eventRouter = createTRPCRouter({ conversion: z.boolean().optional(), }) ) - .mutation(({ input: { projectId, name, icon, color, conversion } }) => { - return db.eventMeta.upsert({ - where: { - name_projectId: { - name, - projectId, + .mutation( + async ({ input: { projectId, name, icon, color, conversion } }) => { + return db.eventMeta.upsert({ + where: { + name_projectId: { + name, + projectId, + }, }, - }, - create: { projectId, name, icon, color, conversion }, - update: { icon, color, conversion }, - }); - }), + create: { projectId, name, icon, color, conversion }, + update: { icon, color, conversion }, + }); + } + ), byId: protectedProcedure .input( @@ -78,8 +80,10 @@ export const eventRouter = createTRPCRouter({ profile: z.boolean().optional(), }) ) - .query(async ({ input }) => getEventList(input)), - conversions: publicProcedure + .query(async ({ input }) => { + return getEventList(input); + }), + conversions: protectedProcedure .input( z.object({ projectId: z.string(), diff --git a/packages/trpc/src/routers/organization.ts b/packages/trpc/src/routers/organization.ts index dbe0c4f6..42802e1e 100644 --- a/packages/trpc/src/routers/organization.ts +++ b/packages/trpc/src/routers/organization.ts @@ -5,6 +5,8 @@ import { z } from 'zod'; import { db } from '@openpanel/db'; import { zInviteUser } from '@openpanel/validation'; +import { getOrganizationAccess, getOrganizationAccessCached } from '../access'; +import { TRPCAccessError } from '../errors'; import { createTRPCRouter, protectedProcedure } from '../trpc'; export const organizationRouter = createTRPCRouter({ @@ -15,7 +17,16 @@ export const organizationRouter = createTRPCRouter({ name: z.string(), }) ) - .mutation(({ input }) => { + .mutation(async ({ input, ctx }) => { + const access = await getOrganizationAccess({ + userId: ctx.session.userId, + organizationId: input.id, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return db.organization.update({ where: { id: input.id, @@ -29,6 +40,15 @@ export const organizationRouter = createTRPCRouter({ inviteUser: protectedProcedure .input(zInviteUser) .mutation(async ({ input, ctx }) => { + const access = await getOrganizationAccess({ + userId: ctx.session.userId, + organizationId: input.organizationSlug, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + const email = input.email.toLowerCase(); const userExists = await db.user.findFirst({ where: { @@ -68,12 +88,22 @@ export const organizationRouter = createTRPCRouter({ memberId: z.string(), }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const member = await db.member.findUniqueOrThrow({ where: { id: input.memberId, }, }); + + const access = await getOrganizationAccess({ + userId: ctx.session.userId, + organizationId: member.organizationId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + const invitationId = pathOr( undefined, ['meta', 'invitationId'], @@ -107,6 +137,15 @@ export const organizationRouter = createTRPCRouter({ throw new Error('You cannot remove yourself from the organization'); } + const access = await getOrganizationAccess({ + userId: ctx.session.userId, + organizationId: input.organizationId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + await db.$transaction([ db.member.deleteMany({ where: { @@ -131,7 +170,16 @@ export const organizationRouter = createTRPCRouter({ access: z.array(z.string()), }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + const access = await getOrganizationAccess({ + userId: ctx.session.userId, + organizationId: input.organizationSlug, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return db.$transaction([ db.projectAccess.deleteMany({ where: { diff --git a/packages/trpc/src/routers/project.ts b/packages/trpc/src/routers/project.ts index 9beed89b..c9183d1a 100644 --- a/packages/trpc/src/routers/project.ts +++ b/packages/trpc/src/routers/project.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import { db, getId, getProjectsByOrganizationSlug } from '@openpanel/db'; +import { getProjectAccess } from '../access'; +import { TRPCAccessError } from '../errors'; import { createTRPCRouter, protectedProcedure } from '../trpc'; export const projectRouter = createTRPCRouter({ @@ -23,7 +25,16 @@ export const projectRouter = createTRPCRouter({ name: z.string(), }) ) - .mutation(({ input }) => { + .mutation(async ({ input, ctx }) => { + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId: input.id, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return db.project.update({ where: { id: input.id, @@ -56,7 +67,16 @@ export const projectRouter = createTRPCRouter({ id: z.string(), }) ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId: input.id, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + await db.project.delete({ where: { id: input.id, diff --git a/packages/trpc/src/routers/reference.ts b/packages/trpc/src/routers/reference.ts index 55a4ce62..bca56abe 100644 --- a/packages/trpc/src/routers/reference.ts +++ b/packages/trpc/src/routers/reference.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { db, getReferences } from '@openpanel/db'; import { zCreateReference, zRange } from '@openpanel/validation'; +import { getProjectAccess } from '../access'; +import { TRPCAccessError } from '../errors'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; import { getChartStartEndDate } from './chart.helpers'; @@ -23,7 +25,22 @@ export const referenceRouter = createTRPCRouter({ ), delete: protectedProcedure .input(z.object({ id: z.string() })) - .mutation(async ({ input: { id } }) => { + .mutation(async ({ input: { id }, ctx }) => { + const reference = await db.reference.findUniqueOrThrow({ + where: { + id, + }, + }); + + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId: reference.projectId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return db.reference.delete({ where: { id, diff --git a/packages/trpc/src/routers/report.ts b/packages/trpc/src/routers/report.ts index cc94419b..cc774067 100644 --- a/packages/trpc/src/routers/report.ts +++ b/packages/trpc/src/routers/report.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { db } from '@openpanel/db'; import { zReportInput } from '@openpanel/validation'; +import { getProjectAccess } from '../access'; +import { TRPCAccessError } from '../errors'; import { createTRPCRouter, protectedProcedure } from '../trpc'; export const reportRouter = createTRPCRouter({ @@ -13,12 +15,22 @@ export const reportRouter = createTRPCRouter({ dashboardId: z.string(), }) ) - .mutation(async ({ input: { report, dashboardId } }) => { + .mutation(async ({ input: { report, dashboardId }, ctx }) => { const dashboard = await db.dashboard.findUniqueOrThrow({ where: { id: dashboardId, }, }); + + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId: dashboard.projectId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return db.report.create({ data: { projectId: dashboard.projectId, @@ -42,7 +54,22 @@ export const reportRouter = createTRPCRouter({ report: zReportInput.omit({ projectId: true }), }) ) - .mutation(({ input: { report, reportId } }) => { + .mutation(async ({ input: { report, reportId }, ctx }) => { + const dbReport = await db.report.findUniqueOrThrow({ + where: { + id: reportId, + }, + }); + + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId: dbReport.projectId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return db.report.update({ where: { id: reportId, @@ -66,7 +93,22 @@ export const reportRouter = createTRPCRouter({ reportId: z.string(), }) ) - .mutation(({ input: { reportId } }) => { + .mutation(async ({ input: { reportId }, ctx }) => { + const report = await db.report.findUniqueOrThrow({ + where: { + id: reportId, + }, + }); + + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId: report.projectId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return db.report.delete({ where: { id: reportId, diff --git a/packages/trpc/src/routers/share.ts b/packages/trpc/src/routers/share.ts index b6613114..76fb7bb7 100644 --- a/packages/trpc/src/routers/share.ts +++ b/packages/trpc/src/routers/share.ts @@ -10,7 +10,7 @@ const uid = new ShortUniqueId({ length: 6 }); export const shareRouter = createTRPCRouter({ shareOverview: protectedProcedure .input(zShareOverview) - .mutation(({ input }) => { + .mutation(async ({ input }) => { return db.shareOverview.upsert({ where: { projectId: input.projectId, diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index 1cde624d..67a23204 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -5,7 +5,7 @@ import { has } from 'ramda'; import superjson from 'superjson'; import { ZodError } from 'zod'; -import { getProjectAccessCached } from './access'; +import { getOrganizationAccessCached, getProjectAccessCached } from './access'; import { TRPCAccessError } from './errors'; export function createContext({ req, res }: CreateFastifyContextOptions) { @@ -45,7 +45,7 @@ const t = initTRPC.context().create({ }, }); -const enforceUserIsAuthed = t.middleware(async ({ ctx, next, input }) => { +const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => { if (!ctx.session?.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' }); } @@ -66,7 +66,7 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next, input }) => { }); // Only used on protected routes -const enforceProjectAccess = t.middleware(async ({ ctx, next, rawInput }) => { +const enforceAccess = t.middleware(async ({ ctx, next, rawInput }) => { if (has('projectId', rawInput)) { const access = await getProjectAccessCached({ userId: ctx.session.userId!, @@ -78,6 +78,28 @@ const enforceProjectAccess = t.middleware(async ({ ctx, next, rawInput }) => { } } + if (has('organizationId', rawInput)) { + const access = await getOrganizationAccessCached({ + userId: ctx.session.userId!, + organizationId: rawInput.organizationId as string, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this organization'); + } + } + + if (has('organizationSlug', rawInput)) { + const access = await getOrganizationAccessCached({ + userId: ctx.session.userId!, + organizationId: rawInput.organizationSlug as string, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this organization'); + } + } + return next(); }); @@ -86,4 +108,4 @@ export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure .use(enforceUserIsAuthed) - .use(enforceProjectAccess); + .use(enforceAccess);