Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/directory-sync-role-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/localizations': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Add a role mapping step to the self-serve Directory Sync setup in `<OrganizationProfile />`. Organization admins can now assign an organization role to each directory group their identity provider pushes, set which group wins when a member belongs to several, and turn role syncing on or off. Members in no mapped group keep the organization's default role.
77 changes: 77 additions & 0 deletions packages/clerk-js/src/core/resources/DirectorySync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import type {
ClerkPaginatedResponse,
DeletedObjectJSON,
DeletedObjectResource,
DirectorySyncGroupRoleMappingJSON,
DirectorySyncGroupRoleMappingsJSON,
DirectorySyncGroupRoleMappingsResource,
DirectorySyncGroupsPage,
DirectorySyncGroupsPageJSON,
DirectorySyncJSON,
DirectorySyncJSONSnapshot,
DirectorySyncProvider,
Expand All @@ -10,7 +15,9 @@ import type {
DirectorySyncStatusResource,
DirectorySyncUserJSON,
DirectorySyncUserResource,
GetDirectorySyncGroupsParams,
GetDirectorySyncUsersParams,
ReplaceDirectorySyncGroupRoleMappingsParams,
SetDirectorySyncCredentialsParams,
UpdateDirectorySyncParams,
} from '@clerk/shared/types';
Expand All @@ -19,6 +26,7 @@ import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffset
import { unixEpochToDate } from '../../utils/date';
import { BaseResource } from './Base';
import { DeletedObject } from './DeletedObject';
import { Role } from './Role';

export class DirectorySync extends BaseResource implements DirectorySyncResource {
id!: string;
Expand Down Expand Up @@ -50,6 +58,9 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource
if (params.enabled !== undefined) {
body.enabled = params.enabled;
}
if (params.groupRoleMappingEnabled !== undefined) {
body.group_role_mapping_enabled = params.groupRoleMappingEnabled;
}
if (params.attributeMapping !== undefined) {
body.attribute_mapping = JSON.stringify(params.attributeMapping);
}
Expand Down Expand Up @@ -125,6 +136,57 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource
};
};

getGroups = async (params?: GetDirectorySyncGroupsParams): Promise<DirectorySyncGroupsPage> => {
const search: Record<string, string> = {};
if (params?.limit !== undefined) {
search.limit = String(params.limit);
}
if (params?.startingAfter) {
search.starting_after = params.startingAfter;
}

const res = await BaseResource._fetch({
path: `${this.directoryPath}/groups`,
method: 'GET',
search,
});

const payload = res?.response as unknown as DirectorySyncGroupsPageJSON | undefined;

return {
data: (payload?.data ?? []).map(group => ({
id: group.id,
displayName: group.display_name,
updatedAt: group.updated_at ? unixEpochToDate(group.updated_at) : null,
})),
startingAfter: payload?.cursor?.starting_after ?? null,
hasNextPage: payload?.cursor?.has_next_page ?? false,
};
};

getGroupRoleMappings = async (): Promise<DirectorySyncGroupRoleMappingsResource> => {
const res = await BaseResource._fetch({
path: `${this.directoryPath}/group_role_mappings`,
method: 'GET',
});

return toGroupRoleMappings(res?.response as unknown as DirectorySyncGroupRoleMappingsJSON | undefined);
};

replaceGroupRoleMappings = async (
params: ReplaceDirectorySyncGroupRoleMappingsParams,
): Promise<DirectorySyncGroupRoleMappingsResource> => {
const res = await BaseResource._fetch({
path: `${this.directoryPath}/group_role_mappings`,
method: 'PUT',
body: {
mappings: JSON.stringify(params.mappings.map(m => ({ directory_group_id: m.directoryGroupId, role: m.role }))),
} as any,
});

return toGroupRoleMappings(res?.response as unknown as DirectorySyncGroupRoleMappingsJSON | undefined);
};

getUsers = async (
params?: GetDirectorySyncUsersParams,
): Promise<ClerkPaginatedResponse<DirectorySyncUserResource>> => {
Expand Down Expand Up @@ -183,6 +245,21 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource
}
}

