diff --git a/CHANGELOG.md b/CHANGELOG.md index af7ccdb7c..00ce51b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- [EE] Added one-hour repository-scoped access tokens with public mint and revoke APIs. [#1549](https://github.com/sourcebot-dev/sourcebot/pull/1549) - [EE] Added guided reconnection for MCP connector authentication failures during Ask Sourcebot agent turns. [#1548](https://github.com/sourcebot-dev/sourcebot/pull/1548) ### Removed diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index d88a0c798..216a4f2a4 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -18,6 +18,10 @@ "name": "Git", "description": "Git history, diff, and file content endpoints." }, + { + "name": "Scoped Access Tokens", + "description": "Mint and revoke short-lived credentials restricted to specific repositories." + }, { "name": "System", "description": "System health and version endpoints." @@ -1090,6 +1094,63 @@ "$ref": "#/components/schemas/PublicCommitAuthor" } }, + "PublicCreateScopedAccessTokenResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier used to revoke the token." + }, + "token": { + "type": "string", + "pattern": "^sbst_", + "description": "Opaque bearer token. This value is returned only when the token is created." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "repoIds": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "minItems": 1 + } + }, + "required": [ + "id", + "token", + "createdAt", + "expiresAt", + "repoIds" + ] + }, + "PublicCreateScopedAccessTokenRequest": { + "type": "object", + "properties": { + "repoIds": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "minItems": 1, + "description": "Repository IDs to bind to the token. Every ID must identify a repository accessible to the API-key owner." + } + }, + "required": [ + "repoIds" + ], + "additionalProperties": false + }, "PublicEeUser": { "type": "object", "properties": { @@ -1253,7 +1314,7 @@ "bearerToken": { "type": "http", "scheme": "bearer", - "description": "Bearer authentication header of the form `Bearer `, where `` is your API key." + "description": "Bearer authentication header of the form `Bearer `. The token may be a Sourcebot API key, OAuth access token, or scoped access token, subject to endpoint requirements." }, "apiKeyHeader": { "type": "apiKey", @@ -2266,6 +2327,161 @@ } } }, + "/api/ee/scoped_access_token": { + "post": { + "operationId": "createScopedAccessToken", + "tags": [ + "Scoped Access Tokens" + ], + "summary": "Create a scoped access token", + "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.", + "security": [ + { + "bearerToken": [] + }, + { + "apiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicCreateScopedAccessTokenRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Scoped access token created. The opaque token value is returned only in this response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicCreateScopedAccessTokenResponse" + } + } + } + }, + "400": { + "description": "Invalid request body or repository scope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "403": { + "description": "The current authentication method is not an API key, or the API-key owner is not permitted to perform this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected token creation failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, + "/api/ee/scoped_access_token/{id}": { + "delete": { + "operationId": "revokeScopedAccessToken", + "tags": [ + "Scoped Access Tokens" + ], + "summary": "Revoke a scoped access token", + "description": "Immediately revokes a scoped access token created by the authenticated API-key owner. This endpoint requires a Sourcebot API key.", + "security": [ + { + "bearerToken": [] + }, + { + "apiKeyHeader": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Identifier returned when the scoped access token was created." + }, + "required": true, + "description": "Identifier returned when the scoped access token was created.", + "name": "id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Scoped access token revoked." + }, + "401": { + "description": "Missing or invalid authentication.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "403": { + "description": "The current authentication method is not an API key, or the API-key owner is not permitted to perform this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "404": { + "description": "Scoped access token not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected token revocation failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, "/api/ee/user": { "get": { "operationId": "getUser", diff --git a/docs/docs.json b/docs/docs.json index ad45e7fc8..7521cb827 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -189,6 +189,14 @@ "GET /api/repos" ] }, + { + "group": "Scoped Access Tokens", + "icon": "key", + "pages": [ + "POST /api/ee/scoped_access_token", + "DELETE /api/ee/scoped_access_token/{id}" + ] + }, { "group": "Git", "icon": "code-branch", diff --git a/docs/docs/api-reference/authentication.mdx b/docs/docs/api-reference/authentication.mdx index 9c75fb425..03b27b97a 100644 --- a/docs/docs/api-reference/authentication.mdx +++ b/docs/docs/api-reference/authentication.mdx @@ -32,3 +32,22 @@ curl -X POST https://your-sourcebot-instance.com/api/search \ -H "Content-Type: application/json" \ -d '{"query": "hello world", "matches": 10}' ``` + +## Using a scoped access token + +Scoped access tokens are short-lived bearer credentials intended for clients that should only access a specific set of repositories. Create one with a Sourcebot API key by calling `POST /api/ee/scoped_access_token` with repository names: + +```bash +curl -X POST https://your-sourcebot-instance.com/api/ee/scoped_access_token \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"repos": ["github.com/acme/frontend", "github.com/acme/backend"]}' +``` + +The response contains an opaque token beginning with `sbst_`. It expires exactly one hour after issuance, cannot be refreshed, and is returned only once. Use it as a Bearer token with public API endpoints or the Sourcebot MCP server: + +```bash +Authorization: Bearer +``` + +Repository scope is bound internally to repository IDs and is also intersected with the creating user's current repository permissions. Creating and revoking scoped access tokens requires an API key; a scoped access token cannot mint or revoke tokens. diff --git a/packages/db/prisma/migrations/20260806033656_add_scoped_access_tokens/migration.sql b/packages/db/prisma/migrations/20260806033656_add_scoped_access_tokens/migration.sql new file mode 100644 index 000000000..010569849 --- /dev/null +++ b/packages/db/prisma/migrations/20260806033656_add_scoped_access_tokens/migration.sql @@ -0,0 +1,44 @@ +-- CreateTable +CREATE TABLE "ScopedAccessToken" ( + "id" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "createdById" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + + CONSTRAINT "ScopedAccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ScopedAccessTokenToRepo" ( + "tokenId" TEXT NOT NULL, + "repoId" INTEGER NOT NULL, + + CONSTRAINT "ScopedAccessTokenToRepo_pkey" PRIMARY KEY ("tokenId","repoId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ScopedAccessToken_hash_key" ON "ScopedAccessToken"("hash"); + +-- CreateIndex +CREATE INDEX "ScopedAccessToken_createdById_orgId_expiresAt_idx" ON "ScopedAccessToken"("createdById", "orgId", "expiresAt"); + +-- CreateIndex +CREATE INDEX "ScopedAccessToken_expiresAt_idx" ON "ScopedAccessToken"("expiresAt"); + +-- CreateIndex +CREATE INDEX "ScopedAccessTokenToRepo_repoId_idx" ON "ScopedAccessTokenToRepo"("repoId"); + +-- AddForeignKey +ALTER TABLE "ScopedAccessToken" ADD CONSTRAINT "ScopedAccessToken_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ScopedAccessToken" ADD CONSTRAINT "ScopedAccessToken_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ScopedAccessTokenToRepo" ADD CONSTRAINT "ScopedAccessTokenToRepo_tokenId_fkey" FOREIGN KEY ("tokenId") REFERENCES "ScopedAccessToken"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ScopedAccessTokenToRepo" ADD CONSTRAINT "ScopedAccessTokenToRepo_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index e43f2887b..b4b43c8f0 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -71,6 +71,7 @@ model Repo { defaultBranch String? permittedAccounts AccountToRepoPermission[] + scopedAccessTokens ScopedAccessTokenToRepo[] permissionSyncJobs RepoPermissionSyncJob[] permissionSyncedAt DateTime? /// When the permissions were last synced successfully. @@ -286,6 +287,7 @@ model Org { connections Connection[] repos Repo[] apiKeys ApiKey[] + scopedAccessTokens ScopedAccessToken[] scimTokens ScimToken[] attachments Attachment[] isOnboarded Boolean @default(false) @@ -454,6 +456,37 @@ model ApiKey { createdById String } +model ScopedAccessToken { + id String @id @default(cuid()) + hash String @unique + + createdAt DateTime @default(now()) + expiresAt DateTime + lastUsedAt DateTime? + + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById String + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + repos ScopedAccessTokenToRepo[] + + @@index([createdById, orgId, expiresAt]) + @@index([expiresAt]) +} + +model ScopedAccessTokenToRepo { + token ScopedAccessToken @relation(fields: [tokenId], references: [id], onDelete: Cascade) + tokenId String + + repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade) + repoId Int + + @@id([tokenId, repoId]) + @@index([repoId]) +} + /// Org-scoped bearer token presented by an IdP (Okta, Entra) to authenticate /// against the SCIM provisioning endpoints. Unlike `ApiKey`, a SCIM token is /// not tied to a user — it acts on behalf of the SCIM integration for the @@ -509,6 +542,7 @@ model User { invites Invite[] apiKeys ApiKey[] + scopedAccessTokens ScopedAccessToken[] chats Chat[] sharedChats ChatAccess[] diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c299ef1cc..42d6371dc 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -12,6 +12,7 @@ export const API_KEY_PREFIX = 'sbk_'; export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_'; export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_'; export const SCIM_TOKEN_PREFIX = 'sbscim_'; +export const SCOPED_ACCESS_TOKEN_PREFIX = 'sbst_'; /** * Default settings. diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index c5b8842be..b04e65d3b 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { env } from './env.server.js'; import { Token } from '@sourcebot/schemas/v3/shared.type'; import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; -import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX, SCIM_TOKEN_PREFIX } from './constants.js'; +import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX, SCIM_TOKEN_PREFIX, SCOPED_ACCESS_TOKEN_PREFIX } from './constants.js'; const algorithm = 'aes-256-cbc'; const ivLength = 16; // 16 bytes for CBC @@ -66,6 +66,16 @@ export function generateScimToken(): { token: string; hash: string } { }; } +export function generateScopedAccessToken(): { token: string; hash: string } { + const secret = crypto.randomBytes(32).toString('hex'); + const hash = hashSecret(secret); + + return { + token: `${SCOPED_ACCESS_TOKEN_PREFIX}${secret}`, + hash, + }; +} + export function generateOAuthToken(): { token: string; hash: string } { const secret = crypto.randomBytes(32).toString('hex'); const hash = hashSecret(secret); @@ -246,4 +256,4 @@ export function encryptActivationCode(code: string): string { export function decryptActivationCode(encrypted: string): string { const { iv, encryptedData } = JSON.parse(Buffer.from(encrypted, 'base64').toString('utf8')); return decrypt(iv, encryptedData); -} \ No newline at end of file +} diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 039394653..d4ee54b58 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -52,6 +52,7 @@ const ALL_ENTITLEMENTS = [ "oauth", "ask", "mcp", + "scoped-access-tokens", "scim" ] as const; export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 6c1d8d723..b37165413 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -61,6 +61,7 @@ export { hashSecret, generateApiKey, generateScimToken, + generateScopedAccessToken, generateOAuthToken, generateOAuthRefreshToken, verifySignature, diff --git a/packages/web/src/app/api/(server)/ee/scoped_access_token/[id]/route.ts b/packages/web/src/app/api/(server)/ee/scoped_access_token/[id]/route.ts new file mode 100644 index 000000000..40f2ef5fc --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/scoped_access_token/[id]/route.ts @@ -0,0 +1,20 @@ +import { revokeScopedAccessToken } from '@/ee/features/scopedAccessTokens/api'; +import { apiHandler } from '@/lib/apiHandler'; +import { serviceErrorResponse } from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; +import { StatusCodes } from 'http-status-codes'; +import type { NextRequest } from 'next/server'; + +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to revokeScopedAccessToken(), which requires API-key authentication +export const DELETE = apiHandler(async ( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + const { id } = await params; + const result = await revokeScopedAccessToken(id); + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return new Response(null, { status: StatusCodes.NO_CONTENT }); +}); diff --git a/packages/web/src/app/api/(server)/ee/scoped_access_token/route.ts b/packages/web/src/app/api/(server)/ee/scoped_access_token/route.ts new file mode 100644 index 000000000..3cd1a89a2 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/scoped_access_token/route.ts @@ -0,0 +1,26 @@ +import { + createScopedAccessToken, + createScopedAccessTokenRequestSchema, +} from '@/ee/features/scopedAccessTokens/api'; +import { apiHandler } from '@/lib/apiHandler'; +import { requestBodySchemaValidationError, serviceErrorResponse } from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; +import { StatusCodes } from 'http-status-codes'; +import type { NextRequest } from 'next/server'; + +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to createScopedAccessToken(), which requires API-key authentication +export const POST = apiHandler(async (request: NextRequest) => { + const parsed = createScopedAccessTokenRequestSchema.safeParse( + await request.json().catch(() => null), + ); + if (!parsed.success) { + return serviceErrorResponse(requestBodySchemaValidationError(parsed.error)); + } + + const result = await createScopedAccessToken(parsed.data); + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json(result, { status: StatusCodes.CREATED }); +}); diff --git a/packages/web/src/ee/features/scopedAccessTokens/api.test.ts b/packages/web/src/ee/features/scopedAccessTokens/api.test.ts new file mode 100644 index 000000000..71cd019fc --- /dev/null +++ b/packages/web/src/ee/features/scopedAccessTokens/api.test.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, + generateScopedAccessToken: vi.fn(), + hasEntitlement: vi.fn(), +})); + +vi.mock('@/middleware/sew', () => ({ + sew: (callback: () => unknown) => callback(), +})); + +vi.mock('@/middleware/withAuth', () => ({ + withAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), +})); + +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); + +vi.mock('@sourcebot/shared', () => ({ + generateScopedAccessToken: mocks.generateScopedAccessToken, +})); + +const { + createScopedAccessToken, + createScopedAccessTokenRequestSchema, + revokeScopedAccessToken, +} = await import('./api'); +const { withAuth } = await import('@/middleware/withAuth'); + +const NOW = new Date('2026-08-06T04:00:00.000Z'); +const REPO_A_ID = 11; +const REPO_B_ID = 22; + +function createPrismaMock( + repositories: Array<{ id: number }>, + deletedTokenCount = 1, +) { + const scopedAccessTokenCreate = vi.fn().mockImplementation(async ({ + data, + }: { + data: { createdAt: Date; expiresAt: Date }; + }) => ({ + id: 'token-id', + createdAt: data.createdAt, + expiresAt: data.expiresAt, + })); + + return { + repo: { + findMany: vi.fn().mockResolvedValue(repositories), + }, + scopedAccessToken: { + create: scopedAccessTokenCreate, + deleteMany: vi.fn().mockResolvedValue({ count: deletedTokenCount }), + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + mocks.generateScopedAccessToken.mockReturnValue({ + token: 'sbst_secret', + hash: 'token-hash', + }); + mocks.hasEntitlement.mockReturnValue(true); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('createScopedAccessTokenRequestSchema', () => { + test('accepts only a non-empty repoIds array of positive integers', () => { + expect(createScopedAccessTokenRequestSchema.safeParse({ repoIds: [REPO_A_ID] }).success).toBe(true); + expect(createScopedAccessTokenRequestSchema.safeParse({ repoIds: [] }).success).toBe(false); + expect(createScopedAccessTokenRequestSchema.safeParse({ repos: [REPO_A_ID] }).success).toBe(false); + expect(createScopedAccessTokenRequestSchema.safeParse({ repoIds: ['11'] }).success).toBe(false); + expect(createScopedAccessTokenRequestSchema.safeParse({ repoIds: [0] }).success).toBe(false); + expect(createScopedAccessTokenRequestSchema.safeParse({ + repoIds: [REPO_A_ID], + expiresAt: '2026-08-06T05:00:00.000Z', + }).success).toBe(false); + }); +}); + +describe('createScopedAccessToken', () => { + test('rejects minting before repository lookup when the entitlement is unavailable', async () => { + const prisma = createPrismaMock([{ id: REPO_A_ID }]); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + prisma, + }; + mocks.hasEntitlement.mockReturnValue(false); + + await expect(createScopedAccessToken({ repoIds: [REPO_A_ID] })).resolves.toEqual({ + statusCode: 403, + errorCode: 'INSUFFICIENT_PERMISSIONS', + message: 'Scoped access tokens are not available in your current plan.', + }); + expect(mocks.hasEntitlement).toHaveBeenCalledWith('scoped-access-tokens'); + expect(prisma.repo.findMany).not.toHaveBeenCalled(); + expect(mocks.generateScopedAccessToken).not.toHaveBeenCalled(); + expect(prisma.scopedAccessToken.create).not.toHaveBeenCalled(); + }); + + test('creates an API-key-authenticated token with an exact one-hour lifetime', async () => { + const prisma = createPrismaMock([ + { id: REPO_B_ID }, + { id: REPO_A_ID }, + ]); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + prisma, + }; + + await expect(createScopedAccessToken({ repoIds: [REPO_A_ID, REPO_B_ID] })).resolves.toEqual({ + id: 'token-id', + token: 'sbst_secret', + createdAt: '2026-08-06T04:00:00.000Z', + expiresAt: '2026-08-06T05:00:00.000Z', + repoIds: [REPO_A_ID, REPO_B_ID], + }); + expect(withAuth).toHaveBeenCalledWith(expect.any(Function), { + requiredAuthSource: 'api_key', + }); + expect(prisma.repo.findMany).toHaveBeenCalledWith({ + where: { + orgId: 1, + id: { in: [REPO_A_ID, REPO_B_ID] }, + }, + select: { + id: true, + }, + }); + expect(prisma.scopedAccessToken.create).toHaveBeenCalledWith({ + data: { + hash: 'token-hash', + createdAt: NOW, + expiresAt: new Date('2026-08-06T05:00:00.000Z'), + createdById: 'user-id', + orgId: 1, + repos: { + create: [{ repoId: 11 }, { repoId: 22 }], + }, + }, + select: { + id: true, + createdAt: true, + expiresAt: true, + }, + }); + }); + + test('rejects the entire request before generating a token when a repo is inaccessible or missing', async () => { + const prisma = createPrismaMock([{ id: REPO_A_ID }]); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + prisma, + }; + + await expect(createScopedAccessToken({ repoIds: [REPO_A_ID, REPO_B_ID] })).resolves.toEqual({ + statusCode: 400, + errorCode: 'INVALID_REPOSITORY_SCOPE', + message: 'Each repository ID must identify an accessible repository.', + }); + expect(mocks.generateScopedAccessToken).not.toHaveBeenCalled(); + expect(prisma.scopedAccessToken.create).not.toHaveBeenCalled(); + }); + + test('normalizes duplicate repository IDs before lookup and persistence', async () => { + const prisma = createPrismaMock([{ id: REPO_A_ID }]); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + prisma, + }; + + await expect(createScopedAccessToken({ repoIds: [REPO_A_ID, REPO_A_ID] })).resolves.toMatchObject({ + repoIds: [REPO_A_ID], + }); + expect(prisma.repo.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { + orgId: 1, + id: { in: [REPO_A_ID] }, + }, + })); + expect(prisma.scopedAccessToken.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + repos: { + create: [{ repoId: REPO_A_ID }], + }, + }), + })); + }); +}); + +describe('revokeScopedAccessToken', () => { + test('rejects revocation before deletion when the entitlement is unavailable', async () => { + const prisma = createPrismaMock([]); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + prisma, + }; + mocks.hasEntitlement.mockReturnValue(false); + + await expect(revokeScopedAccessToken('token-id')).resolves.toEqual({ + statusCode: 403, + errorCode: 'INSUFFICIENT_PERMISSIONS', + message: 'Scoped access tokens are not available in your current plan.', + }); + expect(mocks.hasEntitlement).toHaveBeenCalledWith('scoped-access-tokens'); + expect(prisma.scopedAccessToken.deleteMany).not.toHaveBeenCalled(); + }); + + test('deletes only a token owned by the API-key user in the current org', async () => { + const prisma = createPrismaMock([]); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + prisma, + }; + + await expect(revokeScopedAccessToken('token-id')).resolves.toEqual({ success: true }); + expect(withAuth).toHaveBeenCalledWith(expect.any(Function), { + requiredAuthSource: 'api_key', + }); + expect(prisma.scopedAccessToken.deleteMany).toHaveBeenCalledWith({ + where: { + id: 'token-id', + createdById: 'user-id', + orgId: 1, + }, + }); + }); + + test('returns the same not-found response when no owned token is deleted', async () => { + const prisma = createPrismaMock([], 0); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + prisma, + }; + + await expect(revokeScopedAccessToken('unknown-or-unowned')).resolves.toEqual({ + statusCode: 404, + errorCode: 'SCOPED_ACCESS_TOKEN_NOT_FOUND', + message: 'Scoped access token not found.', + }); + }); +}); diff --git a/packages/web/src/ee/features/scopedAccessTokens/api.ts b/packages/web/src/ee/features/scopedAccessTokens/api.ts new file mode 100644 index 000000000..08e125612 --- /dev/null +++ b/packages/web/src/ee/features/scopedAccessTokens/api.ts @@ -0,0 +1,129 @@ +import { ErrorCode } from '@/lib/errorCodes'; +import { hasEntitlement } from '@/lib/entitlements'; +import type { ServiceError } from '@/lib/serviceError'; +import { sew } from '@/middleware/sew'; +import { withAuth } from '@/middleware/withAuth'; +import { generateScopedAccessToken } from '@sourcebot/shared'; +import { StatusCodes } from 'http-status-codes'; +import { z } from 'zod'; + +const SCOPED_ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; + +export const createScopedAccessTokenRequestSchema = z.object({ + repoIds: z.array(z.number().int().positive()).min(1), +}).strict(); + +export type CreateScopedAccessTokenRequest = z.infer; + +export interface CreateScopedAccessTokenResponse { + id: string; + token: string; + createdAt: string; + expiresAt: string; + repoIds: number[]; +} + +export interface RevokeScopedAccessTokenResponse { + success: true; +} + +const checkScopedAccessTokenEntitlement = async (): Promise => { + if (await hasEntitlement('scoped-access-tokens')) { + return null; + } + + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: 'Scoped access tokens are not available in your current plan.', + } satisfies ServiceError; +}; + +export const createScopedAccessToken = async ( + request: CreateScopedAccessTokenRequest, +): Promise => sew(() => + withAuth(async ({ org, user, prisma }) => { + const entitlementError = await checkScopedAccessTokenEntitlement(); + if (entitlementError) { + return entitlementError; + } + + // Treat duplicate IDs as one scope entry while preserving request order. + const repositoryIds = [...new Set(request.repoIds)]; + const repositories = await prisma.repo.findMany({ + where: { + orgId: org.id, + id: { in: repositoryIds }, + }, + select: { + id: true, + }, + }); + + if (repositories.length !== repositoryIds.length) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REPOSITORY_SCOPE, + message: 'Each repository ID must identify an accessible repository.', + } satisfies ServiceError; + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + SCOPED_ACCESS_TOKEN_TTL_MS); + const { token, hash } = generateScopedAccessToken(); + const createdToken = await prisma.scopedAccessToken.create({ + data: { + hash, + createdAt: now, + expiresAt, + createdById: user.id, + orgId: org.id, + repos: { + create: repositoryIds.map((repoId) => ({ repoId })), + }, + }, + select: { + id: true, + createdAt: true, + expiresAt: true, + }, + }); + + return { + id: createdToken.id, + token, + createdAt: createdToken.createdAt.toISOString(), + expiresAt: createdToken.expiresAt.toISOString(), + repoIds: repositoryIds, + } satisfies CreateScopedAccessTokenResponse; + }, { requiredAuthSource: 'api_key' }) +); + +export const revokeScopedAccessToken = async ( + id: string, +): Promise => sew(() => + withAuth(async ({ org, user, prisma }) => { + const entitlementError = await checkScopedAccessTokenEntitlement(); + if (entitlementError) { + return entitlementError; + } + + const { count } = await prisma.scopedAccessToken.deleteMany({ + where: { + id, + createdById: user.id, + orgId: org.id, + }, + }); + + if (count === 0) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.SCOPED_ACCESS_TOKEN_NOT_FOUND, + message: 'Scoped access token not found.', + } satisfies ServiceError; + } + + return { success: true } satisfies RevokeScopedAccessTokenResponse; + }, { requiredAuthSource: 'api_key' }) +); diff --git a/packages/web/src/features/search/searchApi.test.ts b/packages/web/src/features/search/searchApi.test.ts new file mode 100644 index 000000000..08397e58b --- /dev/null +++ b/packages/web/src/features/search/searchApi.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, + createAudit: vi.fn(), + createZoektSearchRequest: vi.fn(), + env: {} as Record, + hasEntitlement: vi.fn(), + zoektSearch: vi.fn(), + zoektStreamSearch: vi.fn(), +})); + +vi.mock('@/middleware/sew', () => ({ + sew: (callback: () => unknown) => callback(), +})); + +vi.mock('@/middleware/withAuth', () => ({ + withOptionalAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), +})); + +vi.mock('@/ee/features/audit/audit', () => ({ + createAudit: mocks.createAudit, +})); + +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: mocks.hasEntitlement, +})); + +vi.mock('@sourcebot/shared', () => ({ + env: mocks.env, +})); + +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => new Headers()), +})); + +vi.mock('./parser', () => ({ + parseQuerySyntaxIntoIR: vi.fn(), +})); + +vi.mock('./zoektSearcher', () => ({ + createZoektSearchRequest: mocks.createZoektSearchRequest, + zoektSearch: mocks.zoektSearch, + zoektStreamSearch: mocks.zoektStreamSearch, +})); + +const { search, streamSearch } = await import('./searchApi'); + +const query = {} as never; +const request = { + queryType: 'ir' as const, + query, + options: { matches: 10 }, +}; + +function createPrismaMock(repositoryNames: string[]) { + return { + repo: { + findMany: vi.fn().mockResolvedValue( + repositoryNames.map((name) => ({ name })), + ), + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + Object.keys(mocks.env).forEach((key) => delete mocks.env[key]); + mocks.createAudit.mockResolvedValue(undefined); + mocks.createZoektSearchRequest.mockImplementation(async (input) => input); + mocks.zoektSearch.mockResolvedValue({ files: [] }); + mocks.zoektStreamSearch.mockResolvedValue('stream-result'); + mocks.hasEntitlement.mockResolvedValue(false); +}); + +describe('scoped access token search filtering', () => { + test('passes the scoped Prisma repository names to blocking search when permission syncing is disabled', async () => { + const prisma = createPrismaMock(['github.com/acme/a', 'github.com/acme/b']); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + principal: { + source: 'scoped_access_token', + credentialId: 'token-id', + orgId: 1, + repositoryIds: [11, 22], + expiresAt: new Date('2026-08-06T05:00:00.000Z'), + }, + prisma, + }; + + await search(request); + + expect(prisma.repo.findMany).toHaveBeenCalledWith({ + select: { name: true }, + }); + expect(mocks.createZoektSearchRequest).toHaveBeenCalledWith(expect.objectContaining({ + repoSearchScope: { + kind: 'repos', + repos: ['github.com/acme/a', 'github.com/acme/b'], + }, + })); + expect(mocks.hasEntitlement).not.toHaveBeenCalled(); + }); + + test('passes an empty repository scope to streaming search instead of treating it as unrestricted', async () => { + const prisma = createPrismaMock([]); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + principal: { + source: 'scoped_access_token', + credentialId: 'token-id', + orgId: 1, + repositoryIds: [], + expiresAt: new Date('2026-08-06T05:00:00.000Z'), + }, + prisma, + }; + + await streamSearch(request); + + expect(mocks.createZoektSearchRequest).toHaveBeenCalledWith(expect.objectContaining({ + repoSearchScope: { + kind: 'repos', + repos: [], + }, + })); + }); + + test('preserves unrestricted search for API keys when permission syncing is disabled', async () => { + const prisma = createPrismaMock(['github.com/acme/a']); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + principal: { source: 'api_key' }, + prisma, + }; + + await search(request); + + expect(prisma.repo.findMany).not.toHaveBeenCalled(); + expect(mocks.createZoektSearchRequest).toHaveBeenCalledWith(expect.objectContaining({ + repoSearchScope: { kind: 'all' }, + })); + }); + + test('passes the scoped Prisma repository names for API keys when permission syncing is enabled', async () => { + const prisma = createPrismaMock(['github.com/acme/a']); + mocks.env.PERMISSION_SYNC_ENABLED = 'true'; + mocks.hasEntitlement.mockResolvedValue(true); + mocks.authContext = { + org: { id: 1 }, + user: { id: 'user-id' }, + principal: { source: 'api_key' }, + prisma, + }; + + await search(request); + + expect(prisma.repo.findMany).toHaveBeenCalledWith({ + select: { name: true }, + }); + expect(mocks.createZoektSearchRequest).toHaveBeenCalledWith(expect.objectContaining({ + repoSearchScope: { + kind: 'repos', + repos: ['github.com/acme/a'], + }, + })); + }); +}); diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts index 7676120f5..741b6d701 100644 --- a/packages/web/src/features/search/searchApi.ts +++ b/packages/web/src/features/search/searchApi.ts @@ -1,15 +1,14 @@ import { sew } from "@/middleware/sew"; import { createAudit } from "@/ee/features/audit/audit"; -import { getRepoPermissionFilterForUser } from "@/prisma"; -import { withOptionalAuth } from "@/middleware/withAuth"; -import { PrismaClient, UserWithAccounts } from "@sourcebot/db"; +import { withOptionalAuth, type AuthPrincipal } from "@/middleware/withAuth"; +import { PrismaClient } from "@sourcebot/db"; import { env } from "@sourcebot/shared"; import { hasEntitlement } from "@/lib/entitlements"; import { headers } from "next/headers"; import { QueryIR } from './ir'; import { parseQuerySyntaxIntoIR } from './parser'; import { SearchOptions } from "./types"; -import { createZoektSearchRequest, zoektSearch, zoektStreamSearch } from './zoektSearcher'; +import { createZoektSearchRequest, type RepoSearchScope, zoektSearch, zoektStreamSearch } from './zoektSearcher'; type QueryStringSearchRequest = { @@ -30,7 +29,7 @@ type QueryIRSearchRequest = { type SearchRequest = QueryStringSearchRequest | QueryIRSearchRequest; export const search = (request: SearchRequest) => sew(() => - withOptionalAuth(async ({ prisma, user, org }) => { + withOptionalAuth(async ({ prisma, user, org, principal }) => { if (user) { const source = request.source ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined; await createAudit({ @@ -42,7 +41,7 @@ export const search = (request: SearchRequest) => sew(() => }); } - const repoSearchScope = await getAccessibleRepoNamesForUser({ user, prisma }); + const repoSearchScope = await getRepoSearchScope({ prisma, principal }); // If needed, parse the query syntax into the query intermediate representation. const query = request.queryType === 'string' ? await parseQuerySyntaxIntoIR({ @@ -61,7 +60,7 @@ export const search = (request: SearchRequest) => sew(() => })); export const streamSearch = (request: SearchRequest) => sew(() => - withOptionalAuth(async ({ prisma, user, org }) => { + withOptionalAuth(async ({ prisma, user, org, principal }) => { if (user) { const source = request.source ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined; await createAudit({ @@ -73,7 +72,7 @@ export const streamSearch = (request: SearchRequest) => sew(() => }); } - const repoSearchScope = await getAccessibleRepoNamesForUser({ user, prisma }); + const repoSearchScope = await getRepoSearchScope({ prisma, principal }); // If needed, parse the query syntax into the query intermediate representation. const query = request.queryType === 'string' ? await parseQuerySyntaxIntoIR({ @@ -92,22 +91,33 @@ export const streamSearch = (request: SearchRequest) => sew(() => })); /** - * Returns a list of repository names that the user has access to. - * If permission syncing is disabled, returns undefined. + * Returns whether search can include all repositories or must be constrained to + * the repositories exposed by the scoped Prisma client. */ -const getAccessibleRepoNamesForUser = async ({ user, prisma }: { user?: UserWithAccounts, prisma: PrismaClient }) => { +const getRepoSearchScope = async ({ + prisma, + principal, +}: { + prisma: PrismaClient; + principal?: AuthPrincipal; +}): Promise => { if ( - env.PERMISSION_SYNC_ENABLED !== 'true' || - !await hasEntitlement('permission-syncing') + principal?.source !== 'scoped_access_token' && + ( + env.PERMISSION_SYNC_ENABLED !== 'true' || + !await hasEntitlement('permission-syncing') + ) ) { - return undefined; + return { kind: 'all' }; } const accessibleRepos = await prisma.repo.findMany({ - where: getRepoPermissionFilterForUser(user), select: { name: true, } }); - return accessibleRepos.map(repo => repo.name); + return { + kind: 'repos', + repos: accessibleRepos.map(repo => repo.name), + }; } diff --git a/packages/web/src/features/search/zoektSearcher.ts b/packages/web/src/features/search/zoektSearcher.ts index f3fa6278f..3fd2d9e17 100644 --- a/packages/web/src/features/search/zoektSearcher.ts +++ b/packages/web/src/features/search/zoektSearcher.ts @@ -22,6 +22,10 @@ import { getBrowsePath } from "@/app/(app)/browse/hooks/utils"; const logger = createLogger("zoekt-searcher"); +export type RepoSearchScope = + | { kind: 'all' } + | { kind: 'repos'; repos: string[] }; + /** * Creates a ZoektGrpcSearchRequest given a query IR. */ @@ -36,8 +40,7 @@ export const createZoektSearchRequest = async ({ contextLines?: number, whole?: boolean, }; - // Allows the caller to scope the search to a specific set of repositories. - repoSearchScope?: string[]; + repoSearchScope: RepoSearchScope; }) => { // Find if there are any `rev:` filters in the query. const containsRevExpression = someInQueryIR(query, (q) => isBranchQuery(q)); @@ -54,9 +57,9 @@ export const createZoektSearchRequest = async ({ exact: true, } }] : []), - ...(repoSearchScope ? [{ + ...(repoSearchScope.kind === 'repos' ? [{ repo_set: { - set: repoSearchScope.reduce((acc, repo) => { + set: repoSearchScope.repos.reduce((acc, repo) => { acc[repo] = true; return acc; }, {} as Record) @@ -594,4 +597,4 @@ const accumulateStats = (a: SearchStats, b: SearchStats): SearchStats => { flushReason: a.flushReason, }), } -} \ No newline at end of file +} diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 121a45428..766842b9f 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -31,6 +31,8 @@ export enum ErrorCode { API_KEY_ALREADY_EXISTS = 'API_KEY_ALREADY_EXISTS', API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND', INVALID_API_KEY = 'INVALID_API_KEY', + INVALID_REPOSITORY_SCOPE = 'INVALID_REPOSITORY_SCOPE', + SCOPED_ACCESS_TOKEN_NOT_FOUND = 'SCOPED_ACCESS_TOKEN_NOT_FOUND', FAILED_TO_PARSE_QUERY = 'FAILED_TO_PARSE_QUERY', INVALID_GIT_REF = 'INVALID_GIT_REF', LAST_OWNER_CANNOT_BE_DEMOTED = 'LAST_OWNER_CANNOT_BE_DEMOTED', diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 5437b7541..a9d174496 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -62,6 +62,7 @@ vi.mock('@sourcebot/shared', () => ({ OAUTH_ACCESS_TOKEN_PREFIX: 'sboa_', API_KEY_PREFIX: 'sbk_', LEGACY_API_KEY_PREFIX: 'sourcebot-', + SCOPED_ACCESS_TOKEN_PREFIX: 'sbst_', env: mocks.env, getSeatCap: mocks.getSeatCap, createLogger: vi.fn(() => ({ @@ -83,6 +84,26 @@ const setMockHeaders = (headers: Headers) => { const SUSPENDED_AT = new Date('2026-01-01T00:00:00.000Z'); +const createMockScopedAccessToken = ({ + expiresAt = new Date('2099-01-01T00:00:00.000Z'), + orgId = MOCK_ORG.id, + repositoryIds = [11, 22], +}: { + expiresAt?: Date; + orgId?: number; + repositoryIds?: number[]; +} = {}) => ({ + id: 'scoped-token-id', + hash: 'scopedtoken', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt, + lastUsedAt: null, + createdById: MOCK_USER_WITH_ACCOUNTS.id, + orgId, + createdBy: MOCK_USER_WITH_ACCOUNTS, + repos: repositoryIds.map((repoId) => ({ repoId })), +}); + // Helper to create mock session objects const createMockSession = (overrides: Partial = {}): Session => ({ user: { @@ -102,7 +123,9 @@ beforeEach(() => { vi.mocked(userScopedPrismaClientExtension).mockReset(); mocks.auth.mockResolvedValue(null); mocks.headers.mockResolvedValue(new Headers()); - mocks.hasEntitlement.mockReturnValue(false); + mocks.hasEntitlement.mockImplementation( + (entitlement: string) => entitlement === 'scoped-access-tokens', + ); mocks.isAnonymousAccessAvailable.mockReturnValue(false); // getAuthContext fires `prisma.user.update().catch(...)` and // `prisma.userToOrg.updateMany().catch(...)` to bump lastActiveAt; without a @@ -127,7 +150,7 @@ describe('getAuthenticatedUser', () => { const result = await getAuthenticatedUser(); expect(result).not.toBeUndefined(); expect(result?.user.id).toBe(userId); - expect(result?.source).toBe('session'); + expect(result?.principal.source).toBe('session'); }); test('should return a user object if a valid api key is present', async () => { @@ -146,7 +169,7 @@ describe('getAuthenticatedUser', () => { const result = await getAuthenticatedUser(); expect(result).not.toBeUndefined(); expect(result?.user.id).toBe(userId); - expect(result?.source).toBe('api_key'); + expect(result?.principal.source).toBe('api_key'); expect(prisma.apiKey.update).toHaveBeenCalledWith({ where: { hash: 'apikey', @@ -173,7 +196,7 @@ describe('getAuthenticatedUser', () => { const result = await getAuthenticatedUser(); expect(result).not.toBeUndefined(); expect(result?.user.id).toBe(userId); - expect(result?.source).toBe('api_key'); + expect(result?.principal.source).toBe('api_key'); expect(prisma.apiKey.update).toHaveBeenCalledWith({ where: { hash: 'apikey' }, data: { lastUsedAt: expect.any(Date) }, @@ -196,7 +219,7 @@ describe('getAuthenticatedUser', () => { const result = await getAuthenticatedUser(); expect(result).not.toBeUndefined(); expect(result?.user.id).toBe(userId); - expect(result?.source).toBe('api_key'); + expect(result?.principal.source).toBe('api_key'); expect(prisma.apiKey.update).toHaveBeenCalledWith({ where: { hash: 'apikey', @@ -207,6 +230,104 @@ describe('getAuthenticatedUser', () => { }); }); + describe('scoped access token Bearer authentication', () => { + test('should return undefined before token lookup without the entitlement', async () => { + prisma.scopedAccessToken.findUnique.mockResolvedValue(createMockScopedAccessToken()); + mocks.hasEntitlement.mockReturnValue(false); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_scopedtoken' })); + + const result = await getAuthenticatedUser(); + + expect(result).toBeUndefined(); + expect(mocks.hasEntitlement.mock.calls[0]?.[0]).toBe('scoped-access-tokens'); + expect(prisma.scopedAccessToken.findUnique).not.toHaveBeenCalled(); + expect(prisma.scopedAccessToken.update).not.toHaveBeenCalled(); + expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); + }); + + test('should return the token creator and scoped access token principal for a valid token', async () => { + const scopedAccessToken = createMockScopedAccessToken(); + prisma.scopedAccessToken.findUnique.mockResolvedValue(scopedAccessToken); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_scopedtoken' })); + + const result = await getAuthenticatedUser(); + + expect(prisma.scopedAccessToken.findUnique).toHaveBeenCalledWith({ + where: { hash: 'scopedtoken' }, + include: { + createdBy: { + include: { accounts: true }, + }, + repos: { + select: { repoId: true }, + }, + }, + }); + expect(result).toStrictEqual({ + user: MOCK_USER_WITH_ACCOUNTS, + principal: { + source: 'scoped_access_token', + credentialId: scopedAccessToken.id, + orgId: scopedAccessToken.orgId, + repositoryIds: [11, 22], + expiresAt: scopedAccessToken.expiresAt, + }, + }); + expect(prisma.scopedAccessToken.update).toHaveBeenCalledWith({ + where: { hash: 'scopedtoken' }, + data: { lastUsedAt: expect.any(Date) }, + }); + expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); + }); + + test('should preserve an empty repository scope', async () => { + const scopedAccessToken = createMockScopedAccessToken({ repositoryIds: [] }); + prisma.scopedAccessToken.findUnique.mockResolvedValue(scopedAccessToken); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_scopedtoken' })); + + const result = await getAuthenticatedUser(); + + expect(result?.principal).toMatchObject({ + source: 'scoped_access_token', + repositoryIds: [], + }); + }); + + test('should return undefined for an expired token', async () => { + prisma.scopedAccessToken.findUnique.mockResolvedValue(createMockScopedAccessToken({ + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + })); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_scopedtoken' })); + + const result = await getAuthenticatedUser(); + + expect(result).toBeUndefined(); + expect(prisma.scopedAccessToken.update).not.toHaveBeenCalled(); + expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); + }); + + test('should return undefined for a revoked token', async () => { + prisma.scopedAccessToken.findUnique.mockResolvedValue(null); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_scopedtoken' })); + + const result = await getAuthenticatedUser(); + + expect(result).toBeUndefined(); + expect(prisma.scopedAccessToken.update).not.toHaveBeenCalled(); + expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); + }); + + test('should return undefined for a token without a secret', async () => { + setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_' })); + + const result = await getAuthenticatedUser(); + + expect(result).toBeUndefined(); + expect(prisma.scopedAccessToken.findUnique).not.toHaveBeenCalled(); + expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); + }); + }); + test('should use the current request context when no request is passed', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ @@ -229,7 +350,7 @@ describe('getAuthenticatedUser', () => { expect(result).not.toBeUndefined(); expect(result?.user.id).toBe(userId); - expect(result?.source).toBe('api_key'); + expect(result?.principal.source).toBe('api_key'); expect(mocks.headers).not.toHaveBeenCalled(); expect(prisma.apiKey.update).toHaveBeenCalledWith({ where: { @@ -255,7 +376,7 @@ describe('getAuthenticatedUser', () => { const result = await getAuthenticatedUser(); expect(result).not.toBeUndefined(); expect(result?.user.id).toBe(MOCK_USER_WITH_ACCOUNTS.id); - expect(result?.source).toBe('oauth'); + expect(result?.principal.source).toBe('oauth'); }); test('should return parsed scopes for a valid OAuth Bearer token', async () => { @@ -266,7 +387,10 @@ describe('getAuthenticatedUser', () => { }); setMockHeaders(new Headers({ 'Authorization': 'Bearer sboa_oauthtoken' })); const result = await getAuthenticatedUser(); - expect(result?.oauthScopes).toEqual([TEST_OAUTH_SCOPE, 'other']); + expect(result?.principal).toEqual({ + source: 'oauth', + oauthScopes: [TEST_OAUTH_SCOPE, 'other'], + }); }); test('should update lastUsedAt when an OAuth Bearer token is used', async () => { @@ -395,6 +519,55 @@ describe('getAuthenticatedUser', () => { }); describe('getAuthContext', () => { + test('should pass scoped access token repository IDs to the Prisma extension', async () => { + const scopedAccessToken = createMockScopedAccessToken(); + prisma.scopedAccessToken.findUnique.mockResolvedValue(scopedAccessToken); + prisma.org.findUnique.mockResolvedValue(MOCK_ORG); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId: scopedAccessToken.createdBy.id, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: new Date(), + role: OrgRole.MEMBER, + }); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_scopedtoken' })); + + const authContext = await getAuthContext(); + + expect(userScopedPrismaClientExtension).toHaveBeenCalledWith( + scopedAccessToken.createdBy, + [11, 22], + ); + expect(authContext).toMatchObject({ + user: scopedAccessToken.createdBy, + org: MOCK_ORG, + role: OrgRole.MEMBER, + principal: { + source: 'scoped_access_token', + credentialId: scopedAccessToken.id, + orgId: MOCK_ORG.id, + repositoryIds: [11, 22], + expiresAt: scopedAccessToken.expiresAt, + }, + }); + }); + + test('should reject a scoped access token issued for a different organization', async () => { + prisma.scopedAccessToken.findUnique.mockResolvedValue(createMockScopedAccessToken({ + orgId: MOCK_ORG.id + 1, + })); + prisma.org.findUnique.mockResolvedValue(MOCK_ORG); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_scopedtoken' })); + + const authContext = await getAuthContext(); + + expect(authContext).toStrictEqual(notAuthenticated()); + expect(prisma.userToOrg.findUnique).not.toHaveBeenCalled(); + expect(userScopedPrismaClientExtension).not.toHaveBeenCalled(); + }); + test('sets the Sentry user for direct callers', async () => { const userId = 'test-user-id'; const user = { @@ -458,6 +631,7 @@ describe('getAuthContext', () => { org: MOCK_ORG, role: OrgRole.MEMBER, prisma: undefined, + principal: { source: 'session' }, }); }); @@ -664,6 +838,7 @@ describe('getAuthContext', () => { org: MOCK_ORG, role: OrgRole.OWNER, prisma: undefined, + principal: { source: 'session' }, }); }); @@ -688,6 +863,7 @@ describe('getAuthContext', () => { }, org: MOCK_ORG, prisma: undefined, + principal: { source: 'session' }, }); }); @@ -703,6 +879,7 @@ describe('getAuthContext', () => { user: undefined, org: MOCK_ORG, prisma: undefined, + principal: undefined, }); }); @@ -734,6 +911,7 @@ describe('getAuthContext', () => { }, org: MOCK_ORG, prisma: undefined, + principal: { source: 'session' }, }); expect(prisma.userToOrg.updateMany).not.toHaveBeenCalled(); }); @@ -771,6 +949,7 @@ describe('getAuthContext', () => { }, org: MOCK_ORG, prisma: undefined, + principal: { source: 'api_key' }, }); expect(mocks.setSentryUser).toHaveBeenCalledWith( expect.objectContaining({ id: userId }), @@ -827,6 +1006,7 @@ describe('getAuthContext', () => { org: MOCK_ORG, role: OrgRole.OWNER, prisma: undefined, + principal: { source: 'api_key' }, }); }); @@ -852,6 +1032,7 @@ describe('getAuthContext', () => { org: MOCK_ORG, role: OrgRole.MEMBER, prisma: undefined, + principal: { source: 'session' }, }); }); }); @@ -936,6 +1117,104 @@ describe('getAuthContext', () => { }); describe('withAuth', () => { + describe('requiredAuthSource', () => { + test('should call the callback when the authentication source matches', async () => { + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }); + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: null, + role: OrgRole.MEMBER, + }); + prisma.apiKey.findUnique.mockResolvedValue({ + ...MOCK_API_KEY, + hash: 'apikey', + createdById: userId, + }); + setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' })); + const cb = vi.fn(async () => 'allowed'); + + const result = await withAuth(cb, { requiredAuthSource: 'api_key' }); + + expect(result).toBe('allowed'); + expect(cb).toHaveBeenCalledOnce(); + }); + + test('should return forbidden when the authentication source does not match', async () => { + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }); + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: null, + role: OrgRole.MEMBER, + }); + setMockSession(createMockSession({ user: { id: userId } })); + const cb = vi.fn(async () => 'allowed'); + + const result = await withAuth(cb, { requiredAuthSource: 'api_key' }); + + expect(result).toStrictEqual({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: 'This operation cannot be performed with the current authentication method.', + }); + expect(cb).not.toHaveBeenCalled(); + }); + + test('should return forbidden when a scoped access token is used for an API-key-only operation', async () => { + const scopedAccessToken = createMockScopedAccessToken(); + prisma.scopedAccessToken.findUnique.mockResolvedValue(scopedAccessToken); + prisma.org.findUnique.mockResolvedValue(MOCK_ORG); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId: scopedAccessToken.createdBy.id, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: new Date(), + role: OrgRole.MEMBER, + }); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sbst_scopedtoken' })); + const cb = vi.fn(async () => 'allowed'); + + const result = await withAuth(cb, { requiredAuthSource: 'api_key' }); + + expect(result).toStrictEqual({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: 'This operation cannot be performed with the current authentication method.', + }); + expect(cb).not.toHaveBeenCalled(); + expect(userScopedPrismaClientExtension).not.toHaveBeenCalled(); + }); + + test('should return unauthenticated when no authentication source is present', async () => { + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); + const cb = vi.fn(async () => 'allowed'); + + const result = await withAuth(cb, { requiredAuthSource: 'api_key' }); + + expect(result).toStrictEqual(notAuthenticated()); + expect(cb).not.toHaveBeenCalled(); + }); + }); + test('should pass the scoped prisma client from $extends to the callback', async () => { const userId = 'test-user-id'; const user = { @@ -965,10 +1244,11 @@ describe('withAuth', () => { const cb = vi.fn(); await withAuth(cb); - expect(userScopedPrismaClientExtension).toHaveBeenCalledWith(user); + expect(userScopedPrismaClientExtension).toHaveBeenCalledWith(user, undefined); expect(prisma.$extends).toHaveBeenCalledWith(extension); expect(cb).toHaveBeenCalledWith(expect.objectContaining({ prisma: scopedPrisma, + principal: { source: 'session' }, })); }); @@ -1000,7 +1280,8 @@ describe('withAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.MEMBER + role: OrgRole.MEMBER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1033,7 +1314,8 @@ describe('withAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.OWNER + role: OrgRole.OWNER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1071,7 +1353,8 @@ describe('withAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.MEMBER + role: OrgRole.MEMBER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1109,7 +1392,8 @@ describe('withAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.OWNER + role: OrgRole.OWNER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1147,7 +1431,8 @@ describe('withAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.MEMBER + role: OrgRole.MEMBER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1185,7 +1470,8 @@ describe('withAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.OWNER + role: OrgRole.OWNER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1321,7 +1607,8 @@ describe('withOptionalAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.MEMBER + role: OrgRole.MEMBER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1354,7 +1641,8 @@ describe('withOptionalAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.OWNER + role: OrgRole.OWNER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1392,7 +1680,8 @@ describe('withOptionalAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.MEMBER + role: OrgRole.MEMBER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1430,7 +1719,8 @@ describe('withOptionalAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.OWNER + role: OrgRole.OWNER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1468,7 +1758,8 @@ describe('withOptionalAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.MEMBER + role: OrgRole.MEMBER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1506,7 +1797,8 @@ describe('withOptionalAuth', () => { id: userId, }, org: MOCK_ORG, - role: OrgRole.OWNER + role: OrgRole.OWNER, + principal: expect.any(Object), }); expect(result).toEqual(undefined); }); @@ -1581,6 +1873,7 @@ describe('withOptionalAuth', () => { isAnonymousAccessEnabled: true, }, prisma: undefined, + principal: { source: 'session' }, }); expect(result).toEqual(undefined); }); diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 3e68e8bf4..b004d6169 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -1,5 +1,5 @@ import { __unsafePrisma, userScopedPrismaClientExtension } from "@/prisma"; -import { hashSecret, OAUTH_ACCESS_TOKEN_PREFIX, API_KEY_PREFIX, LEGACY_API_KEY_PREFIX, env } from "@sourcebot/shared"; +import { hashSecret, OAUTH_ACCESS_TOKEN_PREFIX, API_KEY_PREFIX, LEGACY_API_KEY_PREFIX, SCOPED_ACCESS_TOKEN_PREFIX, env } from "@sourcebot/shared"; import { ApiKey, Org, OrgRole, PrismaClient, UserToOrg, UserWithAccounts } from "@sourcebot/db"; import { headers } from "next/headers"; import { auth } from "../auth"; @@ -22,6 +22,7 @@ type RequiredAuthContext = { role: OrgRole; org: Org; prisma: PrismaClient; + principal: AuthPrincipal; }; type OptionalAuthContext = @@ -31,10 +32,31 @@ type OptionalAuthContext = role?: undefined; org: Org; prisma: PrismaClient; + principal?: AuthPrincipal; }; +export type AuthPrincipal = + | { source: 'session' } + | { source: 'oauth'; oauthScopes: string[] } + | { source: 'api_key' } + | { + source: 'scoped_access_token'; + credentialId: string; + orgId: number; + repositoryIds: number[]; + expiresAt: Date; + }; + +export type AuthSource = AuthPrincipal['source']; + +export type AuthResult = { + user: UserWithAccounts; + principal: AuthPrincipal; +}; + type AuthOptions = { requiredOAuthScopes?: readonly string[]; + requiredAuthSource?: AuthSource; }; export const withAuth = async (fn: (params: RequiredAuthContext) => Promise, options: AuthOptions = {}) => { @@ -44,13 +66,13 @@ export const withAuth = async (fn: (params: RequiredAuthContext) => Promise(fn: (params: OptionalAuthContext) => Promise, options: AuthOptions = {}) => { @@ -88,6 +110,13 @@ export const getAuthContext = async (options: AuthOptions = {}): Promise { @@ -195,9 +241,7 @@ const updateMembershipLastActiveAt = (membership: UserToOrg) => { .catch(() => { /* updating the lastActiveAt is best effort. */ }); }; -type AuthSource = 'session' | 'oauth' | 'api_key'; - -export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, source: AuthSource, oauthScopes?: string[] } | undefined> => { +export const getAuthenticatedUser = async (): Promise => { // First, check if we have a valid JWT session. const session = await auth(); if (session) { @@ -211,7 +255,7 @@ export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, } }); - return user ? { user, source: 'session' } : undefined; + return user ? { user, principal: { source: 'session' } } : undefined; } const currentRequest = getCurrentRequest(); @@ -264,8 +308,10 @@ export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, }); return { user: oauthToken.user, - source: 'oauth', - oauthScopes: parseOAuthScopeString(oauthToken.scope) + principal: { + source: 'oauth', + oauthScopes: parseOAuthScopeString(oauthToken.scope), + }, }; } } @@ -274,6 +320,50 @@ export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, return undefined; } + if (bearerToken.startsWith(SCOPED_ACCESS_TOKEN_PREFIX)) { + if (!await hasEntitlement('scoped-access-tokens')) { + return undefined; + } + + const secret = bearerToken.slice(SCOPED_ACCESS_TOKEN_PREFIX.length); + if (!secret) { + return undefined; + } + + const hash = hashSecret(secret); + const scopedAccessToken = await __unsafePrisma.scopedAccessToken.findUnique({ + where: { hash }, + include: { + createdBy: { + include: { accounts: true }, + }, + repos: { + select: { repoId: true }, + }, + }, + }); + + if (!scopedAccessToken || scopedAccessToken.expiresAt <= new Date()) { + return undefined; + } + + await __unsafePrisma.scopedAccessToken.update({ + where: { hash }, + data: { lastUsedAt: new Date() }, + }); + + return { + user: scopedAccessToken.createdBy, + principal: { + source: 'scoped_access_token', + credentialId: scopedAccessToken.id, + orgId: scopedAccessToken.orgId, + repositoryIds: scopedAccessToken.repos.map(({ repoId }) => repoId), + expiresAt: scopedAccessToken.expiresAt, + }, + }; + } + // API key Bearer token (sourcebot-) const apiKey = await getVerifiedApiObject(bearerToken); if (apiKey) { @@ -286,7 +376,7 @@ export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, where: { hash: apiKey.hash }, data: { lastUsedAt: new Date() }, }); - return { user, source: 'api_key' }; + return { user, principal: { source: 'api_key' } }; } } } @@ -323,7 +413,7 @@ export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, }, }); - return { user, source: 'api_key' }; + return { user, principal: { source: 'api_key' } }; } return undefined; diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts index d0f11ee84..ea4951aa0 100644 --- a/packages/web/src/openapi/publicApiDocument.ts +++ b/packages/web/src/openapi/publicApiDocument.ts @@ -5,6 +5,8 @@ import z from 'zod'; import { publicEeAuditQuerySchema, publicEeAuditResponseSchema, + publicCreateScopedAccessTokenRequestSchema, + publicCreateScopedAccessTokenResponseSchema, publicEeDeleteUserResponseSchema, publicEeUserSchema, publicEeUsersResponseSchema, @@ -38,6 +40,7 @@ const reposTag = { name: 'Repositories', description: 'Repository listing and me const gitTag = { name: 'Git', description: 'Git history, diff, and file content endpoints.' }; const systemTag = { name: 'System', description: 'System health and version endpoints.' }; const eeTag = { name: 'Enterprise (EE)', description: 'Enterprise endpoints for user management and audit logging.' }; +const scopedAccessTokensTag = { name: 'Scoped Access Tokens', description: 'Mint and revoke short-lived credentials restricted to specific repositories.' }; const EE_LICENSE_KEY_NOTE = dedent` @@ -78,7 +81,7 @@ const securitySchemes: Record`, where `` is your API key.', + description: 'Bearer authentication header of the form `Bearer `. The token may be a Sourcebot API key, OAuth access token, or scoped access token, subject to endpoint requirements.', }, [securitySchemeNames.apiKeyHeader]: { type: 'apiKey', @@ -426,6 +429,67 @@ export function createPublicOpenApiDocument(version: string) { }, }); + // EE: Scoped Access Tokens + registry.registerPath({ + method: 'post', + path: '/api/ee/scoped_access_token', + operationId: 'createScopedAccessToken', + tags: [scopedAccessTokensTag.name], + summary: 'Create a scoped access token', + description: dedent` + 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. + + 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. + `, + security: [ + { [securitySchemeNames.bearerToken]: [] }, + { [securitySchemeNames.apiKeyHeader]: [] }, + ], + request: { + body: { + required: true, + content: jsonContent(publicCreateScopedAccessTokenRequestSchema), + }, + }, + responses: { + 201: { + description: 'Scoped access token created. The opaque token value is returned only in this response.', + content: jsonContent(publicCreateScopedAccessTokenResponseSchema), + }, + 400: errorJson('Invalid request body or repository scope.'), + 401: errorJson('Missing or invalid authentication.'), + 403: errorJson('The current authentication method is not an API key, or the API-key owner is not permitted to perform this operation.'), + 500: errorJson('Unexpected token creation failure.'), + }, + }); + + registry.registerPath({ + method: 'delete', + path: '/api/ee/scoped_access_token/{id}', + operationId: 'revokeScopedAccessToken', + tags: [scopedAccessTokensTag.name], + summary: 'Revoke a scoped access token', + description: 'Immediately revokes a scoped access token created by the authenticated API-key owner. This endpoint requires a Sourcebot API key.', + security: [ + { [securitySchemeNames.bearerToken]: [] }, + { [securitySchemeNames.apiKeyHeader]: [] }, + ], + request: { + params: z.object({ + id: z.string().describe('Identifier returned when the scoped access token was created.'), + }), + }, + responses: { + 204: { + description: 'Scoped access token revoked.', + }, + 401: errorJson('Missing or invalid authentication.'), + 403: errorJson('The current authentication method is not an API key, or the API-key owner is not permitted to perform this operation.'), + 404: errorJson('Scoped access token not found.'), + 500: errorJson('Unexpected token revocation failure.'), + }, + }); + // EE: User Management registry.registerPath({ method: 'get', @@ -545,7 +609,7 @@ export function createPublicOpenApiDocument(version: string) { version, description: 'OpenAPI description for the public Sourcebot REST endpoints used for search, repository listing, and file browsing. Authentication is instance-dependent: API keys are the standard integration mechanism, OAuth bearer tokens are EE-only, and some instances may allow anonymous access.', }, - tags: [searchTag, reposTag, gitTag, systemTag, eeTag], + tags: [searchTag, reposTag, gitTag, scopedAccessTokensTag, systemTag, eeTag], security: [ { [securitySchemeNames.bearerToken]: [] }, { [securitySchemeNames.apiKeyHeader]: [] }, diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index de1c4011f..869e29ba5 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -105,3 +105,18 @@ export const publicEeAuditRecordSchema = z.object({ }).openapi('PublicEeAuditRecord'); export const publicEeAuditResponseSchema = z.array(publicEeAuditRecordSchema).openapi('PublicEeAuditResponse'); + +// EE: Scoped Access Tokens +export const publicCreateScopedAccessTokenRequestSchema = z.object({ + repoIds: z.array(z.number().int().positive()).min(1) + .describe('Repository IDs to bind to the token. Every ID must identify a repository accessible to the API-key owner.'), +}).strict().openapi('PublicCreateScopedAccessTokenRequest'); + +export const publicCreateScopedAccessTokenResponseSchema = z.object({ + id: z.string().describe('Identifier used to revoke the token.'), + token: z.string().regex(/^sbst_/) + .describe('Opaque bearer token. This value is returned only when the token is created.'), + createdAt: z.string().datetime(), + expiresAt: z.string().datetime(), + repoIds: z.array(z.number().int().positive()).min(1), +}).openapi('PublicCreateScopedAccessTokenResponse'); diff --git a/packages/web/src/prisma.test.ts b/packages/web/src/prisma.test.ts new file mode 100644 index 000000000..b66307d59 --- /dev/null +++ b/packages/web/src/prisma.test.ts @@ -0,0 +1,103 @@ +import type { UserWithAccounts } from '@sourcebot/db'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('server-only', () => ({ + default: vi.fn(), +})); + +vi.mock('@sourcebot/shared', () => ({ + env: { NODE_ENV: 'test' }, + getDBConnectionString: () => undefined, +})); + +vi.mock('@/features/mcp/prismaScope', () => ({ + getMcpPrismaQueryExtension: () => ({}), +})); + +const { + getEffectiveRepoPermissionFilter, + getRepoPermissionFilterForUser, + intersectRepoWhere, +} = await import('./prisma'); + +const user = { accounts: [] } as unknown as UserWithAccounts; + +describe('getEffectiveRepoPermissionFilter', () => { + test('does not filter repositories when neither permission syncing nor a token scope applies', () => { + expect(getEffectiveRepoPermissionFilter({ + user, + hasPermissionSyncing: false, + })).toBeUndefined(); + }); + + test('enforces a token repository scope when permission syncing is disabled', () => { + expect(getEffectiveRepoPermissionFilter({ + user, + hasPermissionSyncing: false, + repositoryIds: [11, 22], + })).toEqual({ + id: { in: [11, 22] }, + }); + }); + + test('intersects current user permissions with the token repository scope', () => { + expect(getEffectiveRepoPermissionFilter({ + user, + hasPermissionSyncing: true, + repositoryIds: [11, 22], + })).toEqual({ + AND: [ + getRepoPermissionFilterForUser(user), + { id: { in: [11, 22] } }, + ], + }); + }); + + test('preserves an empty token scope as a match-nothing filter', () => { + expect(getEffectiveRepoPermissionFilter({ + user, + hasPermissionSyncing: false, + repositoryIds: [], + })).toEqual({ + id: { in: [] }, + }); + }); +}); + +describe('intersectRepoWhere', () => { + test('combines the caller filter and permission filter while preserving top-level fields', () => { + expect(intersectRepoWhere( + { OR: [{ name: 'repo-a' }, { name: 'repo-b' }] }, + { id: { in: [11] } }, + )).toEqual({ + OR: [{ name: 'repo-a' }, { name: 'repo-b' }], + AND: [{ id: { in: [11] } }], + }); + }); + + test('preserves a unique identifier at the top level for findUnique operations', () => { + expect(intersectRepoWhere( + { id: 147 }, + { id: { in: [11, 147] } }, + )).toEqual({ + id: 147, + AND: [{ id: { in: [11, 147] } }], + }); + }); + + test('preserves existing AND filters', () => { + expect(intersectRepoWhere( + { + id: 147, + AND: [{ name: 'github.com/airbnb/MaxScale' }], + }, + { id: { in: [11, 147] } }, + )).toEqual({ + id: 147, + AND: [ + { name: 'github.com/airbnb/MaxScale' }, + { id: { in: [11, 147] } }, + ], + }); + }); +}); diff --git a/packages/web/src/prisma.ts b/packages/web/src/prisma.ts index 654573af9..971322c6a 100644 --- a/packages/web/src/prisma.ts +++ b/packages/web/src/prisma.ts @@ -28,25 +28,33 @@ if (env.NODE_ENV !== "production") globalForPrisma.prisma = __unsafePrisma * Creates a prisma client extension that scopes queries to striclty information * a given user should be able to access. */ -export const userScopedPrismaClientExtension = async (user?: UserWithAccounts) => { +export const userScopedPrismaClientExtension = async ( + user?: UserWithAccounts, + repositoryIds?: readonly number[], +) => { const hasPermissionSyncing = env.PERMISSION_SYNC_ENABLED === 'true'; + const repoPermissionFilter = getEffectiveRepoPermissionFilter({ + user, + hasPermissionSyncing, + repositoryIds, + }); return Prisma.defineExtension( (prisma) => { return prisma.$extends({ query: { ...getMcpPrismaQueryExtension(user), - ...(hasPermissionSyncing ? { + ...(repoPermissionFilter ? { repo: { async $allOperations({ args, query }) { const argsWithWhere = args as Record & { where?: Prisma.RepoWhereInput; } - argsWithWhere.where = { - ...(argsWithWhere.where || {}), - ...getRepoPermissionFilterForUser(user), - }; + argsWithWhere.where = intersectRepoWhere( + argsWithWhere.where, + repoPermissionFilter, + ); return query(args); } @@ -55,7 +63,7 @@ export const userScopedPrismaClientExtension = async (user?: UserWithAccounts) = async $allOperations({ args, query }) { injectRepoPermissionFilterIntoRelation( args as Record, - getRepoPermissionFilterForUser(user), + repoPermissionFilter, ); return query(args); @@ -67,6 +75,58 @@ export const userScopedPrismaClientExtension = async (user?: UserWithAccounts) = }) } +export const intersectRepoWhere = ( + where: Prisma.RepoWhereInput | undefined, + repoPermissionFilter: Prisma.RepoWhereInput, +): Prisma.RepoWhereInput => { + if (!where) { + return repoPermissionFilter; + } + + const { AND: existingAnd, ...topLevelWhere } = where; + const existingAndFilters = existingAnd === undefined + ? [] + : Array.isArray(existingAnd) + ? existingAnd + : [existingAnd]; + + return { + ...topLevelWhere, + AND: [...existingAndFilters, repoPermissionFilter], + }; +}; + +export const getEffectiveRepoPermissionFilter = ({ + user, + hasPermissionSyncing, + repositoryIds, +}: { + user?: UserWithAccounts; + hasPermissionSyncing: boolean; + repositoryIds?: readonly number[]; +}): Prisma.RepoWhereInput | undefined => { + const filters: Prisma.RepoWhereInput[] = []; + + if (hasPermissionSyncing) { + filters.push(getRepoPermissionFilterForUser(user)); + } + if (repositoryIds) { + filters.push({ + id: { + in: [...repositoryIds], + }, + }); + } + + if (filters.length === 0) { + return undefined; + } + if (filters.length === 1) { + return filters[0]; + } + return { AND: filters }; +}; + /** * Injects a `Repo` permission filter into a nested `repos` relation referenced * by an operation's `include` or `select` clause. Mutates `args` in place,