From d2348b1647b15592bf5f8b3811850c427e88aa20 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:47:05 +0100 Subject: [PATCH 1/3] fix(web): add ownership checks to folder, space and org video actions --- apps/web/actions/folders/add-videos.ts | 21 +++++++++- apps/web/actions/folders/get-folder-videos.ts | 39 ++++++++++++++++++- apps/web/actions/folders/moveVideoToFolder.ts | 14 ++++++- .../organizations/get-organization-videos.ts | 7 ++++ apps/web/actions/spaces/get-space-videos.ts | 21 ++++++++++ .../app/(org)/dashboard/folder/[id]/page.tsx | 19 +++++++++ 6 files changed, 118 insertions(+), 3 deletions(-) diff --git a/apps/web/actions/folders/add-videos.ts b/apps/web/actions/folders/add-videos.ts index 817da61914d..debaeaa41b2 100644 --- a/apps/web/actions/folders/add-videos.ts +++ b/apps/web/actions/folders/add-videos.ts @@ -12,6 +12,7 @@ import { import type { Folder, Space, Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function addVideosToFolder( folderId: Folder.FolderId, @@ -30,7 +31,11 @@ export async function addVideosToFolder( } const [folder] = await db() - .select({ id: folders.id, spaceId: folders.spaceId }) + .select({ + id: folders.id, + spaceId: folders.spaceId, + createdById: folders.createdById, + }) .from(folders) .where(eq(folders.id, folderId)); @@ -38,6 +43,20 @@ export async function addVideosToFolder( throw new Error("Folder not found"); } + // Verify the caller can edit the destination folder. Mirrors + // FoldersPolicy.canEdit: personal folders (no space) are creator-only, + // space folders require space-admin or org admin/owner access. + if (folder.spaceId === null) { + if (folder.createdById !== user.id) { + throw new Error("You don't have permission to manage this folder"); + } + } else { + const access = await getSpaceAccess(user.id, folder.spaceId); + if (!access?.canManage) { + throw new Error("You don't have permission to manage this folder"); + } + } + const userVideos = await db() .select({ id: videos.id }) .from(videos) diff --git a/apps/web/actions/folders/get-folder-videos.ts b/apps/web/actions/folders/get-folder-videos.ts index 704f6aeb224..84e02b019aa 100644 --- a/apps/web/actions/folders/get-folder-videos.ts +++ b/apps/web/actions/folders/get-folder-videos.ts @@ -2,9 +2,11 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { sharedVideos, spaceVideos } from "@cap/database/schema"; +import { folders, sharedVideos, spaceVideos } from "@cap/database/schema"; import type { Folder, Space, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function getFolderVideoIds( folderId: Folder.FolderId, @@ -21,6 +23,41 @@ export async function getFolderVideoIds( throw new Error("Folder ID is required"); } + // Ensure the caller can see this folder before disclosing its contents. + const [folder] = await db() + .select({ + spaceId: folders.spaceId, + organizationId: folders.organizationId, + createdById: folders.createdById, + }) + .from(folders) + .where(eq(folders.id, folderId)); + + if (!folder) { + throw new Error("Folder not found"); + } + + if (folder.spaceId === null) { + const access = await getOrganizationAccess( + user.id, + folder.organizationId, + ); + if (!access && folder.createdById !== user.id) { + throw new Error("Folder not found"); + } + } else { + // getSpaceAccess returns a non-null object even for non-members (with + // both roles null), so a bare `!access` check would NOT block them. + // Require an actual org or space role to view the folder's contents. + const access = await getSpaceAccess(user.id, folder.spaceId); + if ( + !access || + (access.organizationRole === null && access.spaceRole === null) + ) { + throw new Error("Folder not found"); + } + } + const isAllSpacesEntry = user.activeOrganizationId === spaceId; const rows = isAllSpacesEntry diff --git a/apps/web/actions/folders/moveVideoToFolder.ts b/apps/web/actions/folders/moveVideoToFolder.ts index 59d8de51d05..47424b72ebf 100644 --- a/apps/web/actions/folders/moveVideoToFolder.ts +++ b/apps/web/actions/folders/moveVideoToFolder.ts @@ -11,6 +11,8 @@ import { import type { Folder, Space, Video } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function moveVideoToFolder({ videoId, folderId, @@ -54,6 +56,11 @@ export async function moveVideoToFolder({ } if (spaceId && !isAllSpacesEntry) { + const access = await getSpaceAccess(user.id, spaceId); + if (!access?.canManage) { + throw new Error("You don't have permission to manage this space"); + } + await db() .update(spaceVideos) .set({ @@ -63,6 +70,11 @@ export async function moveVideoToFolder({ and(eq(spaceVideos.videoId, videoId), eq(spaceVideos.spaceId, spaceId)), ); } else if (spaceId && isAllSpacesEntry) { + await requireOrganizationSettingsManager( + user.id, + user.activeOrganizationId, + ); + await db() .update(sharedVideos) .set({ @@ -80,7 +92,7 @@ export async function moveVideoToFolder({ .set({ folderId: folderId === null ? null : folderId, }) - .where(eq(videos.id, videoId)); + .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); } // Always revalidate the main caps page diff --git a/apps/web/actions/organizations/get-organization-videos.ts b/apps/web/actions/organizations/get-organization-videos.ts index 2e584388f4c..39ead8c4b2c 100644 --- a/apps/web/actions/organizations/get-organization-videos.ts +++ b/apps/web/actions/organizations/get-organization-videos.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sharedVideos } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { and, eq, isNull } from "drizzle-orm"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; export async function getOrganizationVideoIds( organizationId: Organisation.OrganisationId, @@ -20,6 +21,12 @@ export async function getOrganizationVideoIds( throw new Error("Organization ID is required"); } + // Only members/owner of the organization may see its shared videos. + const access = await getOrganizationAccess(user.id, organizationId); + if (!access) { + throw new Error("Organization not found"); + } + const videoIds = await db() .select({ videoId: sharedVideos.videoId, diff --git a/apps/web/actions/spaces/get-space-videos.ts b/apps/web/actions/spaces/get-space-videos.ts index ddbcc42b823..5c3581907e1 100644 --- a/apps/web/actions/spaces/get-space-videos.ts +++ b/apps/web/actions/spaces/get-space-videos.ts @@ -5,6 +5,8 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sharedVideos, spaceVideos } from "@cap/database/schema"; import type { Space } from "@cap/web-domain"; import { and, eq, isNull } from "drizzle-orm"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function getSpaceVideoIds(spaceId: Space.SpaceIdOrOrganisationId) { try { @@ -20,6 +22,25 @@ export async function getSpaceVideoIds(spaceId: Space.SpaceIdOrOrganisationId) { const isAllSpacesEntry = user.activeOrganizationId === spaceId; + // Only members/owner of the space (or organization) may see its videos. + if (isAllSpacesEntry) { + const access = await getOrganizationAccess(user.id, spaceId); + if (!access) { + throw new Error("Space not found"); + } + } else { + // getSpaceAccess returns a non-null object even for non-members (with + // both roles null), so a bare `!access` check would NOT block them. + // Require an actual org or space role to view the space's videos. + const access = await getSpaceAccess(user.id, spaceId); + if ( + !access || + (access.organizationRole === null && access.spaceRole === null) + ) { + throw new Error("Space not found"); + } + } + const videoIds = isAllSpacesEntry ? await db() .select({ diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index 2495073044f..0601cc08cd7 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -4,6 +4,7 @@ import { makeCurrentUserLayer } from "@cap/web-backend"; import { Folder } from "@cap/web-domain"; import { Effect } from "effect"; import { notFound } from "next/navigation"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; import { getChildFolders, getFolderBreadcrumb, @@ -31,6 +32,24 @@ const FolderPage = async (props: PageProps<"/dashboard/folder/[id]">) => { const user = await getCurrentUser(); if (!user || !user.activeOrganizationId) return notFound(); + // Ensure the folder belongs to a space/org the caller can access before + // disclosing its contents (mirrors FoldersPolicy: personal folders are + // creator-only, space/org folders require org membership/ownership). + const folderForAccess = await getFolderById(folderId).pipe( + Effect.provide(makeCurrentUserLayer(user)), + runPromise, + ); + + if (folderForAccess.spaceId === null) { + if (folderForAccess.createdById !== user.id) return notFound(); + } else { + const access = await getOrganizationAccess( + user.id, + folderForAccess.organizationId, + ); + if (!access) return notFound(); + } + return Effect.gen(function* () { const [childFolders, breadcrumb, videosData, share] = yield* Effect.all( [ From 97c3abc7e7ae158e2bec76d6ae28bfdb94843857 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:46:09 +0100 Subject: [PATCH 2/3] fix(web): tighten folder access checks for personal and space folders --- apps/web/actions/folders/add-videos.ts | 4 +-- apps/web/actions/folders/get-folder-videos.ts | 10 +++----- apps/web/actions/folders/moveVideoToFolder.ts | 1 + .../app/(org)/dashboard/folder/[id]/page.tsx | 25 +++++++++++++------ 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/web/actions/folders/add-videos.ts b/apps/web/actions/folders/add-videos.ts index debaeaa41b2..2bd4b2b6d41 100644 --- a/apps/web/actions/folders/add-videos.ts +++ b/apps/web/actions/folders/add-videos.ts @@ -48,12 +48,12 @@ export async function addVideosToFolder( // space folders require space-admin or org admin/owner access. if (folder.spaceId === null) { if (folder.createdById !== user.id) { - throw new Error("You don't have permission to manage this folder"); + throw new Error("Folder not found"); } } else { const access = await getSpaceAccess(user.id, folder.spaceId); if (!access?.canManage) { - throw new Error("You don't have permission to manage this folder"); + throw new Error("Folder not found"); } } diff --git a/apps/web/actions/folders/get-folder-videos.ts b/apps/web/actions/folders/get-folder-videos.ts index 84e02b019aa..ad7881147a2 100644 --- a/apps/web/actions/folders/get-folder-videos.ts +++ b/apps/web/actions/folders/get-folder-videos.ts @@ -5,7 +5,6 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { folders, sharedVideos, spaceVideos } from "@cap/database/schema"; import type { Folder, Space, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { getOrganizationAccess } from "@/actions/organization/authorization"; import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function getFolderVideoIds( @@ -38,11 +37,10 @@ export async function getFolderVideoIds( } if (folder.spaceId === null) { - const access = await getOrganizationAccess( - user.id, - folder.organizationId, - ); - if (!access && folder.createdById !== user.id) { + // Personal folders are creator-only (mirrors FoldersPolicy.canEdit and + // add-videos.ts); org membership must NOT grant access to another user's + // personal folder. + if (folder.createdById !== user.id) { throw new Error("Folder not found"); } } else { diff --git a/apps/web/actions/folders/moveVideoToFolder.ts b/apps/web/actions/folders/moveVideoToFolder.ts index 47424b72ebf..8dbcd46ca5c 100644 --- a/apps/web/actions/folders/moveVideoToFolder.ts +++ b/apps/web/actions/folders/moveVideoToFolder.ts @@ -13,6 +13,7 @@ import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requireOrganizationSettingsManager } from "@/actions/organization/authorization"; import { getSpaceAccess } from "@/actions/organization/space-authorization"; + export async function moveVideoToFolder({ videoId, folderId, diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index 0601cc08cd7..5b8d4265537 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -4,7 +4,7 @@ import { makeCurrentUserLayer } from "@cap/web-backend"; import { Folder } from "@cap/web-domain"; import { Effect } from "effect"; import { notFound } from "next/navigation"; -import { getOrganizationAccess } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; import { getChildFolders, getFolderBreadcrumb, @@ -32,22 +32,31 @@ const FolderPage = async (props: PageProps<"/dashboard/folder/[id]">) => { const user = await getCurrentUser(); if (!user || !user.activeOrganizationId) return notFound(); - // Ensure the folder belongs to a space/org the caller can access before + // Ensure the folder belongs to a space the caller can access before // disclosing its contents (mirrors FoldersPolicy: personal folders are - // creator-only, space/org folders require org membership/ownership). + // creator-only, space folders require space/org membership). A missing folder + // surfaces as notFound() rather than an unhandled 500. const folderForAccess = await getFolderById(folderId).pipe( Effect.provide(makeCurrentUserLayer(user)), + Effect.catchAll(() => Effect.succeed(null)), runPromise, ); + if (!folderForAccess) return notFound(); + if (folderForAccess.spaceId === null) { if (folderForAccess.createdById !== user.id) return notFound(); } else { - const access = await getOrganizationAccess( - user.id, - folderForAccess.organizationId, - ); - if (!access) return notFound(); + // getSpaceAccess returns a non-null object even for non-members (both roles + // null), so check for an actual role. Using space access (not org-only) + // keeps legitimate space members who aren't org members from being blocked. + const access = await getSpaceAccess(user.id, folderForAccess.spaceId); + if ( + !access || + (access.organizationRole === null && access.spaceRole === null) + ) { + return notFound(); + } } return Effect.gen(function* () { From a27d0ccbcc46ac381b84af87cb34ae94e7e33cb8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:53:13 +0100 Subject: [PATCH 3/3] fix(web): validate destination folder matches move type in moveVideoToFolder --- apps/web/actions/folders/moveVideoToFolder.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/web/actions/folders/moveVideoToFolder.ts b/apps/web/actions/folders/moveVideoToFolder.ts index 8dbcd46ca5c..861f407520d 100644 --- a/apps/web/actions/folders/moveVideoToFolder.ts +++ b/apps/web/actions/folders/moveVideoToFolder.ts @@ -39,10 +39,18 @@ export async function moveVideoToFolder({ const isAllSpacesEntry = spaceId === user.activeOrganizationId; - // If folderId is provided, verify it exists and belongs to the same organization + // If a destination folder is provided, load it once (scoped to the caller's + // org) so each branch can also verify the caller may WRITE to that specific + // folder — not just that the source space/folder is manageable. + let destinationFolder: + | { spaceId: string | null; createdById: string } + | undefined; if (folderId) { - const [folder] = await db() - .select() + [destinationFolder] = await db() + .select({ + spaceId: folders.spaceId, + createdById: folders.createdById, + }) .from(folders) .where( and( @@ -51,7 +59,7 @@ export async function moveVideoToFolder({ ), ); - if (!folder) { + if (!destinationFolder) { throw new Error("Folder not found or not accessible"); } } @@ -62,6 +70,11 @@ export async function moveVideoToFolder({ throw new Error("You don't have permission to manage this space"); } + // The destination folder must belong to the same space being managed. + if (destinationFolder && destinationFolder.spaceId !== spaceId) { + throw new Error("Folder not found or not accessible"); + } + await db() .update(spaceVideos) .set({ @@ -76,6 +89,11 @@ export async function moveVideoToFolder({ user.activeOrganizationId, ); + // The destination must be an org-level (non-space) folder. + if (destinationFolder && destinationFolder.spaceId !== null) { + throw new Error("Folder not found or not accessible"); + } + await db() .update(sharedVideos) .set({ @@ -88,6 +106,15 @@ export async function moveVideoToFolder({ ), ); } else { + // Personal move: the destination must be the caller's own personal folder. + if ( + destinationFolder && + (destinationFolder.spaceId !== null || + destinationFolder.createdById !== user.id) + ) { + throw new Error("Folder not found or not accessible"); + } + await db() .update(videos) .set({