Skip to content

Commit 3a4447b

Browse files
feat: add repository scoped tokens (#1549)
* feat: add repository-scoped access tokens * chore: add changelog entry for scoped access tokens * feat: scope access tokens by repository ID * feat: gate scoped access tokens by entitlement
1 parent a07c681 commit 3a4447b

24 files changed

Lines changed: 1652 additions & 73 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Added a manually triggered cloud image release workflow for isolated internal deployments. [#1566](https://github.com/sourcebot-dev/sourcebot/pull/1566)
1212
- Added Prometheus metrics for the web process, served on `WEB_METRICS_PORT` (default `3070`). [#1570](https://github.com/sourcebot-dev/sourcebot/pull/1570)
1313
- Added an `http_request_duration_seconds` metric recording web request latency by route, method, and status. [#1571](https://github.com/sourcebot-dev/sourcebot/pull/1571)
14+
- [EE] Added one-hour repository-scoped access tokens with public mint and revoke APIs. [#1549](https://github.com/sourcebot-dev/sourcebot/pull/1549)
1415

1516
### Fixed
1617
- Fixed the web process being capped at a ~4GiB heap regardless of how much memory the container has, which caused multi-second garbage collection pauses on larger deployments. [#1569](https://github.com/sourcebot-dev/sourcebot/pull/1569)

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

Lines changed: 217 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"name": "Git",
1919
"description": "Git history, diff, and file content endpoints."
2020
},
21+
{
22+
"name": "Scoped Access Tokens",
23+
"description": "Mint and revoke short-lived credentials restricted to specific repositories."
24+
},
2125
{
2226
"name": "System",
2327
"description": "System health and version endpoints."
@@ -1090,6 +1094,63 @@
10901094
"$ref": "#/components/schemas/PublicCommitAuthor"
10911095
}
10921096
},
1097+
"PublicCreateScopedAccessTokenResponse": {
1098+
"type": "object",
1099+
"properties": {
1100+
"id": {
1101+
"type": "string",
1102+
"description": "Identifier used to revoke the token."
1103+
},
1104+
"token": {
1105+
"type": "string",
1106+
"pattern": "^sbst_",
1107+
"description": "Opaque bearer token. This value is returned only when the token is created."
1108+
},
1109+
"createdAt": {
1110+
"type": "string",
1111+
"format": "date-time"
1112+
},
1113+
"expiresAt": {
1114+
"type": "string",
1115+
"format": "date-time"
1116+
},
1117+
"repoIds": {
1118+
"type": "array",
1119+
"items": {
1120+
"type": "integer",
1121+
"minimum": 0,
1122+
"exclusiveMinimum": true
1123+
},
1124+
"minItems": 1
1125+
}
1126+
},
1127+
"required": [
1128+
"id",
1129+
"token",
1130+
"createdAt",
1131+
"expiresAt",
1132+
"repoIds"
1133+
]
1134+
},
1135+
"PublicCreateScopedAccessTokenRequest": {
1136+
"type": "object",
1137+
"properties": {
1138+
"repoIds": {
1139+
"type": "array",
1140+
"items": {
1141+
"type": "integer",
1142+
"minimum": 0,
1143+
"exclusiveMinimum": true
1144+
},
1145+
"minItems": 1,
1146+
"description": "Repository IDs to bind to the token. Every ID must identify a repository accessible to the API-key owner."
1147+
}
1148+
},
1149+
"required": [
1150+
"repoIds"
1151+
],
1152+
"additionalProperties": false
1153+
},
10931154
"PublicEeUser": {
10941155
"type": "object",
10951156
"properties": {
@@ -1253,7 +1314,7 @@
12531314
"bearerToken": {
12541315
"type": "http",
12551316
"scheme": "bearer",
1256-
"description": "Bearer authentication header of the form `Bearer <token>`, where `<token>` is your API key."
1317+
"description": "Bearer authentication header of the form `Bearer <token>`. The token may be a Sourcebot API key, OAuth access token, or scoped access token, subject to endpoint requirements."
12571318
},
12581319
"apiKeyHeader": {
12591320
"type": "apiKey",
@@ -2266,6 +2327,161 @@
22662327
}
22672328
}
22682329
},
2330+
"/api/ee/scoped_access_token": {
2331+
"post": {
2332+
"operationId": "createScopedAccessToken",
2333+
"tags": [
2334+
"Scoped Access Tokens"
2335+
],
2336+
"summary": "Create a scoped access token",
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.",
2338+
"security": [
2339+
{
2340+
"bearerToken": []
2341+
},
2342+
{
2343+
"apiKeyHeader": []
2344+
}
2345+
],
2346+
"requestBody": {
2347+
"required": true,
2348+
"content": {
2349+
"application/json": {
2350+
"schema": {
2351+
"$ref": "#/components/schemas/PublicCreateScopedAccessTokenRequest"
2352+
}
2353+
}
2354+
}
2355+
},
2356+
"responses": {
2357+
"201": {
2358+
"description": "Scoped access token created. The opaque token value is returned only in this response.",
2359+
"content": {
2360+
"application/json": {
2361+
"schema": {
2362+
"$ref": "#/components/schemas/PublicCreateScopedAccessTokenResponse"
2363+
}
2364+
}
2365+
}
2366+
},
2367+
"400": {
2368+
"description": "Invalid request body or repository scope.",
2369+
"content": {
2370+
"application/json": {
2371+
"schema": {
2372+
"$ref": "#/components/schemas/PublicApiServiceError"
2373+
}
2374+
}
2375+
}
2376+
},
2377+
"401": {
2378+
"description": "Missing or invalid authentication.",
2379+
"content": {
2380+
"application/json": {
2381+
"schema": {
2382+
"$ref": "#/components/schemas/PublicApiServiceError"
2383+
}
2384+
}
2385+
}
2386+
},
2387+
"403": {
2388+
"description": "The current authentication method is not an API key, or the API-key owner is not permitted to perform this operation.",
2389+
"content": {
2390+
"application/json": {
2391+
"schema": {
2392+
"$ref": "#/components/schemas/PublicApiServiceError"
2393+
}
2394+
}
2395+
}
2396+
},
2397+
"500": {
2398+
"description": "Unexpected token creation failure.",
2399+
"content": {
2400+
"application/json": {
2401+
"schema": {
2402+
"$ref": "#/components/schemas/PublicApiServiceError"
2403+
}
2404+
}
2405+
}
2406+
}
2407+
}
2408+
}
2409+
},
2410+
"/api/ee/scoped_access_token/{id}": {
2411+
"delete": {
2412+
"operationId": "revokeScopedAccessToken",
2413+
"tags": [
2414+
"Scoped Access Tokens"
2415+
],
2416+
"summary": "Revoke a scoped access token",
2417+
"description": "Immediately revokes a scoped access token created by the authenticated API-key owner. This endpoint requires a Sourcebot API key.",
2418+
"security": [
2419+
{
2420+
"bearerToken": []
2421+
},
2422+
{
2423+
"apiKeyHeader": []
2424+
}
2425+
],
2426+
"parameters": [
2427+
{
2428+
"schema": {
2429+
"type": "string",
2430+
"description": "Identifier returned when the scoped access token was created."
2431+
},
2432+
"required": true,
2433+
"description": "Identifier returned when the scoped access token was created.",
2434+
"name": "id",
2435+
"in": "path"
2436+
}
2437+
],
2438+
"responses": {
2439+
"204": {
2440+
"description": "Scoped access token revoked."
2441+
},
2442+
"401": {
2443+
"description": "Missing or invalid authentication.",
2444+
"content": {
2445+
"application/json": {
2446+
"schema": {
2447+
"$ref": "#/components/schemas/PublicApiServiceError"
2448+
}
2449+
}
2450+
}
2451+
},
2452+
"403": {
2453+
"description": "The current authentication method is not an API key, or the API-key owner is not permitted to perform this operation.",
2454+
"content": {
2455+
"application/json": {
2456+
"schema": {
2457+
"$ref": "#/components/schemas/PublicApiServiceError"
2458+
}
2459+
}
2460+
}
2461+
},
2462+
"404": {
2463+
"description": "Scoped access token not found.",
2464+
"content": {
2465+
"application/json": {
2466+
"schema": {
2467+
"$ref": "#/components/schemas/PublicApiServiceError"
2468+
}
2469+
}
2470+
}
2471+
},
2472+
"500": {
2473+
"description": "Unexpected token revocation failure.",
2474+
"content": {
2475+
"application/json": {
2476+
"schema": {
2477+
"$ref": "#/components/schemas/PublicApiServiceError"
2478+
}
2479+
}
2480+
}
2481+
}
2482+
}
2483+
}
2484+
},
22692485
"/api/ee/user": {
22702486
"get": {
22712487
"operationId": "getUser",

docs/docs.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,14 @@
189189
"GET /api/repos"
190190
]
191191
},
192+
{
193+
"group": "Scoped Access Tokens",
194+
"icon": "key",
195+
"pages": [
196+
"POST /api/ee/scoped_access_token",
197+
"DELETE /api/ee/scoped_access_token/{id}"
198+
]
199+
},
192200
{
193201
"group": "Git",
194202
"icon": "code-branch",

docs/docs/api-reference/authentication.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,22 @@ curl -X POST https://your-sourcebot-instance.com/api/search \
3232
-H "Content-Type: application/json" \
3333
-d '{"query": "hello world", "matches": 10}'
3434
```
35+
36+
## Using a scoped access token
37+
38+
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:
39+
40+
```bash
41+
curl -X POST https://your-sourcebot-instance.com/api/ee/scoped_access_token \
42+
-H "Authorization: Bearer <your-api-key>" \
43+
-H "Content-Type: application/json" \
44+
-d '{"repos": ["github.com/acme/frontend", "github.com/acme/backend"]}'
45+
```
46+
47+
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:
48+
49+
```bash
50+
Authorization: Bearer <your-scoped-access-token>
51+
```
52+
53+
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.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- CreateTable
2+
CREATE TABLE "ScopedAccessToken" (
3+
"id" TEXT NOT NULL,
4+
"hash" TEXT NOT NULL,
5+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
6+
"expiresAt" TIMESTAMP(3) NOT NULL,
7+
"lastUsedAt" TIMESTAMP(3),
8+
"createdById" TEXT NOT NULL,
9+
"orgId" INTEGER NOT NULL,
10+
11+
CONSTRAINT "ScopedAccessToken_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- CreateTable
15+
CREATE TABLE "ScopedAccessTokenToRepo" (
16+
"tokenId" TEXT NOT NULL,
17+
"repoId" INTEGER NOT NULL,
18+
19+
CONSTRAINT "ScopedAccessTokenToRepo_pkey" PRIMARY KEY ("tokenId","repoId")
20+
);
21+
22+
-- CreateIndex
23+
CREATE UNIQUE INDEX "ScopedAccessToken_hash_key" ON "ScopedAccessToken"("hash");
24+
25+
-- CreateIndex
26+
CREATE INDEX "ScopedAccessToken_createdById_orgId_expiresAt_idx" ON "ScopedAccessToken"("createdById", "orgId", "expiresAt");
27+
28+
-- CreateIndex
29+
CREATE INDEX "ScopedAccessToken_expiresAt_idx" ON "ScopedAccessToken"("expiresAt");
30+
31+
-- CreateIndex
32+
CREATE INDEX "ScopedAccessTokenToRepo_repoId_idx" ON "ScopedAccessTokenToRepo"("repoId");
33+
34+
-- AddForeignKey
35+
ALTER TABLE "ScopedAccessToken" ADD CONSTRAINT "ScopedAccessToken_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
36+
37+
-- AddForeignKey
38+
ALTER TABLE "ScopedAccessToken" ADD CONSTRAINT "ScopedAccessToken_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
39+
40+
-- AddForeignKey
41+
ALTER TABLE "ScopedAccessTokenToRepo" ADD CONSTRAINT "ScopedAccessTokenToRepo_tokenId_fkey" FOREIGN KEY ("tokenId") REFERENCES "ScopedAccessToken"("id") ON DELETE CASCADE ON UPDATE CASCADE;
42+
43+
-- AddForeignKey
44+
ALTER TABLE "ScopedAccessTokenToRepo" ADD CONSTRAINT "ScopedAccessTokenToRepo_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ model Repo {
7171
defaultBranch String?
7272
7373
permittedAccounts AccountToRepoPermission[]
74+
scopedAccessTokens ScopedAccessTokenToRepo[]
7475
permissionSyncJobs RepoPermissionSyncJob[]
7576
permissionSyncedAt DateTime? /// When the permissions were last synced successfully.
7677
@@ -286,6 +287,7 @@ model Org {
286287
connections Connection[]
287288
repos Repo[]
288289
apiKeys ApiKey[]
290+
scopedAccessTokens ScopedAccessToken[]
289291
scimTokens ScimToken[]
290292
attachments Attachment[]
291293
isOnboarded Boolean @default(false)
@@ -454,6 +456,37 @@ model ApiKey {
454456
createdById String
455457
}
456458

459+
model ScopedAccessToken {
460+
id String @id @default(cuid())
461+
hash String @unique
462+
463+
createdAt DateTime @default(now())
464+
expiresAt DateTime
465+
lastUsedAt DateTime?
466+
467+
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
468+
createdById String
469+
470+
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
471+
orgId Int
472+
473+
repos ScopedAccessTokenToRepo[]
474+
475+
@@index([createdById, orgId, expiresAt])
476+
@@index([expiresAt])
477+
}
478+
479+
model ScopedAccessTokenToRepo {
480+
token ScopedAccessToken @relation(fields: [tokenId], references: [id], onDelete: Cascade)
481+
tokenId String
482+
483+
repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
484+
repoId Int
485+
486+
@@id([tokenId, repoId])
487+
@@index([repoId])
488+
}
489+
457490
/// Org-scoped bearer token presented by an IdP (Okta, Entra) to authenticate
458491
/// against the SCIM provisioning endpoints. Unlike `ApiKey`, a SCIM token is
459492
/// not tied to a user — it acts on behalf of the SCIM integration for the
@@ -509,6 +542,7 @@ model User {
509542
invites Invite[]
510543
511544
apiKeys ApiKey[]
545+
scopedAccessTokens ScopedAccessToken[]
512546
513547
chats Chat[]
514548
sharedChats ChatAccess[]

0 commit comments

Comments
 (0)