Skip to content

Commit cdc2996

Browse files
feat: scope access tokens by repository ID
1 parent e0b2171 commit cdc2996

5 files changed

Lines changed: 53 additions & 82 deletions

File tree

docs/api-reference/sourcebot-public.openapi.json

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,11 +1114,12 @@
11141114
"type": "string",
11151115
"format": "date-time"
11161116
},
1117-
"repos": {
1117+
"repoIds": {
11181118
"type": "array",
11191119
"items": {
1120-
"type": "string",
1121-
"minLength": 1
1120+
"type": "integer",
1121+
"minimum": 0,
1122+
"exclusiveMinimum": true
11221123
},
11231124
"minItems": 1
11241125
}
@@ -1128,24 +1129,25 @@
11281129
"token",
11291130
"createdAt",
11301131
"expiresAt",
1131-
"repos"
1132+
"repoIds"
11321133
]
11331134
},
11341135
"PublicCreateScopedAccessTokenRequest": {
11351136
"type": "object",
11361137
"properties": {
1137-
"repos": {
1138+
"repoIds": {
11381139
"type": "array",
11391140
"items": {
1140-
"type": "string",
1141-
"minLength": 1
1141+
"type": "integer",
1142+
"minimum": 0,
1143+
"exclusiveMinimum": true
11421144
},
11431145
"minItems": 1,
1144-
"description": "Repository names to bind to the token. Every name must identify exactly one repository accessible to the API-key owner."
1146+
"description": "Repository IDs to bind to the token. Every ID must identify a repository accessible to the API-key owner."
11451147
}
11461148
},
11471149
"required": [
1148-
"repos"
1150+
"repoIds"
11491151
],
11501152
"additionalProperties": false
11511153
},
@@ -2332,7 +2334,7 @@
23322334
"Scoped Access Tokens"
23332335
],
23342336
"summary": "Create a scoped access token",
2335-
"description": "Creates an opaque bearer token that expires exactly one hour after issuance and is restricted to the requested repositories. Repository names are resolved atomically against the API-key owner's current access; the request fails if any name is missing, inaccessible, or ambiguous.\n\nThis endpoint requires a Sourcebot API key. Scoped access tokens, OAuth tokens, and browser sessions cannot mint another scoped access token. The returned token is independent of the API key after issuance and cannot be refreshed.",
2337+
"description": "Creates an opaque bearer token that expires exactly one hour after issuance and is restricted to the requested repositories. Repository IDs are validated atomically against the API-key owner's current access; the request fails if any ID is missing or inaccessible. Repository IDs are returned by GET /api/repos.\n\nThis endpoint requires a Sourcebot API key. Scoped access tokens, OAuth tokens, and browser sessions cannot mint another scoped access token. The returned token is independent of the API key after issuance and cannot be refreshed.",
23362338
"security": [
23372339
{
23382340
"bearerToken": []

packages/web/src/ee/features/scopedAccessTokens/api.test.ts

Lines changed: 24 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ const {
2525
const { withAuth } = await import('@/middleware/withAuth');
2626

2727
const NOW = new Date('2026-08-06T04:00:00.000Z');
28-
const REPO_A = 'github.com/acme/a';
29-
const REPO_B = 'github.com/acme/b';
28+
const REPO_A_ID = 11;
29+
const REPO_B_ID = 22;
3030

3131
function createPrismaMock(
32-
repositories: Array<{ id: number; name: string }>,
32+
repositories: Array<{ id: number }>,
3333
deletedTokenCount = 1,
3434
) {
3535
const scopedAccessTokenCreate = vi.fn().mockImplementation(async ({
@@ -68,12 +68,14 @@ afterEach(() => {
6868
});
6969

7070
describe('createScopedAccessTokenRequestSchema', () => {
71-
test('accepts only a non-empty repos array', () => {
72-
expect(createScopedAccessTokenRequestSchema.safeParse({ repos: [REPO_A] }).success).toBe(true);
73-
expect(createScopedAccessTokenRequestSchema.safeParse({ repos: [] }).success).toBe(false);
74-
expect(createScopedAccessTokenRequestSchema.safeParse({ repositories: [REPO_A] }).success).toBe(false);
71+
test('accepts only a non-empty repoIds array of positive integers', () => {
72+
expect(createScopedAccessTokenRequestSchema.safeParse({ repoIds: [REPO_A_ID] }).success).toBe(true);
73+
expect(createScopedAccessTokenRequestSchema.safeParse({ repoIds: [] }).success).toBe(false);
74+
expect(createScopedAccessTokenRequestSchema.safeParse({ repos: [REPO_A_ID] }).success).toBe(false);
75+
expect(createScopedAccessTokenRequestSchema.safeParse({ repoIds: ['11'] }).success).toBe(false);
76+
expect(createScopedAccessTokenRequestSchema.safeParse({ repoIds: [0] }).success).toBe(false);
7577
expect(createScopedAccessTokenRequestSchema.safeParse({
76-
repos: [REPO_A],
78+
repoIds: [REPO_A_ID],
7779
expiresAt: '2026-08-06T05:00:00.000Z',
7880
}).success).toBe(false);
7981
});
@@ -82,33 +84,32 @@ describe('createScopedAccessTokenRequestSchema', () => {
8284
describe('createScopedAccessToken', () => {
8385
test('creates an API-key-authenticated token with an exact one-hour lifetime', async () => {
8486
const prisma = createPrismaMock([
85-
{ id: 22, name: REPO_B },
86-
{ id: 11, name: REPO_A },
87+
{ id: REPO_B_ID },
88+
{ id: REPO_A_ID },
8789
]);
8890
mocks.authContext = {
8991
org: { id: 1 },
9092
user: { id: 'user-id' },
9193
prisma,
9294
};
9395

94-
await expect(createScopedAccessToken({ repos: [REPO_A, REPO_B] })).resolves.toEqual({
96+
await expect(createScopedAccessToken({ repoIds: [REPO_A_ID, REPO_B_ID] })).resolves.toEqual({
9597
id: 'token-id',
9698
token: 'sbst_secret',
9799
createdAt: '2026-08-06T04:00:00.000Z',
98100
expiresAt: '2026-08-06T05:00:00.000Z',
99-
repos: [REPO_A, REPO_B],
101+
repoIds: [REPO_A_ID, REPO_B_ID],
100102
});
101103
expect(withAuth).toHaveBeenCalledWith(expect.any(Function), {
102104
requiredAuthSource: 'api_key',
103105
});
104106
expect(prisma.repo.findMany).toHaveBeenCalledWith({
105107
where: {
106108
orgId: 1,
107-
name: { in: [REPO_A, REPO_B] },
109+
id: { in: [REPO_A_ID, REPO_B_ID] },
108110
},
109111
select: {
110112
id: true,
111-
name: true,
112113
},
113114
});
114115
expect(prisma.scopedAccessToken.create).toHaveBeenCalledWith({
@@ -131,62 +132,43 @@ describe('createScopedAccessToken', () => {
131132
});
132133

133134
test('rejects the entire request before generating a token when a repo is inaccessible or missing', async () => {
134-
const prisma = createPrismaMock([{ id: 11, name: REPO_A }]);
135+
const prisma = createPrismaMock([{ id: REPO_A_ID }]);
135136
mocks.authContext = {
136137
org: { id: 1 },
137138
user: { id: 'user-id' },
138139
prisma,
139140
};
140141

141-
await expect(createScopedAccessToken({ repos: [REPO_A, REPO_B] })).resolves.toEqual({
142+
await expect(createScopedAccessToken({ repoIds: [REPO_A_ID, REPO_B_ID] })).resolves.toEqual({
142143
statusCode: 400,
143144
errorCode: 'INVALID_REPOSITORY_SCOPE',
144-
message: 'Each repository name must identify exactly one accessible repository.',
145+
message: 'Each repository ID must identify an accessible repository.',
145146
});
146147
expect(mocks.generateScopedAccessToken).not.toHaveBeenCalled();
147148
expect(prisma.scopedAccessToken.create).not.toHaveBeenCalled();
148149
});
149150

150-
test('rejects ambiguous repository names before generating a token', async () => {
151-
const prisma = createPrismaMock([
152-
{ id: 11, name: REPO_A },
153-
{ id: 12, name: REPO_A },
154-
]);
155-
mocks.authContext = {
156-
org: { id: 1 },
157-
user: { id: 'user-id' },
158-
prisma,
159-
};
160-
161-
await expect(createScopedAccessToken({ repos: [REPO_A] })).resolves.toMatchObject({
162-
statusCode: 400,
163-
errorCode: 'INVALID_REPOSITORY_SCOPE',
164-
});
165-
expect(mocks.generateScopedAccessToken).not.toHaveBeenCalled();
166-
expect(prisma.scopedAccessToken.create).not.toHaveBeenCalled();
167-
});
168-
169-
test('normalizes duplicate repository names before lookup and persistence', async () => {
170-
const prisma = createPrismaMock([{ id: 11, name: REPO_A }]);
151+
test('normalizes duplicate repository IDs before lookup and persistence', async () => {
152+
const prisma = createPrismaMock([{ id: REPO_A_ID }]);
171153
mocks.authContext = {
172154
org: { id: 1 },
173155
user: { id: 'user-id' },
174156
prisma,
175157
};
176158

177-
await expect(createScopedAccessToken({ repos: [REPO_A, REPO_A] })).resolves.toMatchObject({
178-
repos: [REPO_A],
159+
await expect(createScopedAccessToken({ repoIds: [REPO_A_ID, REPO_A_ID] })).resolves.toMatchObject({
160+
repoIds: [REPO_A_ID],
179161
});
180162
expect(prisma.repo.findMany).toHaveBeenCalledWith(expect.objectContaining({
181163
where: {
182164
orgId: 1,
183-
name: { in: [REPO_A] },
165+
id: { in: [REPO_A_ID] },
184166
},
185167
}));
186168
expect(prisma.scopedAccessToken.create).toHaveBeenCalledWith(expect.objectContaining({
187169
data: expect.objectContaining({
188170
repos: {
189-
create: [{ repoId: 11 }],
171+
create: [{ repoId: REPO_A_ID }],
190172
},
191173
}),
192174
}));

packages/web/src/ee/features/scopedAccessTokens/api.ts

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { z } from 'zod';
99
const SCOPED_ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
1010

1111
export const createScopedAccessTokenRequestSchema = z.object({
12-
repos: z.array(z.string().min(1)).min(1),
12+
repoIds: z.array(z.number().int().positive()).min(1),
1313
}).strict();
1414

1515
export type CreateScopedAccessTokenRequest = z.infer<typeof createScopedAccessTokenRequestSchema>;
@@ -19,7 +19,7 @@ export interface CreateScopedAccessTokenResponse {
1919
token: string;
2020
createdAt: string;
2121
expiresAt: string;
22-
repos: string[];
22+
repoIds: number[];
2323
}
2424

2525
export interface RevokeScopedAccessTokenResponse {
@@ -30,37 +30,24 @@ export const createScopedAccessToken = async (
3030
request: CreateScopedAccessTokenRequest,
3131
): Promise<CreateScopedAccessTokenResponse | ServiceError> => sew(() =>
3232
withAuth(async ({ org, user, prisma }) => {
33-
// Treat duplicate names as one scope entry while preserving request order.
34-
const repositoryNames = [...new Set(request.repos)];
33+
// Treat duplicate IDs as one scope entry while preserving request order.
34+
const repositoryIds = [...new Set(request.repoIds)];
3535
const repositories = await prisma.repo.findMany({
3636
where: {
3737
orgId: org.id,
38-
name: { in: repositoryNames },
38+
id: { in: repositoryIds },
3939
},
4040
select: {
4141
id: true,
42-
name: true,
4342
},
4443
});
4544

46-
const repositoriesByName = new Map<string, typeof repositories>();
47-
for (const repository of repositories) {
48-
const matches = repositoriesByName.get(repository.name) ?? [];
49-
matches.push(repository);
50-
repositoriesByName.set(repository.name, matches);
51-
}
52-
53-
const resolvedRepositories: typeof repositories = [];
54-
for (const repositoryName of repositoryNames) {
55-
const matches = repositoriesByName.get(repositoryName);
56-
if (matches?.length !== 1) {
57-
return {
58-
statusCode: StatusCodes.BAD_REQUEST,
59-
errorCode: ErrorCode.INVALID_REPOSITORY_SCOPE,
60-
message: "Each repository name must identify exactly one accessible repository.",
61-
} satisfies ServiceError;
62-
}
63-
resolvedRepositories.push(matches[0]);
45+
if (repositories.length !== repositoryIds.length) {
46+
return {
47+
statusCode: StatusCodes.BAD_REQUEST,
48+
errorCode: ErrorCode.INVALID_REPOSITORY_SCOPE,
49+
message: 'Each repository ID must identify an accessible repository.',
50+
} satisfies ServiceError;
6451
}
6552

6653
const now = new Date();
@@ -74,7 +61,7 @@ export const createScopedAccessToken = async (
7461
createdById: user.id,
7562
orgId: org.id,
7663
repos: {
77-
create: resolvedRepositories.map(({ id }) => ({ repoId: id })),
64+
create: repositoryIds.map((repoId) => ({ repoId })),
7865
},
7966
},
8067
select: {
@@ -89,7 +76,7 @@ export const createScopedAccessToken = async (
8976
token,
9077
createdAt: createdToken.createdAt.toISOString(),
9178
expiresAt: createdToken.expiresAt.toISOString(),
92-
repos: repositoryNames,
79+
repoIds: repositoryIds,
9380
} satisfies CreateScopedAccessTokenResponse;
9481
}, { requiredAuthSource: 'api_key' })
9582
);

packages/web/src/openapi/publicApiDocument.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ export function createPublicOpenApiDocument(version: string) {
437437
tags: [scopedAccessTokensTag.name],
438438
summary: 'Create a scoped access token',
439439
description: dedent`
440-
Creates an opaque bearer token that expires exactly one hour after issuance and is restricted to the requested repositories. Repository names are resolved atomically against the API-key owner's current access; the request fails if any name is missing, inaccessible, or ambiguous.
440+
Creates an opaque bearer token that expires exactly one hour after issuance and is restricted to the requested repositories. Repository IDs are validated atomically against the API-key owner's current access; the request fails if any ID is missing or inaccessible. Repository IDs are returned by GET /api/repos.
441441
442442
This endpoint requires a Sourcebot API key. Scoped access tokens, OAuth tokens, and browser sessions cannot mint another scoped access token. The returned token is independent of the API key after issuance and cannot be refreshed.
443443
`,

packages/web/src/openapi/publicApiSchemas.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ export const publicEeAuditResponseSchema = z.array(publicEeAuditRecordSchema).op
108108

109109
// EE: Scoped Access Tokens
110110
export const publicCreateScopedAccessTokenRequestSchema = z.object({
111-
repos: z.array(z.string().min(1)).min(1)
112-
.describe('Repository names to bind to the token. Every name must identify exactly one repository accessible to the API-key owner.'),
111+
repoIds: z.array(z.number().int().positive()).min(1)
112+
.describe('Repository IDs to bind to the token. Every ID must identify a repository accessible to the API-key owner.'),
113113
}).strict().openapi('PublicCreateScopedAccessTokenRequest');
114114

115115
export const publicCreateScopedAccessTokenResponseSchema = z.object({
@@ -118,5 +118,5 @@ export const publicCreateScopedAccessTokenResponseSchema = z.object({
118118
.describe('Opaque bearer token. This value is returned only when the token is created.'),
119119
createdAt: z.string().datetime(),
120120
expiresAt: z.string().datetime(),
121-
repos: z.array(z.string().min(1)).min(1),
121+
repoIds: z.array(z.number().int().positive()).min(1),
122122
}).openapi('PublicCreateScopedAccessTokenResponse');

0 commit comments

Comments
 (0)