Skip to content

Commit 58c00a1

Browse files
dnplkndllclaude
andcommitted
feat: implement Phase 1 API token scopes (read/write/delete)
Add coarse-grained scope enforcement for API tokens. Tokens can now be created with scopes ['read:*'], ['read:*','write:*'], or ['read:*','write:*','delete:*']. Existing tokens without scopes retain full access (backward compatible). - DB: v26 migration adds scopes TEXT[] column to api_tokens - Types: add scopes field to ApiToken and ApiTokenInfo - Operations: createApiToken accepts/validates/persists scopes, embeds in JWT via extra.scopes - Enforcement: withSession checks scopes against method; tx handler additionally requires delete:* for TxRemoveDoc - Client: createApiToken signature accepts optional scopes param - UI: scope preset dropdown in create popup (default: Read Only), permissions column in token list with i18n labels - Also fixes 3 pre-existing TS2322/TS2345 errors in operations.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Don Kendall <kendall@donkendall.com>
1 parent 273ce3d commit 58c00a1

10 files changed

Lines changed: 190 additions & 14 deletions

File tree

foundations/core/packages/account-client/src/client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export interface AccountClient {
257257
getWorkspaceUsersWithPermission: (params: { permission: string }) => Promise<AccountUuid[]>
258258

259259
verify2fa: (code: string) => Promise<LoginInfo>
260-
createApiToken: (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number) => Promise<ApiTokenResult>
260+
createApiToken: (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number, scopes?: string[]) => Promise<ApiTokenResult>
261261
listApiTokens: () => Promise<ApiTokenInfo[]>
262262
revokeApiToken: (tokenId: string) => Promise<void>
263263
listWorkspaceApiTokens: (workspaceUuid: WorkspaceUuid) => Promise<ApiTokenInfo[]>
@@ -1232,10 +1232,10 @@ class AccountClientImpl implements AccountClient {
12321232
await this.rpc(request)
12331233
}
12341234

1235-
async createApiToken (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number): Promise<ApiTokenResult> {
1235+
async createApiToken (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number, scopes?: string[]): Promise<ApiTokenResult> {
12361236
const request = {
12371237
method: 'createApiToken' as const,
1238-
params: { name, workspaceUuid, expiryDays }
1238+
params: { name, workspaceUuid, expiryDays, scopes }
12391239
}
12401240

12411241
return await this.rpc(request)

foundations/core/packages/account-client/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export interface ApiTokenInfo {
120120
createdOn: number
121121
expiresOn: number
122122
revoked: boolean
123+
scopes?: string[]
123124
}
124125

125126
export interface ApiTokenResult {

plugins/setting-assets/lang/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,11 @@
250250
"ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.",
251251
"ApiTokenNoTokens": "No API tokens yet",
252252
"ApiTokenWorkspace": "Workspace",
253+
"ApiTokenPermissions": "Permissions",
254+
"ApiTokenScopePreset": "Permissions",
255+
"ApiTokenScopeReadOnly": "Read Only",
256+
"ApiTokenScopeReadWrite": "Read & Write",
257+
"ApiTokenScopeFullAccess": "Full Access",
253258
"Created": "Created",
254259
"Expires": "Expires",
255260
"TokenStatus": "Status",

plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,35 @@
3232
let copiedTime: Timestamp | undefined
3333
let copied = false
3434
35+
const scopePresets = [
36+
{ _id: 'read-only', label: 'Read Only', scopes: ['read:*'] },
37+
{ _id: 'read-write', label: 'Read & Write', scopes: ['read:*', 'write:*'] },
38+
{ _id: 'full-access', label: 'Full Access', scopes: ['read:*', 'write:*', 'delete:*'] }
39+
]
40+
let scopePresetItems: ListItem[] = scopePresets.map((p) => ({ _id: p._id, label: p.label }))
41+
let selectedScopePreset: ListItem = scopePresetItems[0]
42+
43+
async function resolveScopeLabels (): Promise<void> {
44+
const lang = $themeStore.language
45+
const labels = await Promise.all([
46+
translate(setting.string.ApiTokenScopeReadOnly, {}, lang),
47+
translate(setting.string.ApiTokenScopeReadWrite, {}, lang),
48+
translate(setting.string.ApiTokenScopeFullAccess, {}, lang)
49+
])
50+
const prevId = selectedScopePreset._id
51+
scopePresetItems = [
52+
{ _id: 'read-only', label: labels[0] },
53+
{ _id: 'read-write', label: labels[1] },
54+
{ _id: 'full-access', label: labels[2] }
55+
]
56+
selectedScopePreset = scopePresetItems.find((o) => o._id === prevId) ?? scopePresetItems[0]
57+
}
58+
59+
function getSelectedScopes (): string[] {
60+
const preset = scopePresets.find((p) => p._id === selectedScopePreset._id)
61+
return preset?.scopes ?? ['read:*']
62+
}
63+
3564
const expiryKeys = [
3665
{ _id: '7', intl: setting.string.ApiTokenExpiry7Days },
3766
{ _id: '30', intl: setting.string.ApiTokenExpiry30Days },
@@ -72,10 +101,12 @@
72101
loading = true
73102
error = undefined
74103
try {
104+
const scopes = getSelectedScopes()
75105
const result = await getAccountClient().createApiToken(
76106
name.trim(),
77107
selectedWs._id as WorkspaceUuid,
78-
parseInt(selectedExpiry._id, 10)
108+
parseInt(selectedExpiry._id, 10),
109+
scopes
79110
)
80111
createdToken = result.token
81112
} catch (err: any) {
@@ -99,6 +130,7 @@
99130
onMount(() => {
100131
void loadWorkspaces()
101132
void resolveExpiryLabels()
133+
void resolveScopeLabels()
102134
})
103135
</script>
104136

@@ -139,6 +171,10 @@
139171
<span class="label"><Label label={setting.string.ApiTokenWorkspace} /></span>
140172
<Dropdown placeholder={setting.string.ApiTokenWorkspace} items={wsItems} bind:selected={selectedWs} />
141173
</div>
174+
<div class="antiPopup-msg">
175+
<span class="label"><Label label={setting.string.ApiTokenScopePreset} /></span>
176+
<Dropdown placeholder={setting.string.ApiTokenScopePreset} items={scopePresetItems} bind:selected={selectedScopePreset} />
177+
</div>
142178
<div class="antiPopup-msg">
143179
<span class="label"><Label label={setting.string.ApiTokenExpiry} /></span>
144180
<Dropdown placeholder={setting.string.ApiTokenExpiry} items={expiryOptions} bind:selected={selectedExpiry} />

plugins/setting-resources/src/components/ApiTokens.svelte

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<script lang="ts">
1616
import { Breadcrumb, Header, IconAdd, Label, Loading, ModernButton, Scroller, showPopup } from '@hcengineering/ui'
1717
import { MessageBox } from '@hcengineering/presentation'
18+
import { translate } from '@hcengineering/platform'
1819
import setting from '@hcengineering/setting'
1920
import { type ApiTokenInfo } from '@hcengineering/account-client'
2021
import { getAccountClient } from '../utils'
@@ -83,6 +84,33 @@
8384
})
8485
}
8586
87+
let scopeLabels = {
88+
readOnly: 'Read Only',
89+
readWrite: 'Read & Write',
90+
fullAccess: 'Full Access'
91+
}
92+
93+
async function resolveScopeLabels (): Promise<void> {
94+
const lang = $themeStore.language
95+
scopeLabels = {
96+
readOnly: await translate(setting.string.ApiTokenScopeReadOnly, {}, lang),
97+
readWrite: await translate(setting.string.ApiTokenScopeReadWrite, {}, lang),
98+
fullAccess: await translate(setting.string.ApiTokenScopeFullAccess, {}, lang)
99+
}
100+
}
101+
102+
function getScopeLabel (token: ApiTokenInfo): string {
103+
const scopes = token.scopes
104+
if (scopes == null || scopes.length === 0) return scopeLabels.fullAccess
105+
const hasRead = scopes.includes('read:*')
106+
const hasWrite = scopes.includes('write:*')
107+
const hasDelete = scopes.includes('delete:*')
108+
if (hasRead && hasWrite && hasDelete && scopes.length === 3) return scopeLabels.fullAccess
109+
if (hasRead && hasWrite && scopes.length === 2) return scopeLabels.readWrite
110+
if (hasRead && scopes.length === 1) return scopeLabels.readOnly
111+
return `${scopes.length} scopes`
112+
}
113+
86114
function getStatus (token: ApiTokenInfo): 'active' | 'expiring' | 'revoked' | 'expired' {
87115
if (token.revoked) return 'revoked'
88116
const now = Date.now()
@@ -93,6 +121,7 @@
93121
94122
onMount(() => {
95123
loadTokens()
124+
void resolveScopeLabels()
96125
})
97126
</script>
98127

@@ -132,6 +161,7 @@
132161
<tr class="scroller-thead__tr">
133162
<th><Label label={setting.string.ApiTokenName} /></th>
134163
<th><Label label={setting.string.ApiTokenWorkspace} /></th>
164+
<th><Label label={setting.string.ApiTokenPermissions} /></th>
135165
<th><Label label={setting.string.Created} /></th>
136166
<th><Label label={setting.string.Expires} /></th>
137167
<th><Label label={setting.string.TokenStatus} /></th>
@@ -144,6 +174,7 @@
144174
<tr class="antiGrid-row">
145175
<td class="overflow-label font-medium-14">{token.name}</td>
146176
<td class="overflow-label">{token.workspaceName}</td>
177+
<td><span class="tag-item tag-scope">{getScopeLabel(token)}</span></td>
147178
<td>{formatDate(token.createdOn)}</td>
148179
<td>{token.revoked ? '' : formatDate(token.expiresOn)}</td>
149180
<td>
@@ -201,4 +232,8 @@
201232
background-color: var(--tag-accent-FlamingoColor);
202233
color: var(--tag-on-accent-FlamingoColor);
203234
}
235+
.tag-scope {
236+
background-color: var(--theme-button-default);
237+
color: var(--theme-content-color);
238+
}
204239
</style>

plugins/setting/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,11 @@ export default plugin(settingId, {
371371
ApiTokenCopyWarning: '' as IntlString,
372372
ApiTokenNoTokens: '' as IntlString,
373373
ApiTokenWorkspace: '' as IntlString,
374+
ApiTokenPermissions: '' as IntlString,
375+
ApiTokenScopePreset: '' as IntlString,
376+
ApiTokenScopeReadOnly: '' as IntlString,
377+
ApiTokenScopeReadWrite: '' as IntlString,
378+
ApiTokenScopeFullAccess: '' as IntlString,
374379
Created: '' as IntlString,
375380
Expires: '' as IntlString,
376381
TokenStatus: '' as IntlString,

pods/server/src/rpc.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,34 @@ async function isApiTokenRevoked (apiTokenId: string, accountClient: AccountClie
158158
return cached.revoked
159159
}
160160

161+
// ── Token Scope Enforcement ─────────────────────────────────────────
162+
// Phase 1: coarse scopes only (read:*, write:*, delete:*)
163+
164+
function hasScope (scopes: string[], required: string): boolean {
165+
return scopes.includes(required)
166+
}
167+
168+
function getRequiredScope (method: string): string | null {
169+
switch (method) {
170+
case 'ping':
171+
case 'generateId':
172+
return null // Always allowed
173+
case 'findAll':
174+
case 'searchFulltext':
175+
case 'loadModel':
176+
case 'account':
177+
return 'read:*'
178+
case 'tx':
179+
// write:* checked here; delete:* checked after body parsing in the tx handler
180+
return 'write:*'
181+
case 'domainRequest':
182+
case 'ensurePerson':
183+
return 'write:*'
184+
default:
185+
return 'read:*'
186+
}
187+
}
188+
161189
export function registerRPC (app: Express, sessions: SessionManager, ctx: MeasureContext, accountsUrl: string): void {
162190
const rpcSessions = new Map<string, RPCClientInfo>()
163191

@@ -205,6 +233,17 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
205233
}
206234
}
207235

236+
// Enforce token scopes (Phase 1: coarse scopes — read:*, write:*, delete:*)
237+
const scopesRaw = decodedToken.extra?.scopes
238+
if (scopesRaw !== undefined) {
239+
const scopes: string[] = JSON.parse(scopesRaw)
240+
const requiredScope = getRequiredScope(method)
241+
if (requiredScope !== null && !hasScope(scopes, requiredScope)) {
242+
sendError(res, 403, { message: 'Insufficient token scope', required: requiredScope })
243+
return
244+
}
245+
}
246+
208247
let transactorRpc = rpcSessions.get(token)
209248

210249
if (transactorRpc === undefined) {
@@ -305,9 +344,21 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
305344
})
306345

307346
app.post('/api/v1/tx/:workspaceId', (req, res) => {
308-
void withSession(req, res, 'tx', async (ctx, session, rateLimit) => {
347+
void withSession(req, res, 'tx', async (ctx, session, rateLimit, token) => {
309348
const tx: any = (await retrieveJson(req)) ?? {}
310349

350+
// Enforce delete:* scope for remove transactions
351+
if (tx._class === core.class.TxRemoveDoc) {
352+
const scopesStr = decodeToken(token).extra?.scopes
353+
if (scopesStr !== undefined) {
354+
const scopes: string[] = JSON.parse(scopesStr)
355+
if (!scopes.includes('delete:*')) {
356+
sendError(res, 403, { message: 'Insufficient token scope', required: 'delete:*' })
357+
return
358+
}
359+
}
360+
}
361+
311362
if (tx._class === core.class.TxDomainEvent) {
312363
const domainTx = tx as TxDomainEvent
313364
const { result } = await session.domainRequestRaw(ctx, domainTx.domain, {

server/account/src/collections/postgres/migrations.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,3 +826,13 @@ function getV26Migration (ns: string, flavor: DBFlavor): [string, string] {
826826
`
827827
]
828828
}
829+
830+
function getV26Migration (ns: string, flavor: DBFlavor): [string, string] {
831+
return [
832+
'account_db_v26_add_api_token_scopes',
833+
`
834+
ALTER TABLE ${ns}.api_tokens
835+
ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT NULL;
836+
`
837+
]
838+
}

server/account/src/operations.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ export async function validateOtp (
418418
throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {}))
419419
}
420420

421-
let callerAccountUuid: AccountUuid | null = null
421+
let callerAccountUuid: AccountUuid | undefined
422422
let callerAccount: Account | null = null
423423

424424
if (action === 'verify') {
@@ -1967,7 +1967,7 @@ export async function getLoginInfoByToken (
19671967
let extra: any
19681968
let grant: PermissionsGrant | undefined
19691969
let sub: AccountUuid | undefined
1970-
let account: AccountUuid | undefined
1970+
let account: AccountUuid
19711971
let nbf: number | undefined
19721972
let exp: number | undefined
19731973

@@ -2574,6 +2574,7 @@ async function deleteMailbox (
25742574
* @param params.name Human-readable token name (1–255 chars)
25752575
* @param params.workspaceUuid Target workspace — user must have access
25762576
* @param params.expiryDays Token validity period (1–365 days)
2577+
* @param params.scopes Optional scopes array. NULL/undefined = full access. e.g. ['read:*', 'write:*']
25772578
* @returns Token ID, signed JWT, and expiration timestamp (ms)
25782579
* @throws BadRequest if validation fails
25792580
* @throws Forbidden if user lacks workspace access
@@ -2587,9 +2588,10 @@ async function createApiToken (
25872588
name: string
25882589
workspaceUuid: WorkspaceUuid
25892590
expiryDays: number
2591+
scopes?: string[]
25902592
}
25912593
): Promise<{ id: string, token: string, expiresOn: number }> {
2592-
const { name, workspaceUuid, expiryDays } = params
2594+
const { name, workspaceUuid, expiryDays, scopes } = params
25932595

25942596
if (
25952597
name == null ||
@@ -2625,12 +2627,29 @@ async function createApiToken (
26252627
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
26262628
}
26272629

2630+
// Validate scopes if provided
2631+
const validScopePattern = /^(read|write|delete):\*$/
2632+
if (scopes !== undefined) {
2633+
if (!Array.isArray(scopes) || scopes.length === 0) {
2634+
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
2635+
}
2636+
for (const scope of scopes) {
2637+
if (typeof scope !== 'string' || !validScopePattern.test(scope)) {
2638+
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
2639+
}
2640+
}
2641+
}
2642+
26282643
const now = Date.now()
26292644
const expiresOn = now + days * 86400000
26302645
const expSec = Math.floor(expiresOn / 1000)
26312646

26322647
const id = randomUUID()
2633-
const apiToken = generateToken(account, workspaceUuid, { apiTokenId: id }, undefined, { exp: expSec })
2648+
const extra: Record<string, string> = { apiTokenId: id }
2649+
if (scopes !== undefined) {
2650+
extra.scopes = JSON.stringify(scopes)
2651+
}
2652+
const apiToken = generateToken(account, workspaceUuid, extra, undefined, { exp: expSec })
26342653

26352654
await db.apiToken.insertOne({
26362655
id,
@@ -2639,10 +2658,11 @@ async function createApiToken (
26392658
workspaceUuid,
26402659
createdOn: now,
26412660
expiresOn,
2642-
revoked: false
2661+
revoked: false,
2662+
scopes
26432663
})
26442664

2645-
ctx.info('API token created', { id, account, workspaceUuid, days })
2665+
ctx.info('API token created', { id, account, workspaceUuid, days, scopes })
26462666
return { id, token: apiToken, expiresOn }
26472667
}
26482668

@@ -2680,7 +2700,8 @@ async function listApiTokens (
26802700
workspaceName: wsMap.get(t.workspaceUuid) ?? t.workspaceUuid,
26812701
createdOn: t.createdOn,
26822702
expiresOn: t.expiresOn,
2683-
revoked: t.revoked
2703+
revoked: t.revoked,
2704+
scopes: t.scopes ?? undefined
26842705
}))
26852706
}
26862707

@@ -2764,7 +2785,8 @@ async function listWorkspaceApiTokens (
27642785
workspaceUuid: t.workspaceUuid,
27652786
createdOn: t.createdOn,
27662787
expiresOn: t.expiresOn,
2767-
revoked: t.revoked
2788+
revoked: t.revoked,
2789+
scopes: t.scopes ?? undefined
27682790
}))
27692791
}
27702792

@@ -3210,7 +3232,7 @@ export async function getSubscriptions (
32103232
targetWorkspace = tokenWorkspace
32113233

32123234
// Verify user has OWNER or MAINTAINER role
3213-
const role = await db.getWorkspaceRole(account, targetWorkspace)
3235+
const role = await db.getWorkspaceRole(account, tokenWorkspace)
32143236
if (role === null) {
32153237
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
32163238
}

0 commit comments

Comments
 (0)