Skip to content

Commit 055e893

Browse files
TheodoreSpeaksclaude
authored andcommitted
feat(cli): sim CLI with AWS-style profiles and a platform key exchange
Adds `packages/sim-cli` (`@sim/cli`, bin `sim`) and extends the existing CLI key handoff so it can mint the credential the public API actually accepts. The handoff already existed but only minted *copilot* keys, which do not authenticate `/api/v1` or `/api/v2` — those want a Sim platform key. The approval now carries a `scope`: - `copilot` (the default, so terminals built against the original flow are unaffected) mints as before - `platform` mints a Sim API key: workspace-scoped when the approver is a workspace admin, personal otherwise Scope and workspace are fixed at *approval*, not at poll: the poll is unauthenticated by necessity, so the browser is the only moment a human is present to consent and the only place a permission can be checked. The poll echoes back what was granted rather than what was asked for, so the CLI cannot file a copilot key under a platform profile and fail later with an opaque 401. Picking a workspace and scoping a key to it are kept separate. The terminal has no key yet, so it cannot list workspaces — the browser picker is the only place that choice can be made, and the pick comes back as the profile's default whether or not the key is bound to it. Otherwise a non-admin would pick a workspace by name and then have to go find its id by hand. Personal-key creation moves into `lib/api-key/orchestration` so the settings route and the exchange share one issuer. Profiles work like the AWS CLI: `~/.sim/config` for settings (`[profile dev]`), `~/.sim/credentials` for keys at 0600 (`[dev]`), selected via `--profile` / `SIM_PROFILE`. Each setting resolves flag → env → file → default, and `sim whoami` reports the winning source so a surprising value is explainable. CI can skip login entirely with `SIM_API_KEY` + `SIM_WORKSPACE`. Commands cover the v2 surface pulled in earlier: workflows, logs, files, and knowledge, with `--output json` passing the API's own shapes through for `jq`. `sim tables` is deliberately absent — that surface is still in flux. The v2 routes were authored a month ago and had fallen behind their services: `checkActorUsageLimits(userId, workspaceId)` → the billing-attribution flow (which also restores correct payer attribution for workspace keys on KB upload and search), `processDocumentsWithQueue` gained a required argument, and the deploy/rollback param objects had stale fields. Caught by a cold type-check — an incremental run had reported these files clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj
1 parent d6505f6 commit 055e893

35 files changed

Lines changed: 3141 additions & 100 deletions

apps/sim/app/api/cli/auth/approve/route.test.ts

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { createHash } from 'node:crypto'
55
import { createMockRequest } from '@sim/testing'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit } = vi.hoisted(() => ({
9-
mockGetSession: vi.fn(),
10-
mockCreateApproval: vi.fn(),
11-
mockEnforceUserRateLimit: vi.fn(),
12-
}))
8+
const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions } =
9+
vi.hoisted(() => ({
10+
mockGetSession: vi.fn(),
11+
mockCreateApproval: vi.fn(),
12+
mockEnforceUserRateLimit: vi.fn(),
13+
mockGetPermissions: vi.fn(),
14+
}))
1315

1416
vi.mock('@/lib/auth', () => ({
1517
auth: { api: { getSession: vi.fn() } },
@@ -24,6 +26,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({
2426
enforceUserRateLimit: mockEnforceUserRateLimit,
2527
}))
2628

29+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
30+
getUserEntityPermissions: mockGetPermissions,
31+
}))
32+
2733
import { POST } from '@/app/api/cli/auth/approve/route'
2834

2935
const REQUEST = 'a'.repeat(43)
@@ -35,6 +41,7 @@ describe('POST /api/cli/auth/approve', () => {
3541
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
3642
mockEnforceUserRateLimit.mockResolvedValue(null)
3743
mockCreateApproval.mockResolvedValue(undefined)
44+
mockGetPermissions.mockResolvedValue('admin')
3845
})
3946

