Skip to content

Commit 4ab82f7

Browse files
authored
chore(clerk-js): Add permission checks to API keys component (#6253)
1 parent a732143 commit 4ab82f7

File tree

11 files changed

+194
-50
lines changed

11 files changed

+194
-50
lines changed

.changeset/cuddly-kiwis-rush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/clerk-js": minor
3+
---
4+
5+
Added granular permission checks to `<APIKeys />` component to support read-only and manage roles

integration/tests/machine-auth/component.test.ts

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,33 @@
11
import { expect, test } from '@playwright/test';
22

33
import { appConfigs } from '../../presets';
4-
import type { FakeUser } from '../../testUtils';
4+
import type { FakeOrganization, FakeUser } from '../../testUtils';
55
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
66

77
testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @generic', ({ app }) => {
88
test.describe.configure({ mode: 'serial' });
99

10-
let fakeUser: FakeUser;
10+
let fakeAdmin: FakeUser;
11+
let fakeOrganization: FakeOrganization;
1112

1213
test.beforeAll(async () => {
1314
const u = createTestUtils({ app });
14-
fakeUser = u.services.users.createFakeUser();
15-
await u.services.users.createBapiUser(fakeUser);
15+
fakeAdmin = u.services.users.createFakeUser();
16+
const admin = await u.services.users.createBapiUser(fakeAdmin);
17+
fakeOrganization = await u.services.users.createFakeOrganization(admin.id);
1618
});
1719

1820
test.afterAll(async () => {
19-
await fakeUser.deleteIfExists();
21+
await fakeOrganization.delete();
22+
await fakeAdmin.deleteIfExists();
2023
await app.teardown();
2124
});
2225

2326
test('can create api keys', async ({ page, context }) => {
2427
const u = createTestUtils({ app, page, context });
2528
await u.po.signIn.goTo();
2629
await u.po.signIn.waitForMounted();
27-
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
30+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
2831
await u.po.expect.toBeSignedIn();
2932

3033
await u.po.page.goToRelative('/api-keys');
@@ -33,7 +36,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge
3336
// Create API key 1
3437
await u.po.apiKeys.clickAddButton();
3538
await u.po.apiKeys.waitForFormOpened();
36-
await u.po.apiKeys.typeName(`${fakeUser.firstName}-api-key-1`);
39+
await u.po.apiKeys.typeName(`${fakeAdmin.firstName}-api-key-1`);
3740
await u.po.apiKeys.selectExpiration('1d');
3841
await u.po.apiKeys.clickSaveButton();
3942

@@ -42,7 +45,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge
4245
// Create API key 2
4346
await u.po.apiKeys.clickAddButton();
4447
await u.po.apiKeys.waitForFormOpened();
45-
await u.po.apiKeys.typeName(`${fakeUser.firstName}-api-key-2`);
48+
await u.po.apiKeys.typeName(`${fakeAdmin.firstName}-api-key-2`);
4649
await u.po.apiKeys.selectExpiration('7d');
4750
await u.po.apiKeys.clickSaveButton();
4851

@@ -54,13 +57,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge
5457
const u = createTestUtils({ app, page, context });
5558
await u.po.signIn.goTo();
5659
await u.po.signIn.waitForMounted();
57-
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
60+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
5861
await u.po.expect.toBeSignedIn();
5962

6063
await u.po.page.goToRelative('/api-keys');
6164
await u.po.apiKeys.waitForMounted();
6265

63-
const apiKeyName = `${fakeUser.firstName}-${Date.now()}`;
66+
const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`;
6467

6568
// Create API key
6669
await u.po.apiKeys.clickAddButton();
@@ -95,13 +98,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge
9598
const u = createTestUtils({ app, page, context });
9699
await u.po.signIn.goTo();
97100
await u.po.signIn.waitForMounted();
98-
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
101+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
99102
await u.po.expect.toBeSignedIn();
100103

101104
await u.po.page.goToRelative('/api-keys');
102105
await u.po.apiKeys.waitForMounted();
103106

104-
const apiKeyName = `${fakeUser.firstName}-${Date.now()}`;
107+
const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`;
105108

106109
// Create API key
107110
await u.po.apiKeys.clickAddButton();
@@ -133,13 +136,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge
133136
const u = createTestUtils({ app, page, context });
134137
await u.po.signIn.goTo();
135138
await u.po.signIn.waitForMounted();
136-
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
139+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
137140
await u.po.expect.toBeSignedIn();
138141

139142
await u.po.page.goToRelative('/api-keys');
140143
await u.po.apiKeys.waitForMounted();
141144

142-
const apiKeyName = `${fakeUser.firstName}-${Date.now()}`;
145+
const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`;
143146

144147
// Create API key
145148
await u.po.apiKeys.clickAddButton();
@@ -169,4 +172,82 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge
169172
await row.locator('.cl-apiKeysRevealButton').click();
170173
await expect(row.locator('input')).toHaveAttribute('type', 'password');
171174
});
175+
176+
test('component does not render for orgs when user does not have permissions', async ({ page, context }) => {
177+
const u = createTestUtils({ app, page, context });
178+
179+
const fakeMember = u.services.users.createFakeUser();
180+
const member = await u.services.users.createBapiUser(fakeMember);
181+
182+
await u.services.clerk.organizations.createOrganizationMembership({
183+
organizationId: fakeOrganization.organization.id,
184+
role: 'org:member',
185+
userId: member.id,
186+
});
187+
188+
await u.po.signIn.goTo();
189+
await u.po.signIn.waitForMounted();
190+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeMember.email, password: fakeMember.password });
191+
await u.po.expect.toBeSignedIn();
192+
193+
let apiKeysRequestWasMade = false;
194+
u.page.on('request', request => {
195+
if (request.url().includes('/api_keys')) {
196+
apiKeysRequestWasMade = true;
197+
}
198+
});
199+
200+
// Check that standalone component is not rendered
201+
await u.po.page.goToRelative('/api-keys');
202+
await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 });
203+
204+
// Check that page is not rendered in OrganizationProfile
205+
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
206+
await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 });
207+
208+
expect(apiKeysRequestWasMade).toBe(false);
209+
210+
await fakeMember.deleteIfExists();
211+
});
212+
213+
test('user with read permission can view API keys but not manage them', async ({ page, context }) => {
214+
const u = createTestUtils({ app, page, context });
215+
216+
const fakeViewer = u.services.users.createFakeUser();
217+
const viewer = await u.services.users.createBapiUser(fakeViewer);
218+
219+
await u.services.clerk.organizations.createOrganizationMembership({
220+
organizationId: fakeOrganization.organization.id,
221+
role: 'org:viewer',
222+
userId: viewer.id,
223+
});
224+
225+
await u.po.signIn.goTo();
226+
await u.po.signIn.waitForMounted();
227+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeViewer.email, password: fakeViewer.password });
228+
await u.po.expect.toBeSignedIn();
229+
230+
let apiKeysRequestWasMade = false;
231+
u.page.on('request', request => {
232+
if (request.url().includes('/api_keys')) {
233+
apiKeysRequestWasMade = true;
234+
}
235+
});
236+
237+
// Check that standalone component is rendered and user can read API keys
238+
await u.po.page.goToRelative('/api-keys');
239+
await u.po.apiKeys.waitForMounted();
240+
await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden();
241+
await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden();
242+
243+
// Check that page is rendered in OrganizationProfile and user can read API keys
244+
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
245+
await expect(u.page.locator('.cl-apiKeys')).toBeVisible();
246+
await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden();
247+
await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden();
248+
249+
expect(apiKeysRequestWasMade).toBe(true);
250+
251+
await fakeViewer.deleteIfExists();
252+
});
172253
});

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{ "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
7-
{ "path": "./dist/ui-common*.js", "maxSize": "111.8KB" },
7+
{ "path": "./dist/ui-common*.js", "maxSize": "111.9KB" },
88
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "113.67KB" },
99
{ "path": "./dist/vendors*.js", "maxSize": "40.2KB" },
1010
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },

packages/clerk-js/src/core/clerk.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ import type { MountComponentRenderer } from '../ui/Components';
8686
import {
8787
ALLOWED_PROTOCOLS,
8888
buildURL,
89+
canViewOrManageAPIKeys,
8990
completeSignUpFlow,
9091
createAllowedRedirectOrigins,
9192
createBeforeUnloadTracker,
@@ -168,6 +169,7 @@ const CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE = 'cannot_render_organizat
168169
const CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE = 'cannot_render_organization_missing';
169170
const CANNOT_RENDER_SINGLE_SESSION_ENABLED_ERROR_CODE = 'cannot_render_single_session_enabled';
170171
const CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE = 'cannot_render_api_keys_disabled';
172+
const CANNOT_RENDER_API_KEYS_ORG_UNAUTHORIZED_ERROR_CODE = 'cannot_render_api_keys_org_unauthorized';
171173
const defaultOptions: ClerkOptions = {
172174
polling: true,
173175
standardBrowser: true,
@@ -1099,6 +1101,16 @@ export class Clerk implements ClerkInterface {
10991101
}
11001102
return;
11011103
}
1104+
1105+
if (this.organization && !canViewOrManageAPIKeys(this)) {
1106+
if (this.#instanceType === 'development') {
1107+
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponentForOrgWhenUnauthorized, {
1108+
code: CANNOT_RENDER_API_KEYS_ORG_UNAUTHORIZED_ERROR_CODE,
1109+
});
1110+
}
1111+
return;
1112+
}
1113+
11021114
void this.#componentControls.ensureMounted({ preloadHint: 'APIKeys' }).then(controls =>
11031115
controls.mountComponent({
11041116
name: 'APIKeys',

packages/clerk-js/src/core/warnings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const warnings = {
4040
'The SignIn or SignUp modals do not render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, this is no-op.',
4141
cannotRenderAPIKeysComponent:
4242
'The <APIKeys/> component cannot be rendered when API keys is disabled. Since API keys is disabled, this is no-op.',
43+
cannotRenderAPIKeysComponentForOrgWhenUnauthorized:
44+
'The <APIKeys/> component cannot be rendered for an organization unless a user has the required permissions. Since the user does not have the necessary permissions, this is no-op.',
4345
};
4446

4547
type SerializableWarnings = Serializable<typeof warnings>;

packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { CreateAPIKeyParams } from '@clerk/types';
44
import { lazy, useState } from 'react';
55
import useSWRMutation from 'swr/mutation';
66

7+
import { useProtect } from '@/ui/common';
78
import { useApiKeysContext, withCoreUserGuard } from '@/ui/contexts';
89
import {
910
Box,
@@ -22,6 +23,7 @@ import { InputWithIcon } from '@/ui/elements/InputWithIcon';
2223
import { Pagination } from '@/ui/elements/Pagination';
2324
import { MagnifyingGlass } from '@/ui/icons';
2425
import { mqu } from '@/ui/styledSystem';
26+
import { isOrganizationId } from '@/utils';
2527

2628
import { ApiKeysTable } from './ApiKeysTable';
2729
import type { OnCreateParams } from './CreateApiKeyForm';
@@ -41,6 +43,10 @@ const RevokeAPIKeyConfirmationModal = lazy(() =>
4143
);
4244

4345
export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPageProps) => {
46+
const isOrg = isOrganizationId(subject);
47+
const canReadAPIKeys = useProtect({ permission: 'org:sys_api_keys:read' });
48+
const canManageAPIKeys = useProtect({ permission: 'org:sys_api_keys:manage' });
49+
4450
const {
4551
apiKeys,
4652
isLoading,
@@ -53,7 +59,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
5359
startingRow,
5460
endingRow,
5561
cacheKey,
56-
} = useApiKeys({ subject, perPage });
62+
} = useApiKeys({ subject, perPage, enabled: isOrg ? canReadAPIKeys : true });
5763
const card = useCardState();
5864
const { trigger: createApiKey, isMutating } = useSWRMutation(cacheKey, (_, { arg }: { arg: CreateAPIKeyParams }) =>
5965
clerk.apiKeys.create(arg),
@@ -118,16 +124,18 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
118124
elementDescriptor={descriptors.apiKeysSearchInput}
119125
/>
120126
</Box>
121-
<Action.Trigger
122-
value='add-api-key'
123-
hideOnActive={false}
124-
>
125-
<Button
126-
variant='solid'
127-
localizationKey={localizationKeys('apiKeys.action__add')}
128-
elementDescriptor={descriptors.apiKeysAddButton}
129-
/>
130-
</Action.Trigger>
127+
{((isOrg && canManageAPIKeys) || !isOrg) && (
128+
<Action.Trigger
129+
value='add-api-key'
130+
hideOnActive={false}
131+
>
132+
<Button
133+
variant='solid'
134+
localizationKey={localizationKeys('apiKeys.action__add')}
135+
elementDescriptor={descriptors.apiKeysAddButton}
136+
/>
137+
</Action.Trigger>
138+
)}
131139
</Flex>
132140
<Action.Open value='add-api-key'>
133141
<Flex sx={t => ({ paddingTop: t.space.$6, paddingBottom: t.space.$6 })}>
@@ -145,6 +153,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
145153
isLoading={isLoading}
146154
onRevoke={handleRevoke}
147155
elementDescriptor={descriptors.apiKeysTable}
156+
canManageAPIKeys={(isOrg && canManageAPIKeys) || !isOrg}
148157
/>
149158
{itemCount > (perPage ?? 5) && (
150159
<Pagination

packages/clerk-js/src/ui/components/ApiKeys/ApiKeysTable.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,13 @@ export const ApiKeysTable = ({
111111
isLoading,
112112
onRevoke,
113113
elementDescriptor,
114+
canManageAPIKeys,
114115
}: {
115116
rows: APIKeyResource[];
116117
isLoading: boolean;
117118
onRevoke: (id: string, name: string) => void;
118119
elementDescriptor?: ElementDescriptor;
120+
canManageAPIKeys: boolean;
119121
}) => {
120122
return (
121123
<Flex sx={t => ({ width: '100%', [mqu.sm]: { overflowX: 'auto', padding: t.space.$0x25 } })}>
@@ -128,7 +130,7 @@ export const ApiKeysTable = ({
128130
<Th>Name</Th>
129131
<Th>Last used</Th>
130132
<Th>Key</Th>
131-
<Th>Actions</Th>
133+
{canManageAPIKeys && <Th>Actions</Th>}
132134
</Tr>
133135
</Thead>
134136
<Tbody>
@@ -197,17 +199,19 @@ export const ApiKeysTable = ({
197199
<CopySecretButton apiKeyID={apiKey.id} />
198200
</Flex>
199201
</Td>
200-
<Td>
201-
<ThreeDotsMenu
202-
actions={[
203-
{
204-
label: localizationKeys('apiKeys.menuAction__revoke'),
205-
isDestructive: true,
206-
onClick: () => onRevoke(apiKey.id, apiKey.name),
207-
},
208-
]}
209-
/>
210-
</Td>
202+
{canManageAPIKeys && (
203+
<Td>
204+
<ThreeDotsMenu
205+
actions={[
206+
{
207+
label: localizationKeys('apiKeys.menuAction__revoke'),
208+
isDestructive: true,
209+
onClick: () => onRevoke(apiKey.id, apiKey.name),
210+
},
211+
]}
212+
/>
213+
</Td>
214+
)}
211215
</Tr>
212216
))
213217
)}

0 commit comments

Comments
 (0)