Skip to content

Commit d5a690f

Browse files
fix: enforce ci token creator access on secret fetch (#14)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent af0c5c6 commit d5a690f

3 files changed

Lines changed: 95 additions & 2 deletions

File tree

src/app/api/ci/file/route.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getBucket,
1010
} from "@/lib/paths";
1111
import { getObjectBuffer } from "@/lib/s3";
12+
import { tokenCreatorHasCollectionAccess } from "@/server/access/access-token-runtime";
1213

1314
function parseBearer(req: NextRequest): string | null {
1415
const h = req.headers.get("authorization");
@@ -77,7 +78,12 @@ export async function GET(req: NextRequest) {
7778

7879
const row = await prisma.accessToken.findUnique({
7980
where: { tokenLookup },
80-
include: { collections: true },
81+
include: {
82+
collections: true,
83+
createdBy: {
84+
select: { email: true },
85+
},
86+
},
8187
});
8288

8389
if (!row) {
@@ -86,7 +92,14 @@ export async function GET(req: NextRequest) {
8692

8793
const collection = await prisma.collection.findUnique({
8894
where: { slug },
89-
select: { id: true },
95+
select: {
96+
id: true,
97+
createdById: true,
98+
accessGrants: {
99+
where: { userId: row.createdById },
100+
select: { userId: true },
101+
},
102+
},
90103
});
91104

92105
if (!collection) {
@@ -97,6 +110,15 @@ export async function GET(req: NextRequest) {
97110
if (!allowed) {
98111
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
99112
}
113+
const creatorHasAccess = tokenCreatorHasCollectionAccess({
114+
creatorUserId: row.createdById,
115+
creatorEmail: row.createdBy.email,
116+
collectionCreatedById: collection.createdById,
117+
hasDirectGrant: collection.accessGrants.length > 0,
118+
});
119+
if (!creatorHasAccess) {
120+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
121+
}
100122

101123
const objectKey = fullObjectKey(slug, relativePath);
102124

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import { tokenCreatorHasCollectionAccess } from "./access-token-runtime";
3+
4+
const PREV_OWNER_EMAILS = process.env.OWNER_EMAILS;
5+
6+
describe("tokenCreatorHasCollectionAccess", () => {
7+
beforeEach(() => {
8+
process.env.OWNER_EMAILS = "owner@example.com";
9+
});
10+
11+
afterEach(() => {
12+
if (PREV_OWNER_EMAILS === undefined) delete process.env.OWNER_EMAILS;
13+
else process.env.OWNER_EMAILS = PREV_OWNER_EMAILS;
14+
});
15+
16+
it("allows tokens created by owners", () => {
17+
expect(
18+
tokenCreatorHasCollectionAccess({
19+
creatorUserId: "user_a",
20+
creatorEmail: "owner@example.com",
21+
collectionCreatedById: "user_b",
22+
hasDirectGrant: false,
23+
}),
24+
).toBe(true);
25+
});
26+
27+
it("allows tokens created by the collection creator", () => {
28+
expect(
29+
tokenCreatorHasCollectionAccess({
30+
creatorUserId: "user_a",
31+
creatorEmail: "user@example.com",
32+
collectionCreatedById: "user_a",
33+
hasDirectGrant: false,
34+
}),
35+
).toBe(true);
36+
});
37+
38+
it("allows tokens while a direct grant exists", () => {
39+
expect(
40+
tokenCreatorHasCollectionAccess({
41+
creatorUserId: "user_a",
42+
creatorEmail: "user@example.com",
43+
collectionCreatedById: "user_b",
44+
hasDirectGrant: true,
45+
}),
46+
).toBe(true);
47+
});
48+
49+
it("denies tokens after creator access is revoked", () => {
50+
expect(
51+
tokenCreatorHasCollectionAccess({
52+
creatorUserId: "user_a",
53+
creatorEmail: "user@example.com",
54+
collectionCreatedById: "user_b",
55+
hasDirectGrant: false,
56+
}),
57+
).toBe(false);
58+
});
59+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { isOwnerEmail } from "@/lib/owners";
2+
3+
export function tokenCreatorHasCollectionAccess(params: {
4+
creatorUserId: string;
5+
creatorEmail: string | null;
6+
collectionCreatedById: string | null;
7+
hasDirectGrant: boolean;
8+
}): boolean {
9+
if (isOwnerEmail(params.creatorEmail)) return true;
10+
if (params.collectionCreatedById === params.creatorUserId) return true;
11+
return params.hasDirectGrant;
12+
}

0 commit comments

Comments
 (0)