From 5f52a63d4210ccf929d29784f90adf0dfe15f221 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 11:07:26 +0000 Subject: [PATCH] fix: enforce ci token creator access on secret fetch --- src/app/api/ci/file/route.ts | 26 +++++++- .../access/access-token-runtime.test.ts | 59 +++++++++++++++++++ src/server/access/access-token-runtime.ts | 12 ++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/server/access/access-token-runtime.test.ts create mode 100644 src/server/access/access-token-runtime.ts diff --git a/src/app/api/ci/file/route.ts b/src/app/api/ci/file/route.ts index acb69a5..b1b0e16 100644 --- a/src/app/api/ci/file/route.ts +++ b/src/app/api/ci/file/route.ts @@ -9,6 +9,7 @@ import { getBucket, } from "@/lib/paths"; import { getObjectBuffer } from "@/lib/s3"; +import { tokenCreatorHasCollectionAccess } from "@/server/access/access-token-runtime"; function parseBearer(req: NextRequest): string | null { const h = req.headers.get("authorization"); @@ -77,7 +78,12 @@ export async function GET(req: NextRequest) { const row = await prisma.accessToken.findUnique({ where: { tokenLookup }, - include: { collections: true }, + include: { + collections: true, + createdBy: { + select: { email: true }, + }, + }, }); if (!row) { @@ -86,7 +92,14 @@ export async function GET(req: NextRequest) { const collection = await prisma.collection.findUnique({ where: { slug }, - select: { id: true }, + select: { + id: true, + createdById: true, + accessGrants: { + where: { userId: row.createdById }, + select: { userId: true }, + }, + }, }); if (!collection) { @@ -97,6 +110,15 @@ export async function GET(req: NextRequest) { if (!allowed) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } + const creatorHasAccess = tokenCreatorHasCollectionAccess({ + creatorUserId: row.createdById, + creatorEmail: row.createdBy.email, + collectionCreatedById: collection.createdById, + hasDirectGrant: collection.accessGrants.length > 0, + }); + if (!creatorHasAccess) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } const objectKey = fullObjectKey(slug, relativePath); diff --git a/src/server/access/access-token-runtime.test.ts b/src/server/access/access-token-runtime.test.ts new file mode 100644 index 0000000..3878e68 --- /dev/null +++ b/src/server/access/access-token-runtime.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { tokenCreatorHasCollectionAccess } from "./access-token-runtime"; + +const PREV_OWNER_EMAILS = process.env.OWNER_EMAILS; + +describe("tokenCreatorHasCollectionAccess", () => { + beforeEach(() => { + process.env.OWNER_EMAILS = "owner@example.com"; + }); + + afterEach(() => { + if (PREV_OWNER_EMAILS === undefined) delete process.env.OWNER_EMAILS; + else process.env.OWNER_EMAILS = PREV_OWNER_EMAILS; + }); + + it("allows tokens created by owners", () => { + expect( + tokenCreatorHasCollectionAccess({ + creatorUserId: "user_a", + creatorEmail: "owner@example.com", + collectionCreatedById: "user_b", + hasDirectGrant: false, + }), + ).toBe(true); + }); + + it("allows tokens created by the collection creator", () => { + expect( + tokenCreatorHasCollectionAccess({ + creatorUserId: "user_a", + creatorEmail: "user@example.com", + collectionCreatedById: "user_a", + hasDirectGrant: false, + }), + ).toBe(true); + }); + + it("allows tokens while a direct grant exists", () => { + expect( + tokenCreatorHasCollectionAccess({ + creatorUserId: "user_a", + creatorEmail: "user@example.com", + collectionCreatedById: "user_b", + hasDirectGrant: true, + }), + ).toBe(true); + }); + + it("denies tokens after creator access is revoked", () => { + expect( + tokenCreatorHasCollectionAccess({ + creatorUserId: "user_a", + creatorEmail: "user@example.com", + collectionCreatedById: "user_b", + hasDirectGrant: false, + }), + ).toBe(false); + }); +}); diff --git a/src/server/access/access-token-runtime.ts b/src/server/access/access-token-runtime.ts new file mode 100644 index 0000000..a27e41e --- /dev/null +++ b/src/server/access/access-token-runtime.ts @@ -0,0 +1,12 @@ +import { isOwnerEmail } from "@/lib/owners"; + +export function tokenCreatorHasCollectionAccess(params: { + creatorUserId: string; + creatorEmail: string | null; + collectionCreatedById: string | null; + hasDirectGrant: boolean; +}): boolean { + if (isOwnerEmail(params.creatorEmail)) return true; + if (params.collectionCreatedById === params.creatorUserId) return true; + return params.hasDirectGrant; +}