Skip to content

Commit c39720c

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 377fd42 commit c39720c

10 files changed

Lines changed: 188 additions & 59 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
@@ -254,7 +254,7 @@ export interface AccountClient {
254254
getWorkspacePermissions: (params: { accountId: AccountUuid, permission: string }) => Promise<WorkspaceUuid[]>
255255
getWorkspaceUsersWithPermission: (params: { permission: string }) => Promise<AccountUuid[]>
256256

257-
createApiToken: (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number) => Promise<ApiTokenResult>
257+
createApiToken: (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number, scopes?: string[]) => Promise<ApiTokenResult>
258258
listApiTokens: () => Promise<ApiTokenInfo[]>
259259
revokeApiToken: (tokenId: string) => Promise<void>
260260
listWorkspaceApiTokens: (workspaceUuid: WorkspaceUuid) => Promise<ApiTokenInfo[]>
@@ -1207,10 +1207,10 @@ class AccountClientImpl implements AccountClient {
12071207
await this.rpc(request)
12081208
}
12091209

1210-
async createApiToken (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number): Promise<ApiTokenResult> {
1210+
async createApiToken (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number, scopes?: string[]): Promise<ApiTokenResult> {
12111211
const request = {
12121212
method: 'createApiToken' as const,
1213-
params: { name, workspaceUuid, expiryDays }
1213+
params: { name, workspaceUuid, expiryDays, scopes }
12141214
}
12151215

12161216
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
@@ -118,6 +118,7 @@ export interface ApiTokenInfo {
118118
createdOn: number
119119
expiresOn: number
120120
revoked: boolean
121+
scopes?: string[]
121122
}
122123

123124
export interface ApiTokenResult {

plugins/setting-assets/lang/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,11 @@
241241
"ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.",
242242
"ApiTokenNoTokens": "No API tokens yet",
243243
"ApiTokenWorkspace": "Workspace",
244+
"ApiTokenPermissions": "Permissions",
245+
"ApiTokenScopePreset": "Permissions",
246+
"ApiTokenScopeReadOnly": "Read Only",
247+
"ApiTokenScopeReadWrite": "Read & Write",
248+
"ApiTokenScopeFullAccess": "Full Access",
244249
"Created": "Created",
245250
"Expires": "Expires",
246251
"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
@@ -360,6 +360,11 @@ export default plugin(settingId, {
360360
ApiTokenCopyWarning: '' as IntlString,
361361
ApiTokenNoTokens: '' as IntlString,
362362
ApiTokenWorkspace: '' as IntlString,
363+
ApiTokenPermissions: '' as IntlString,
364+
ApiTokenScopePreset: '' as IntlString,
365+
ApiTokenScopeReadOnly: '' as IntlString,
366+
ApiTokenScopeReadWrite: '' as IntlString,
367+
ApiTokenScopeFullAccess: '' as IntlString,
363368
Created: '' as IntlString,
364369
Expires: '' as IntlString,
365370
TokenStatus: '' as IntlString,

pods/server/src/rpc.ts

Lines changed: 51 additions & 5 deletions
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,10 +233,16 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
205233
}
206234
}
207235

208-
// Roadmap: enforce token scopes here. When ApiToken gains a scopes field
209-
// (see server/account/src/types.ts), check decodedToken.extra?.scopes against
210-
// the `method` parameter to reject disallowed operations (e.g. reject 'tx'
211-
// for read-only tokens, filter find-all by allowed classes).
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+
}
212246

213247
let transactorRpc = rpcSessions.get(token)
214248

@@ -310,9 +344,21 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
310344
})
311345

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

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+
316362
if (tx._class === core.class.TxDomainEvent) {
317363
const domainTx = tx as TxDomainEvent
318364
const { result } = await session.domainRequestRaw(ctx, domainTx.domain, {

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export function getMigrations (ns: string, flavor: DBFlavor): [string, string][]
8282
getV22Migration(ns, flavor),
8383
getV23Migration(ns, flavor),
8484
getV24Migration(ns, flavor),
85-
getV25Migration(ns, flavor)
85+
getV25Migration(ns, flavor),
86+
getV26Migration(ns, flavor)
8687
]
8788
}
8889

@@ -814,3 +815,13 @@ function getV25Migration(ns: string, flavor: DBFlavor): [string, string] {
814815
`
815816
]
816817
}
818+
819+
function getV26Migration (ns: string, flavor: DBFlavor): [string, string] {
820+
return [
821+
'account_db_v26_add_api_token_scopes',
822+
`
823+
ALTER TABLE ${ns}.api_tokens
824+
ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT NULL;
825+
`
826+
]
827+
}

0 commit comments

Comments
 (0)