Skip to content

Commit c9b3cbd

Browse files
feat: gate scoped access tokens by entitlement
1 parent cdc2996 commit c9b3cbd

5 files changed

Lines changed: 89 additions & 1 deletion

File tree

packages/shared/src/entitlements.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const ALL_ENTITLEMENTS = [
5252
"oauth",
5353
"ask",
5454
"mcp",
55+
"scoped-access-tokens",
5556
"scim"
5657
] as const;
5758
export type Entitlement = (typeof ALL_ENTITLEMENTS)[number];

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
33
const mocks = vi.hoisted(() => ({
44
authContext: undefined as unknown,
55
generateScopedAccessToken: vi.fn(),
6+
hasEntitlement: vi.fn(),
67
}));
78

89
vi.mock('@/middleware/sew', () => ({
@@ -13,6 +14,10 @@ vi.mock('@/middleware/withAuth', () => ({
1314
withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)),
1415
}));
1516

17+
vi.mock('@/lib/entitlements', () => ({
18+
hasEntitlement: mocks.hasEntitlement,
19+
}));
20+
1621
vi.mock('@sourcebot/shared', () => ({
1722
generateScopedAccessToken: mocks.generateScopedAccessToken,
1823
}));
@@ -61,6 +66,7 @@ beforeEach(() => {
6166
token: 'sbst_secret',
6267
hash: 'token-hash',
6368
});
69+
mocks.hasEntitlement.mockReturnValue(true);
6470
});
6571