const toGroupRoleMapping = (json: DirectorySyncGroupRoleMappingJSON) => ({
id: json.id,
directoryGroupId: json.directory_group_id,
directoryGroupDisplayName: json.directory_group_display_name,
role: json.role ? new Role(json.role) : null,
precedence: json.precedence,
});

const toGroupRoleMappings = (
json: DirectorySyncGroupRoleMappingsJSON | undefined,
): DirectorySyncGroupRoleMappingsResource => ({
data: (json?.data ?? []).map(toGroupRoleMapping),
defaultRole: json?.default_role ? new Role(json.default_role) : null,
});

export class DirectorySyncUser extends BaseResource implements DirectorySyncUserResource {
id!: string;
userId!: string;
Expand Down
114 changes: 113 additions & 1 deletion packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DirectorySyncJSON } from '@clerk/shared/types';
import type { DirectorySyncJSON, RoleJSON } from '@clerk/shared/types';
import { describe, expect, it, vi } from 'vitest';

import { BaseResource, DirectorySync } from '../internal';
Expand All @@ -20,6 +20,17 @@ const directoryJSON: DirectorySyncJSON = {
updated_at: 1700000000000,
};

const roleJSON = (key: string, name: string): RoleJSON => ({
object: 'role',
id: `role_${key}`,
key,
name,
description: '',
permissions: [],
created_at: 1700000000000,
updated_at: 1700000000000,
});

function createDirectorySync(): DirectorySync {
return new DirectorySync(directoryJSON, ORG_ID);
}
Expand Down Expand Up @@ -258,4 +269,105 @@ describe('DirectorySync', () => {
expect(result.data[0].identifier).toBe('ada@example.com');
expect(result.data[0].active).toBe(true);
});

it('turns group role mapping on through update', async () => {
// @ts-ignore
BaseResource._fetch = vi
.fn()
.mockReturnValue(Promise.resolve({ response: { ...directoryJSON, group_role_mapping_enabled: true } }));

const result = await createDirectorySync().update({ groupRoleMappingEnabled: true });

// @ts-ignore
expect(BaseResource._fetch).toHaveBeenCalledWith({
method: 'PATCH',
path: DIRECTORY_PATH,
body: { group_role_mapping_enabled: true },
});
expect(result.groupRoleMappingEnabled).toBe(true);
});

it('lists directory groups with cursor pagination', async () => {
// @ts-ignore
BaseResource._fetch = vi.fn().mockReturnValue(
Promise.resolve({
response: {
data: [{ object: 'directory_group', id: 'dirgrp_1', display_name: 'Engineering', updated_at: 1700000000000 }],
cursor: { starting_after: 'dirgrp_1', ending_before: null, has_next_page: true },
},
}),
);

const page = await createDirectorySync().getGroups({ limit: 1, startingAfter: 'dirgrp_0' });

// @ts-ignore
expect(BaseResource._fetch).toHaveBeenCalledWith({
method: 'GET',
path: `${DIRECTORY_PATH}/groups`,
search: { limit: '1', starting_after: 'dirgrp_0' },
});
expect(page.data).toEqual([{ id: 'dirgrp_1', displayName: 'Engineering', updatedAt: new Date(1700000000000) }]);
expect(page.startingAfter).toBe('dirgrp_1');
expect(page.hasNextPage).toBe(true);
});

const mappingsResponse = {
data: [
{
object: 'directory_group_role_mapping',
id: 'dgrm_1',
directory_id: 'scimdir_1',
directory_group_id: 'dirgrp_1',
directory_group_display_name: 'Engineering',
role: roleJSON('org:admin', 'Admin'),
precedence: 1,
created_at: 1700000000000,
updated_at: 1700000000000,
},
],
total_count: 1,
default_role: roleJSON('org:member', 'Member'),
};

it('reads the group role mappings and the default role', async () => {
// @ts-ignore
BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: mappingsResponse }));

const result = await createDirectorySync().getGroupRoleMappings();

