From 2e97e2be5395e99563f18c10db35dcadd216ab64 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 22 May 2025 22:12:33 +0100 Subject: [PATCH] feat: assert user is member of workspace for board routes --- packages/api/src/routers/board.ts | 57 +++++++++++++++++++ packages/api/src/utils/auth.ts | 22 +++++++ packages/db/seed.sql | 60 -------------------- packages/db/src/repository/board.repo.ts | 16 ++++++ packages/db/src/repository/workspace.repo.ts | 20 +++++++ 5 files changed, 115 insertions(+), 60 deletions(-) create mode 100644 packages/api/src/utils/auth.ts delete mode 100644 packages/db/seed.sql diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index 8f19792f..5ce7f788 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -9,6 +9,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { generateSlug, generateUID } from "@kan/shared/utils"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { assertUserInWorkspace } from "../utils/auth"; export const boardRouter = createTRPCRouter({ all: protectedProcedure @@ -27,6 +28,14 @@ export const boardRouter = createTRPCRouter({ z.custom>>(), ) .query(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + const workspace = await workspaceRepo.getByPublicId( ctx.db, input.workspacePublicId, @@ -38,6 +47,8 @@ export const boardRouter = createTRPCRouter({ code: "NOT_FOUND", }); + await assertUserInWorkspace(ctx.db, userId, workspace.id); + const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id); return result; @@ -62,6 +73,27 @@ export const boardRouter = createTRPCRouter({ ) .output(z.custom>>()) .query(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const workspaceId = await boardRepo.getWorkspaceIdByBoardPublicId( + ctx.db, + input.boardPublicId, + ); + + if (!workspaceId) + throw new TRPCError({ + message: `Board with public ID ${input.boardPublicId} not found`, + code: "NOT_FOUND", + }); + + await assertUserInWorkspace(ctx.db, userId, workspaceId); + const result = await boardRepo.getByPublicId( ctx.db, input.boardPublicId, @@ -142,6 +174,8 @@ export const boardRouter = createTRPCRouter({ code: "NOT_FOUND", }); + await assertUserInWorkspace(ctx.db, userId, workspace.id); + let slug = generateSlug(input.name); const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, { @@ -193,6 +227,27 @@ export const boardRouter = createTRPCRouter({ ) .output(z.custom>>()) .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const workspaceId = await boardRepo.getWorkspaceIdByBoardPublicId( + ctx.db, + input.boardPublicId, + ); + + if (!workspaceId) + throw new TRPCError({ + message: `Board with public ID ${input.boardPublicId} not found`, + code: "NOT_FOUND", + }); + + await assertUserInWorkspace(ctx.db, userId, workspaceId); + const result = await boardRepo.update(ctx.db, { name: input.name, slug: input.slug, @@ -245,6 +300,8 @@ export const boardRouter = createTRPCRouter({ code: "NOT_FOUND", }); + await assertUserInWorkspace(ctx.db, userId, board.workspaceId); + const listIds = board.lists.map((list) => list.id); const deletedAt = new Date(); diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts new file mode 100644 index 00000000..4173c60b --- /dev/null +++ b/packages/api/src/utils/auth.ts @@ -0,0 +1,22 @@ +import { TRPCError } from "@trpc/server"; + +import type { dbClient } from "@kan/db/client"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; + +export async function assertUserInWorkspace( + db: dbClient, + userId: string, + workspaceId: number, +) { + const isMember = await workspaceRepo.isUserInWorkspace( + db, + userId, + workspaceId, + ); + + if (!isMember) + throw new TRPCError({ + message: `You do not have access to this workspace`, + code: "FORBIDDEN", + }); +} diff --git a/packages/db/seed.sql b/packages/db/seed.sql deleted file mode 100644 index c961732a..00000000 --- a/packages/db/seed.sql +++ /dev/null @@ -1,60 +0,0 @@ -CREATE OR REPLACE FUNCTION is_workspace_admin(user_id UUID, workspace_id BIGINT) -RETURNS BOOLEAN -LANGUAGE SQL -AS $$ - SELECT EXISTS ( - SELECT 1 - FROM workspace_members - WHERE "workspaceId" = workspace_id - AND "userId" = user_id - AND "role" = 'admin' - ); -$$; - -CREATE OR REPLACE FUNCTION is_workspace_member(user_id UUID, workspace_id BIGINT) -RETURNS BOOLEAN -LANGUAGE SQL -AS $$ - SELECT EXISTS ( - SELECT 1 - FROM workspace_members - WHERE "userId" = user_id AND "workspaceId" = workspace_id - ); -$$; - -/* BUCKETS */ -insert into storage.buckets - (id, name, public) -values - ('avatars', 'avatars', true); - -alter table storage.objects enable row level security; - -CREATE POLICY "Users can upload their own avatar" -ON storage.objects FOR INSERT -TO authenticated -WITH CHECK ( - bucket_id = 'avatars' AND - (storage.foldername(name))[1] = auth.uid()::text -); - -CREATE POLICY "Users can update their own avatar" -ON storage.objects FOR UPDATE -TO authenticated -USING ( - bucket_id = 'avatars' AND - (storage.foldername(name))[1] = auth.uid()::text -); - -CREATE POLICY "Users can delete their own avatar" -ON storage.objects FOR DELETE -TO authenticated -USING ( - bucket_id = 'avatars' AND - (storage.foldername(name))[1] = auth.uid()::text -); - -CREATE POLICY "Avatar images are publicly accessible" -ON storage.objects FOR SELECT -TO anon, authenticated -USING (bucket_id = 'avatars'); \ No newline at end of file diff --git a/packages/db/src/repository/board.repo.ts b/packages/db/src/repository/board.repo.ts index 9b420227..10266d18 100644 --- a/packages/db/src/repository/board.repo.ts +++ b/packages/db/src/repository/board.repo.ts @@ -311,6 +311,7 @@ export const getWithListIdsByPublicId = ( return db.query.boards.findFirst({ columns: { id: true, + workspaceId: true, }, with: { lists: { @@ -330,6 +331,7 @@ export const getWithLatestListIndexByPublicId = ( return db.query.boards.findFirst({ columns: { id: true, + workspaceId: true, }, with: { lists: { @@ -450,3 +452,17 @@ export const isSlugUnique = async ( return result === undefined; }; + +export const getWorkspaceIdByBoardPublicId = async ( + db: dbClient, + boardPublicId: string, +) => { + const result = await db.query.boards.findFirst({ + columns: { + workspaceId: true, + }, + where: eq(boards.publicId, boardPublicId), + }); + + return result?.workspaceId; +}; diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index d409a93a..fe091c78 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -220,3 +220,23 @@ export const isWorkspaceSlugAvailable = async ( return result === undefined; }; + +export const isUserInWorkspace = async ( + db: dbClient, + userId: string, + workspaceId: number, +) => { + const result = await db.query.workspaceMembers.findFirst({ + columns: { + id: true, + }, + where: and( + eq(workspaceMembers.userId, userId), + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.status, "active"), + isNull(workspaceMembers.deletedAt), + ), + }); + + return result?.id !== undefined; +};