Summary
canUserDownloadVideo() in apps/web/lib/video-download-permissions.ts treats the video's own organization as if it were a share target, so every member of the owner's org can download any video that org owns, whether or not it was ever shared with them.
const sharedOrgs = await db()
.select({ organizationId: sharedVideos.organizationId })
.from(sharedVideos)
.where(eq(sharedVideos.videoId, videoId));
const orgIds = [orgId, ...sharedOrgs.map((org) => org.organizationId)];
// ^^^^^ the owner's org, unconditionally
orgId comes straight from video.orgId at the call site in apps/web/actions/videos/download.ts:
const allowed = await canUserDownloadVideo({
userId: user.id,
ownerId: video.ownerId,
videoId,
orgId: video.orgId,
});
video.orgId is the organization the video belongs to, not an organization it was shared with. Mixing it into the same list as the sharedVideos rows means membership of the owner's org is sufficient, and the sharedVideos lookup below it becomes redundant for that org.
Why this looks unintended rather than by design
The view policy right next door requires an explicit share. buildCanView in packages/web-backend/src/Videos/VideosPolicy.ts uses orgsRepo.membershipForVideo, which inner-joins through sharedVideos:
db.select({ membershipId: Db.organizationMembers.id })
.from(Db.organizationMembers)
.leftJoin(Db.sharedVideos, Dz.eq(Db.organizationMembers.organizationId, Db.sharedVideos.organizationId))
.where(Dz.and(
Dz.eq(Db.organizationMembers.userId, userId),
Dz.eq(Db.sharedVideos.videoId, videoId), // <- the share row must exist
))
and logs "Explicit org/space membership found. Access granted." when it matches. video.orgId is used in that policy only to look up the allowed email domain, never as a grant.
So the two paths disagree: a colleague in the owner's org who cannot view an unshared private video can still download it, because the download check never consults sharedVideos for that org — it short-circuits on orgIds containing video.orgId.
The space branch in the same function is written the stricter way, which is what makes the org branch look like an oversight: it derives space ids purely from spaceVideos rows for this video, and returns false when there are none.
Expected
Download permission is granted by the same rule as view permission: ownership, an explicit sharedVideos row for an org the user belongs to, or an explicit spaceVideos row for a space the user belongs to.
Reproduction (by inspection of the query, not run against a live DB)
- User A and user B are both members of org O.
- A records a private video; it is stored with
orgId = O and is never shared to any org or space.
- B calls
getVideoDownloadInfo(videoId).
canUserDownloadVideo builds orgIds = [O], finds B's organizationMembers row for O, and returns true.
- The same B loading the video page is refused by
buildCanView, which finds no sharedVideos row.
I want to be straight about the limits of my evidence here: I traced this by reading the two queries and the call site, and I do not have a running Cap instance with a seeded database to demonstrate the divergence end to end. If video.orgId is intended to be a grant and the view policy is the stricter one on purpose, then this is a documentation gap rather than a bug and I would rather be told so than have it merged. video-download-permissions.ts currently has no test file, so nothing pins either interpretation.
I have a fix ready and will open a PR referencing this issue.
Summary
canUserDownloadVideo()inapps/web/lib/video-download-permissions.tstreats the video's own organization as if it were a share target, so every member of the owner's org can download any video that org owns, whether or not it was ever shared with them.orgIdcomes straight fromvideo.orgIdat the call site inapps/web/actions/videos/download.ts:video.orgIdis the organization the video belongs to, not an organization it was shared with. Mixing it into the same list as thesharedVideosrows means membership of the owner's org is sufficient, and thesharedVideoslookup below it becomes redundant for that org.Why this looks unintended rather than by design
The view policy right next door requires an explicit share.
buildCanViewinpackages/web-backend/src/Videos/VideosPolicy.tsusesorgsRepo.membershipForVideo, which inner-joins throughsharedVideos:and logs
"Explicit org/space membership found. Access granted."when it matches.video.orgIdis used in that policy only to look up the allowed email domain, never as a grant.So the two paths disagree: a colleague in the owner's org who cannot view an unshared private video can still download it, because the download check never consults
sharedVideosfor that org — it short-circuits onorgIdscontainingvideo.orgId.The space branch in the same function is written the stricter way, which is what makes the org branch look like an oversight: it derives space ids purely from
spaceVideosrows for this video, and returnsfalsewhen there are none.Expected
Download permission is granted by the same rule as view permission: ownership, an explicit
sharedVideosrow for an org the user belongs to, or an explicitspaceVideosrow for a space the user belongs to.Reproduction (by inspection of the query, not run against a live DB)
orgId = Oand is never shared to any org or space.getVideoDownloadInfo(videoId).canUserDownloadVideobuildsorgIds = [O], finds B'sorganizationMembersrow for O, and returnstrue.buildCanView, which finds nosharedVideosrow.I want to be straight about the limits of my evidence here: I traced this by reading the two queries and the call site, and I do not have a running Cap instance with a seeded database to demonstrate the divergence end to end. If
video.orgIdis intended to be a grant and the view policy is the stricter one on purpose, then this is a documentation gap rather than a bug and I would rather be told so than have it merged.video-download-permissions.tscurrently has no test file, so nothing pins either interpretation.I have a fix ready and will open a PR referencing this issue.