Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion apps/web/actions/folders/add-videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,14 +31,32 @@ 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));
Comment on lines 33 to 40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security The folder lookup is not scoped to user.activeOrganizationId. In moveVideoToFolder.ts the equivalent query correctly adds eq(folders.organizationId, user.activeOrganizationId) to the WHERE clause. Without it, a caller who created a personal folder in a different organisation (or holds canManage over a space in another org) can pass that foreign folderId, pass the ownership check, and the subsequent inserts will write sharedVideos/spaceVideos rows for the current org that carry a folderId pointing to another org's folder — corrupting cross-org FK references.

Suggested change
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));
const [folder] = await db()
.select({
id: folders.id,
spaceId: folders.spaceId,
createdById: folders.createdById,
})
.from(folders)
.where(
and(
eq(folders.id, folderId),
eq(folders.organizationId, user.activeOrganizationId),
),
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/folders/add-videos.ts
Line: 33-40

Comment:
The folder lookup is not scoped to `user.activeOrganizationId`. In `moveVideoToFolder.ts` the equivalent query correctly adds `eq(folders.organizationId, user.activeOrganizationId)` to the WHERE clause. Without it, a caller who created a personal folder in a different organisation (or holds `canManage` over a space in another org) can pass that foreign `folderId`, pass the ownership check, and the subsequent inserts will write `sharedVideos`/`spaceVideos` rows for the current org that carry a `folderId` pointing to another org's folder — corrupting cross-org FK references.

```suggestion
		const [folder] = await db()
			.select({
				id: folders.id,
				spaceId: folders.spaceId,
				createdById: folders.createdById,
			})
			.from(folders)
			.where(
				and(
					eq(folders.id, folderId),
					eq(folders.organizationId, user.activeOrganizationId),
				),
			);
```

How can I resolve this? If you propose a fix, please make it concise.


if (!folder) {
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("Folder not found");
}
} else {
const access = await getSpaceAccess(user.id, folder.spaceId);
if (!access?.canManage) {
throw new Error("Folder not found");
}
}

const userVideos = await db()
.select({ id: videos.id })
.from(videos)
Expand Down
37 changes: 36 additions & 1 deletion apps/web/actions/folders/get-folder-videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

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 { getSpaceAccess } from "@/actions/organization/space-authorization";

export async function getFolderVideoIds(
folderId: Folder.FolderId,
Expand All @@ -21,6 +22,40 @@ 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) {
Comment thread
richiemcilroy marked this conversation as resolved.
// 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 {
Comment thread
richiemcilroy marked this conversation as resolved.
// 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
Expand Down
50 changes: 45 additions & 5 deletions apps/web/actions/folders/moveVideoToFolder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ 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";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goal is to allow regular org members to move their own shared videos from the “All Spaces” view, this import can switch to getOrganizationAccess (and drop the settings-manager requirement below).

Suggested change
import { requireOrganizationSettingsManager } from "@/actions/organization/authorization";
import { getOrganizationAccess } from "@/actions/organization/authorization";

import { getSpaceAccess } from "@/actions/organization/space-authorization";

export async function moveVideoToFolder({
Comment thread
richiemcilroy marked this conversation as resolved.
videoId,
folderId,
Expand All @@ -36,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(
Expand All @@ -48,12 +59,22 @@ export async function moveVideoToFolder({
),
);

if (!folder) {
if (!destinationFolder) {
throw new Error("Folder not found or not accessible");
}
}

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");
}

// 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()
Comment thread
richiemcilroy marked this conversation as resolved.
.update(spaceVideos)
.set({
Expand All @@ -63,6 +84,16 @@ export async function moveVideoToFolder({
and(eq(spaceVideos.videoId, videoId), eq(spaceVideos.spaceId, spaceId)),
);
} else if (spaceId && isAllSpacesEntry) {
await requireOrganizationSettingsManager(
user.id,
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({
Expand All @@ -75,12 +106,21 @@ export async function moveVideoToFolder({
),
);
Comment on lines 86 to 107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requireOrganizationSettingsManager makes this path admin/owner-only, but the underlying UPDATE currently isn’t scoped to the caller’s own videos. That combination blocks normal members and still lets admins move anyone’s shared videos.

Consider using org membership + scoping the UPDATE to sharedByUserId so members can only move videos they shared.

Suggested change
} else if (spaceId && isAllSpacesEntry) {
const access = await getOrganizationAccess(user.id, user.activeOrganizationId);
if (!access) {
throw new Error("Organization not found");
}
// 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({
folderId: folderId === null ? null : folderId,
})
.where(
and(
eq(sharedVideos.videoId, videoId),
eq(sharedVideos.organizationId, user.activeOrganizationId),
eq(sharedVideos.sharedByUserId, user.id),
),
);

} 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({
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
Expand Down
7 changes: 7 additions & 0 deletions apps/web/actions/organizations/get-organization-videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions apps/web/actions/spaces/get-space-videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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({
Expand Down
28 changes: 28 additions & 0 deletions apps/web/app/(org)/dashboard/folder/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { getSpaceAccess } from "@/actions/organization/space-authorization";
import {
getChildFolders,
getFolderBreadcrumb,
Expand Down Expand Up @@ -31,6 +32,33 @@ 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 the caller can access before
// disclosing its contents (mirrors FoldersPolicy: personal folders are
// 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,
);
Comment thread
richiemcilroy marked this conversation as resolved.

if (!folderForAccess) return notFound();

if (folderForAccess.spaceId === null) {
if (folderForAccess.createdById !== user.id) return notFound();
} else {
// 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* () {
const [childFolders, breadcrumb, videosData, share] = yield* Effect.all(
[
Expand Down
Loading