Skip to content

Commit f16b71a

Browse files
authored
fix(auth): check a workspace's personal-key switch after membership (#9540)
* fix(auth): check a workspace's personal-key switch after membership, so a non-member never learns what it named exists * test(auth): import the key-switch error from the application barrel
1 parent 04b66d4 commit f16b71a

4 files changed

Lines changed: 22 additions & 22 deletions

File tree

‎apps/sim/lib/core/application/workspace-authorization.test.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -488,20 +488,19 @@ describe('authorizeWorkspaceOperation personal API key policy', () => {
488488
})
489489

490490
/**
491-
* The workspace column keeps its fail-fast: it is a property of the workspace
492-
* rather than of any group, so it is not the organization-configuration
493-
* oracle the group key would be, and three call-site suites pin the ordering.
491+
* The workspace column follows membership too: the context is loaded before
492+
* authorization, so refusing the column first would tell a non-member that the
493+
* workspace, and the resource it named in it, exist.
494494
*/
495-
it('keeps refusing the workspace personal-key column before the role lookup', async () => {
495+
it('conceals the workspace before refusing the workspace personal-key column', async () => {
496496
mocks.resolvePermission.mockResolvedValue(null)
497497

498498
await expect(
499499
authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, {
500500
...context,
501501
allowPersonalApiKeys: false,
502502
})
503-
).rejects.toBeInstanceOf(PersonalApiKeysDisabledError)
504-
expect(mocks.resolvePermission).not.toHaveBeenCalled()
503+
).rejects.toBeInstanceOf(NoWorkspaceAccessError)
505504
})
506505
})
507506

‎apps/sim/lib/core/application/workspace-authorization.ts‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -380,33 +380,32 @@ export async function authorizeWorkspaceOperation<C extends WorkspaceAuthorizati
380380
* narrows it further for one cohort inside an enterprise organization.
381381
* Either one saying no is a no.
382382
*
383-
* The column is checked before the role, deliberately: it is a property of
384-
* the workspace rather than of any group, it needs no query, and refusing
385-
* a key the workspace has switched off is the answer whatever the caller's
386-
* role turns out to be. The group key is not — it runs AFTER the role
387-
* check, for the reason {@link requireCurrentHumanRole} gives: it answers
388-
* with a `403` naming how an organization configured one cohort, and
389-
* running it ahead of the concealed {@link NoWorkspaceAccessError} would
390-
* hand that to a caller with no reach into the workspace at all.
383+
* Both run AFTER the role check, for the reason {@link requireCurrentHumanRole}
384+
* gives: each answers with a `403` naming how the workspace or its
385+
* organization is configured, and running it ahead of the concealed
386+
* {@link NoWorkspaceAccessError} would hand that to a caller with no reach
387+
* into the workspace. The context is loaded before authorization, so a
388+
* `403` there would also tell a non-member that the resource it named
389+
* exists, where a missing one answers `404`.
391390
*
392391
* `requireCurrentHumanAccess` is unrolled below so the group's
393392
* personal-key refusal sits between the role check and the operation's own
394393
* capability: the remedies differ, and the narrower one is worth naming
395394
* first.
396395
*/
396+
await requireCurrentHumanRole(principal.userId, context, operation.minimumRole, options)
397397
if (!context.allowPersonalApiKeys) {
398398
throw new PersonalApiKeysDisabledError()
399399
}
400-
await requireCurrentHumanRole(principal.userId, context, operation.minimumRole, options)
401400
await requirePersonalApiKeysAllowed(principal.userId, context, options?.executor)
402401
await requireCapability(principal.userId, context, operation, options?.executor)
403402
return
404403
/** OAuth scopes and expiry were checked before loading protected context. */
405404
case 'oauth_access_token': {
405+
await requireCurrentHumanRole(principal.userId, context, operation.minimumRole, options)
406406
if (!context.allowPersonalApiKeys) {
407407
throw new PersonalApiKeysDisabledError()
408408
}
409-
await requireCurrentHumanRole(principal.userId, context, operation.minimumRole, options)
410409
await requireUserCredentialCapabilities(principal, context, options?.executor)
411410
await requireCapability(principal.userId, context, operation, options?.executor)
412411
return

‎apps/sim/lib/table/application/authorization.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1111

1212
vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
1313

14+
import { PersonalApiKeysDisabledError } from '@/lib/core/application'
1415
import type { OrchestrationError } from '@/lib/core/orchestration/types'
1516
import { authorizeTableOperation } from '@/lib/table/application/authorization'
1617
import { tableOperations } from '@/lib/table/application/operations'
@@ -65,14 +66,14 @@ describe('table operation authorization', () => {
6566
await expectForbidden(createSessionPrincipal())
6667
})
6768

68-
it('rejects disabled personal keys before permission lookup', async () => {
69+
it("refuses a member's personal key when the workspace disables them", async () => {
70+
resolvePermission.mockResolvedValue('admin')
6971
await expect(
7072
authorizeTableOperation(createPersonalApiKeyPrincipal(), tableOperations.updateRow, {
7173
...authorizationContext,
7274
allowPersonalApiKeys: false,
7375
})
74-
).rejects.toMatchObject<Partial<OrchestrationError>>({ code: 'forbidden' })
75-
expect(resolvePermission).not.toHaveBeenCalled()
76+
).rejects.toBeInstanceOf(PersonalApiKeysDisabledError)
7677
})
7778

7879
it('allows workspace keys only in their credential workspace', async () => {

‎apps/sim/lib/workspace-files/application/authorization.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
99

1010
vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
1111

12+
import { PersonalApiKeysDisabledError } from '@/lib/core/application'
1213
import type { OrchestrationError } from '@/lib/core/orchestration/types'
1314
import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization'
1415
import { fileOperations } from '@/lib/workspace-files/application/operations'
@@ -53,14 +54,14 @@ describe('file operation authorization', () => {
5354
})
5455
})
5556

56-
it('fails a personal key immediately when the workspace disables it', async () => {
57+
it("refuses a member's personal key when the workspace disables them", async () => {
58+
resolvePermission.mockResolvedValue('admin')
5759
await expect(
5860
authorizeWorkspaceFileAccess(createPersonalApiKeyPrincipal(), fileOperations.rename, {
5961
...authorizationContext,
6062
allowPersonalApiKeys: false,
6163
})
62-
).rejects.toMatchObject<Partial<OrchestrationError>>({ code: 'forbidden' })
63-
expect(resolvePermission).not.toHaveBeenCalled()
64+
).rejects.toBeInstanceOf(PersonalApiKeysDisabledError)
6465
})
6566

6667
it('rejects a disallowed principal kind before principal-specific authorization', async () => {

0 commit comments

Comments
 (0)