// @ts-ignore
expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'GET', path: `${DIRECTORY_PATH}/group_role_mappings` });
expect(result.data).toHaveLength(1);
expect(result.data[0]).toMatchObject({
directoryGroupId: 'dirgrp_1',
directoryGroupDisplayName: 'Engineering',
precedence: 1,
role: { key: 'org:admin', name: 'Admin' },
});
expect(result.defaultRole).toMatchObject({ key: 'org:member', name: 'Member' });
});

it('replaces the group role mappings, serializing them as JSON in priority order', async () => {
// @ts-ignore
BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: mappingsResponse }));

await createDirectorySync().replaceGroupRoleMappings({
mappings: [
{ directoryGroupId: 'dirgrp_2', role: 'org:member' },
{ directoryGroupId: 'dirgrp_1', role: 'org:admin' },
],
});

// @ts-ignore
expect(BaseResource._fetch).toHaveBeenCalledWith({
method: 'PUT',
path: `${DIRECTORY_PATH}/group_role_mappings`,
body: {
mappings: JSON.stringify([
{ directory_group_id: 'dirgrp_2', role: 'org:member' },
{ directory_group_id: 'dirgrp_1', role: 'org:admin' },
]),
},
});
});
});
32 changes: 32 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,41 @@ export const arSA: LocalizationResource = {
google: undefined,
okta: undefined,
},
roleMappingStep: {
actionHint__reorder: undefined,
actionLabel__reorder: undefined,
columns: {
directoryGroup: undefined,
priority: undefined,
role: undefined,
},
disableDialog: {
cancelButton: undefined,
confirmButton: undefined,
subtitle: undefined,
title: undefined,
},
empty__noGroups: {
subtitle: undefined,
title: undefined,
},
enableDialog: {
cancelButton: undefined,
confirmButton: undefined,
subtitle: undefined,
title: undefined,
},
error__loadMappings: undefined,
everyoneElse: undefined,
formFieldLabel__syncRoles: undefined,
roleOption__unassigned: undefined,
subtitle: undefined,
title: undefined,
},
stepper: {
attributes: undefined,
configure: undefined,
roles: undefined,
test: undefined,
},
testStep: {
Expand Down
32 changes: 32 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,41 @@ export const beBY: LocalizationResource = {
google: undefined,
okta: undefined,
},
roleMappingStep: {
actionHint__reorder: undefined,
actionLabel__reorder: undefined,
columns: {
directoryGroup: undefined,
priority: undefined,
role: undefined,
},
disableDialog: {
cancelButton: undefined,
confirmButton: undefined,
subtitle: undefined,
title: undefined,
},
empty__noGroups: {
subtitle: undefined,
title: undefined,
},
enableDialog: {
cancelButton: undefined,
confirmButton: undefined,
subtitle: undefined,
title: undefined,
},
error__loadMappings: undefined,
everyoneElse: undefined,
formFieldLabel__syncRoles: undefined,
roleOption__unassigned: undefined,
subtitle: undefined,
title: undefined,
},
stepper: {
attributes: undefined,
configure: undefined,
roles: undefined,
test: undefined,
},
testStep: {
Expand Down
32 changes: 32 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,41 @@ export const bgBG: LocalizationResource = {
google: undefined,
okta: undefined,
},
roleMappingStep: {
actionHint__reorder: undefined,
actionLabel__reorder: undefined,
columns: {
directoryGroup: undefined,
priority: undefined,
role: undefined,
},
disableDialog: {
cancelButton: undefined,
confirmButton: undefined,
subtitle: undefined,
title: undefined,
},
empty__noGroups: {
subtitle: undefined,
title: undefined,
},
enableDialog: {
cancelButton: undefined,
confirmButton: undefined,
subtitle: undefined,
title: undefined,
},
error__loadMappings: undefined,
everyoneElse: undefined,
formFieldLabel__syncRoles: undefined,
roleOption__unassigned: undefined,
subtitle: undefined,
title: undefined,
},
stepper: {
attributes: undefined,
configure: undefined,
roles: undefined,
test: undefined,
},
testStep: {
Expand Down
Loading
Loading