4047
it('records the approval for the signed-in user', async () => {
@@ -43,7 +50,112 @@ describe('POST /api/cli/auth/approve', () => {
4350
)
4451
expect(response.status).toBe(200)
4552
await expect(response.json()).resolves.toEqual({ ok: true })
46-
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE)
53+
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, {
54+
scope: 'copilot',
55+
workspaceId: undefined,
56+
workspaceBound: false,
57+
})
58+
})
59+
60+
it('defaults to the copilot scope so pre-scope terminals keep working', async () => {
61+
await POST(createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE }))
62+
expect(mockCreateApproval).toHaveBeenCalledWith(
63+
'user-1',
64+
REQUEST,
65+
CHALLENGE,
66+
expect.objectContaining({ scope: 'copilot' })
67+
)
68+
})
69+
70+
it('records a workspace binding when the approver is a workspace admin', async () => {
71+
const response = await POST(
72+
createMockRequest('POST', {
73+
request: REQUEST,
74+
challenge: CHALLENGE,
75+
scope: 'platform',
76+
workspaceId: 'ws-1',
77+
bindKeyToWorkspace: true,
78+
})
79+
)
80+
expect(response.status).toBe(200)
81+
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, {
82+
scope: 'platform',
83+
workspaceId: 'ws-1',
84+
workspaceBound: true,
85+
})
86+
})
87+
88+
it("records a non-admin's pick as a default without binding the key to it", async () => {
89+
mockGetPermissions.mockResolvedValue('write')
90+
const response = await POST(
91+
createMockRequest('POST', {
92+
request: REQUEST,
93+
challenge: CHALLENGE,
94+
scope: 'platform',
95+
workspaceId: 'ws-1',
96+
})
97+
)
98+
expect(response.status).toBe(200)
99+
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, {
100+
scope: 'platform',
101+
workspaceId: 'ws-1',
102+
workspaceBound: false,
103+
})
104+
})
105+
106+
it('refuses to bind a key to a workspace the approver is not admin of', async () => {
107+
mockGetPermissions.mockResolvedValue('write')
108+
const response = await POST(
109+
createMockRequest('POST', {
110+
request: REQUEST,
111+
challenge: CHALLENGE,
112+
scope: 'platform',
113+
workspaceId: 'ws-1',
114+
bindKeyToWorkspace: true,
115+
})
116+
)
117+
expect(response.status).toBe(403)
118+
expect(mockCreateApproval).not.toHaveBeenCalled()
119+
})
120+
121+
it('refuses a workspace the approver is not a member of', async () => {
122+
mockGetPermissions.mockResolvedValue(null)
123+
const response = await POST(
124+
createMockRequest('POST', {
125+
request: REQUEST,
126+
challenge: CHALLENGE,
127+
scope: 'platform',
128+
workspaceId: 'ws-1',
129+
})
130+
)
131+
expect(response.status).toBe(404)
132+
expect(mockCreateApproval).not.toHaveBeenCalled()
133+
})
134+
135+
it('refuses bindKeyToWorkspace with no workspaceId', async () => {
136+
const response = await POST(
137+
createMockRequest('POST', {
138+
request: REQUEST,
139+
challenge: CHALLENGE,
140+
scope: 'platform',
141+
bindKeyToWorkspace: true,
142+
})
143+
)
144+
expect(response.status).toBe(400)
145+
expect(mockCreateApproval).not.toHaveBeenCalled()
146+
})
147+
148+
it('refuses a workspace binding on the copilot scope', async () => {
149+
const response = await POST(
150+
createMockRequest('POST', {
151+
request: REQUEST,
152+
challenge: CHALLENGE,
153+
scope: 'copilot',
154+
workspaceId: 'ws-1',
155+
})
156+
)
157+
expect(response.status).toBe(400)
158+
expect(mockCreateApproval).not.toHaveBeenCalled()
47159
})
48160

49161
it('rejects an unauthenticated caller', async () => {
@@ -59,7 +171,7 @@ describe('POST /api/cli/auth/approve', () => {
59171
await POST(
60172
createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, userId: 'attacker' })
61173
)
62-
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE)
174+
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, expect.anything())
63175
})
64176

65177
it('rejects a malformed challenge', async () => {

apps/sim/app/api/cli/auth/approve/route.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth'
66
import { createApproval } from '@/lib/cli-auth/approval-store'
77
import { enforceUserRateLimit } from '@/lib/core/rate-limiter'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
910

1011
const logger = createLogger('CliAuthApproveAPI')
1112

@@ -16,6 +17,10 @@ const logger = createLogger('CliAuthApproveAPI')
1617
* The approving user comes from the session and nothing else — a client-supplied
1718
* user id here would let any caller approve a request redeemable for someone
1819
* else's key. No key is generated until the CLI polls.
20+
*
21+
* Workspace binding is authorized here rather than at poll time: the poll is
22+
* unauthenticated by necessity, so it has no session to check a permission
23+
* against. Approving is the only moment a human is present.
1924
*/
2025
export const POST = withRouteHandler(async (request: NextRequest) => {
2126
const session = await getSession()
@@ -29,8 +34,56 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2934
const parsed = await parseRequest(approveCliAuthContract, request, {})
3035
if (!parsed.success) return parsed.response
3136

32-
await createApproval(session.user.id, parsed.data.body.request, parsed.data.body.challenge)
33-
logger.info('Recorded CLI authorization approval', { userId: session.user.id })
37+
const { request: requestId, challenge, scope, workspaceId, bindKeyToWorkspace } = parsed.data.body
38+
39+
if ((workspaceId || bindKeyToWorkspace) && scope !== 'platform') {
40+
return NextResponse.json(
41+
{ error: 'workspaceId is only valid for the platform scope' },
42+
{ status: 400 }
43+
)
44+
}
45+
46+
if (bindKeyToWorkspace && !workspaceId) {
47+
return NextResponse.json(
48+
{ error: 'bindKeyToWorkspace requires a workspaceId' },
49+
{ status: 400 }
50+
)
51+
}
52+
53+
if (workspaceId) {
54+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
55+
56+
// Reading the workspace at all requires membership. Without this, the
57+
// terminal could be handed the id of a workspace the approver cannot see —
58+
// harmless for the key, but it would silently become the profile default and
59+
// every later command would 403 with no explanation.
60+
if (!permission) {
61+
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
62+
}
63+
64+
// Minting a workspace key is an admin action wherever else it is offered;
65+
// the terminal is not a lower bar. Rejected outright rather than downgraded
66+
// to a personal key, so the CLI never quietly stores a different credential
67+
// than the browser said it would.
68+
if (bindKeyToWorkspace && permission !== 'admin') {
69+
return NextResponse.json(
70+
{ error: 'Workspace admin permission is required to issue a workspace API key' },
71+
{ status: 403 }
72+
)
73+
}
74+
}
75+
76+
await createApproval(session.user.id, requestId, challenge, {
77+
scope,
78+
workspaceId,
79+
workspaceBound: bindKeyToWorkspace,
80+
})
81+
logger.info('Recorded CLI authorization approval', {
82+
userId: session.user.id,
83+
scope,
84+
workspaceId: workspaceId ?? null,
85+
workspaceBound: bindKeyToWorkspace,
86+
})
3487

3588
return NextResponse.json({ ok: true })
3689
})

0 commit comments

Comments
 (0)