Skip to content

Commit 2f0b713

Browse files
committed
fix(web): require an explicit share for org video downloads
1 parent 74137d5 commit 2f0b713

4 files changed

Lines changed: 136 additions & 18 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { Organisation, User, Video } from "@cap/web-domain";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const schema = {
5+
organizationMembers: { table: "organizationMembers" },
6+
sharedVideos: { table: "sharedVideos" },
7+
spaceMembers: { table: "spaceMembers" },
8+
spaceVideos: { table: "spaceVideos" },
9+
};
10+
11+
vi.mock("@cap/database/schema", () => schema);
12+
13+
// Each select() call resolves against the next queued result, and the table it
14+
// read is recorded so a test can assert which lookups actually happened.
15+
let queued: unknown[][] = [];
16+
const tablesRead: string[] = [];
17+
18+
const mockDb = {
19+
select: () => mockDb,
20+
from: (table: { table: string }) => {
21+
tablesRead.push(table.table);
22+
return mockDb;
23+
},
24+
where: () => {
25+
const rows = queued.shift() ?? [];
26+
const result = Promise.resolve(rows) as Promise<unknown[]> & {
27+
limit: () => Promise<unknown[]>;
28+
};
29+
result.limit = () => Promise.resolve(rows);
30+
return result;
31+
},
32+
};
33+
34+
vi.mock("@cap/database", () => ({ db: () => mockDb }));
35+
36+
const { canUserDownloadVideo } = await import(
37+
"../../lib/video-download-permissions"
38+
);
39+
40+
const OWNER = "user-owner" as User.UserId;
41+
const OTHER = "user-other" as User.UserId;
42+
const VIDEO = "video-1" as Video.VideoId;
43+
const VIDEO_ORG = "org-owning-the-video" as Organisation.OrganisationId;
44+
45+
function call(userId: User.UserId) {
46+
return canUserDownloadVideo({ userId, ownerId: OWNER, videoId: VIDEO });
47+
}
48+
49+
describe("canUserDownloadVideo", () => {
50+
beforeEach(() => {
51+
queued = [];
52+
tablesRead.length = 0;
53+
});
54+
55+
it("allows the owner without querying shares", async () => {
56+
expect(await call(OWNER)).toBe(true);
57+
expect(tablesRead).toEqual([]);
58+
});
59+
60+
// The video's own orgId must not grant download access: VideosPolicy.canView
61+
// requires an explicit sharedVideos row, and no creation path writes one, so
62+
// trusting orgId let org colleagues download videos they cannot open.
63+
it("denies an org colleague when the video was never explicitly shared", async () => {
64+
queued = [
65+
[], // sharedVideos: no explicit org share
66+
[], // spaceVideos: no space share
67+
];
68+
69+
expect(await call(OTHER)).toBe(false);
70+
expect(tablesRead).toContain("sharedVideos");
71+
expect(tablesRead).not.toContain("organizationMembers");
72+
});
73+
74+
it("allows a member of an org the video was explicitly shared with", async () => {
75+
queued = [
76+
[{ organizationId: VIDEO_ORG }], // sharedVideos
77+
[{ id: "membership-1" }], // organizationMembers
78+
];
79+
80+
expect(await call(OTHER)).toBe(true);
81+
expect(tablesRead).toEqual(["sharedVideos", "organizationMembers"]);
82+
});
83+
84+
it("denies a non-member even when the video is shared with some org", async () => {
85+
queued = [
86+
[{ organizationId: VIDEO_ORG }], // sharedVideos
87+
[], // organizationMembers: not a member
88+
[], // spaceVideos
89+
];
90+
91+
expect(await call(OTHER)).toBe(false);
92+
});
93+
94+
it("allows a member of a space the video was shared into", async () => {
95+
queued = [
96+
[], // sharedVideos
97+
[{ spaceId: "space-1" }], // spaceVideos
98+
[{ id: "space-membership-1" }], // spaceMembers
99+
];
100+
101+
expect(await call(OTHER)).toBe(true);
102+
expect(tablesRead).toContain("spaceMembers");
103+
});
104+
105+
it("denies a non-member of the space the video was shared into", async () => {
106+
queued = [
107+
[], // sharedVideos
108+
[{ spaceId: "space-1" }], // spaceVideos
109+
[], // spaceMembers
110+
];
111+
112+
expect(await call(OTHER)).toBe(false);
113+
});
114+
});

apps/web/actions/videos/download.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ export async function getVideoDownloadInfo(
9090
userId: user.id,
9191
ownerId: video.ownerId,
9292
videoId,
93-
orgId: video.orgId,
9493
});
9594

9695
if (!allowed) {

apps/web/app/s/[videoId]/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,6 @@ async function AuthorizedContent({
801801
userId,
802802
ownerId: video.owner.id,
803803
videoId,
804-
orgId: video.orgId,
805804
})
806805
: false;
807806

apps/web/lib/video-download-permissions.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,22 @@ import {
55
spaceMembers,
66
spaceVideos,
77
} from "@cap/database/schema";
8-
import type { Organisation, User, Video } from "@cap/web-domain";
8+
import type { User, Video } from "@cap/web-domain";
99
import { and, eq, inArray } from "drizzle-orm";
1010

11+
// Download access must not be broader than view access. VideosPolicy.canView
12+
// grants org members access only through an explicit sharedVideos row (see
13+
// OrganisationsRepo.membershipForVideo), and no video-creation path writes one,
14+
// so trusting the video's own orgId here let colleagues download recordings
15+
// they cannot open.
1116
export async function canUserDownloadVideo({
1217
userId,
1318
ownerId,
1419
videoId,
15-
orgId,
1620
}: {
1721
userId: User.UserId;
1822
ownerId: User.UserId;
1923
videoId: Video.VideoId;
20-
orgId: Organisation.OrganisationId;
2124
}): Promise<boolean> {
2225
if (userId === ownerId) return true;
2326

@@ -26,20 +29,23 @@ export async function canUserDownloadVideo({
2629
.from(sharedVideos)
2730
.where(eq(sharedVideos.videoId, videoId));
2831

29-
const orgIds = [orgId, ...sharedOrgs.map((org) => org.organizationId)];
30-
31-
const [orgMembership] = await db()
32-
.select({ id: organizationMembers.id })
33-
.from(organizationMembers)
34-
.where(
35-
and(
36-
eq(organizationMembers.userId, userId),
37-
inArray(organizationMembers.organizationId, orgIds),
38-
),
39-
)
40-
.limit(1);
32+
if (sharedOrgs.length > 0) {
33+
const [orgMembership] = await db()
34+
.select({ id: organizationMembers.id })
35+
.from(organizationMembers)
36+
.where(
37+
and(
38+
eq(organizationMembers.userId, userId),
39+
inArray(
40+
organizationMembers.organizationId,
41+
sharedOrgs.map((org) => org.organizationId),
42+
),
43+
),
44+
)
45+
.limit(1);
4146

42-
if (orgMembership) return true;
47+
if (orgMembership) return true;
48+
}
4349

4450
const sharedSpaces = await db()
4551
.select({ spaceId: spaceVideos.spaceId })

0 commit comments

Comments
 (0)