6672
afterEach(() => {
@@ -82,6 +88,26 @@ describe('createScopedAccessTokenRequestSchema', () => {
8288
});
8389

8490
describe('createScopedAccessToken', () => {
91+
test('rejects minting before repository lookup when the entitlement is unavailable', async () => {
92+
const prisma = createPrismaMock([{ id: REPO_A_ID }]);
93+
mocks.authContext = {
94+
org: { id: 1 },
95+
user: { id: 'user-id' },
96+
prisma,
97+
};
98+
mocks.hasEntitlement.mockReturnValue(false);
99+
100+
await expect(createScopedAccessToken({ repoIds: [REPO_A_ID] })).resolves.toEqual({
101+
statusCode: 403,
102+
errorCode: 'INSUFFICIENT_PERMISSIONS',
103+
message: 'Scoped access tokens are not available in your current plan.',
104+
});
105+
expect(mocks.hasEntitlement).toHaveBeenCalledWith('scoped-access-tokens');
106+
expect(prisma.repo.findMany).not.toHaveBeenCalled();
107+
expect(mocks.generateScopedAccessToken).not.toHaveBeenCalled();
108+
expect(prisma.scopedAccessToken.create).not.toHaveBeenCalled();
109+
});
110+
85111
test('creates an API-key-authenticated token with an exact one-hour lifetime', async () => {
86112
const prisma = createPrismaMock([
87113
{ id: REPO_B_ID },
@@ -176,6 +202,24 @@ describe('createScopedAccessToken', () => {
176202
});
177203

178204
describe('revokeScopedAccessToken', () => {
205+
test('rejects revocation before deletion when the entitlement is unavailable', async () => {
206+
const prisma = createPrismaMock([]);
207+
mocks.authContext = {
208+
org: { id: 1 },
209+
user: { id: 'user-id' },
210+
prisma,
211+
};
212+
mocks.hasEntitlement.mockReturnValue(false);
213+
214+
await expect(revokeScopedAccessToken('token-id')).resolves.toEqual({
215+
statusCode: 403,
216+
errorCode: 'INSUFFICIENT_PERMISSIONS',
217+
message: 'Scoped access tokens are not available in your current plan.',
218+
});
219+
expect(mocks.hasEntitlement).toHaveBeenCalledWith('scoped-access-tokens');
220+
expect(prisma.scopedAccessToken.deleteMany).not.toHaveBeenCalled();
221+
});
222+
179223
test('deletes only a token owned by the API-key user in the current org', async () => {
180224
const prisma = createPrismaMock([]);
181225
mocks.authContext = {

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ErrorCode } from '@/lib/errorCodes';
2+
import { hasEntitlement } from '@/lib/entitlements';
23
import type { ServiceError } from '@/lib/serviceError';
34
import { sew } from '@/middleware/sew';
45
import { withAuth } from '@/middleware/withAuth';
@@ -26,10 +27,27 @@ export interface RevokeScopedAccessTokenResponse {
2627
success: true;
2728
}
2829

30+
const checkScopedAccessTokenEntitlement = async (): Promise<ServiceError | null> => {
31+
if (await hasEntitlement('scoped-access-tokens')) {
32+
return null;
33+
}
34+
35+
return {
36+
statusCode: StatusCodes.FORBIDDEN,
37+
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
38+
message: 'Scoped access tokens are not available in your current plan.',
39+
} satisfies ServiceError;
40+
};
41+
2942
export const createScopedAccessToken = async (
3043
request: CreateScopedAccessTokenRequest,
3144
): Promise<CreateScopedAccessTokenResponse | ServiceError> => sew(() =>
3245
withAuth(async ({ org, user, prisma }) => {
46+
const entitlementError = await checkScopedAccessTokenEntitlement();
47+
if (entitlementError) {
48+
return entitlementError;
49+
}
50+
3351
// Treat duplicate IDs as one scope entry while preserving request order.
3452
const repositoryIds = [...new Set(request.repoIds)];
3553
const repositories = await prisma.repo.findMany({
@@ -85,6 +103,11 @@ export const revokeScopedAccessToken = async (
85103
id: string,
86104
): Promise<RevokeScopedAccessTokenResponse | ServiceError> => sew(() =>
87105
withAuth(async ({ org, user, prisma }) => {
106+
const entitlementError = await checkScopedAccessTokenEntitlement();
107+
if (entitlementError) {
108+
return entitlementError;
109+
}
110+
88111
const { count } = await prisma.scopedAccessToken.deleteMany({
89112
where: {
90113
id,

packages/web/src/middleware/withAuth.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ beforeEach(() => {
123123
vi.mocked(userScopedPrismaClientExtension).mockReset();
124124
mocks.auth.mockResolvedValue(null);
125125
mocks.headers.mockResolvedValue(new Headers());
126-
mocks.hasEntitlement.mockReturnValue(false);
126+
mocks.hasEntitlement.mockImplementation(
127+
(entitlement: string) => entitlement === 'scoped-access-tokens',
128+
);
127129
mocks.isAnonymousAccessAvailable.mockReturnValue(false);
128130
// getAuthContext fires `prisma.user.update().catch(...)` and
129131
// `prisma.userToOrg.updateMany().catch(...)` to bump lastActiveAt; without a
@@ -229,6 +231,20 @@ describe('getAuthenticatedUser', () => {
229231
});
230232

231233
describe('scoped access token Bearer authentication', () => {
234+
test('should return undefined before token lookup without the entitlement', async () => {
235+
prisma.scopedAccessToken.findUnique.mockResolvedValue(createMockScopedAccessToken());
236+
mocks.hasEntitlement.mockReturnValue(false);
237+
setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_scopedtoken' }));
238+
239+
const result = await getAuthenticatedUser();
240+
241+
expect(result).toBeUndefined();
242+
expect(mocks.hasEntitlement.mock.calls[0]?.[0]).toBe('scoped-access-tokens');
243+
expect(prisma.scopedAccessToken.findUnique).not.toHaveBeenCalled();
244+
expect(prisma.scopedAccessToken.update).not.toHaveBeenCalled();
245+
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
246+
});
247+
232248
test('should return the token creator and scoped access token principal for a valid token', async () => {
233249
const scopedAccessToken = createMockScopedAccessToken();
234250
prisma.scopedAccessToken.findUnique.mockResolvedValue(scopedAccessToken);

packages/web/src/middleware/withAuth.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,10 @@ export const getAuthenticatedUser = async (): Promise<AuthResult | undefined> =>
321321
}
322322

323323
if (bearerToken.startsWith(SCOPED_ACCESS_TOKEN_PREFIX)) {
324+
if (!await hasEntitlement('scoped-access-tokens')) {
325+
return undefined;
326+
}
327+
324328
const secret = bearerToken.slice(SCOPED_ACCESS_TOKEN_PREFIX.length);
325329
if (!secret) {
326330
return undefined;

0 commit comments

Comments
 (0)