Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 470
feat(shared,js): add directory sync resource and organization contract#9590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
4cc0d5ad57e3cea1fc392768f164d570743File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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 self-serve Directory Sync (SCIM) setup. The `Organization` resource gains `getDirectorySync()` and `createDirectorySync()` for the directory bound to an enterprise connection, and the returned `DirectorySync` resource exposes `update()`, `rotateToken()`, `delete()`, and `getUsers()`; the SCIM bearer token is only returned by `createDirectorySync()` and `rotateToken()`. The `OrganizationProfile` Security page gains a Directory Sync section, and the internal `ConfigureDirectorySync` component walks through the setup. Both are only shown when the instance has self-serve Directory Sync enabled. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| import type { | ||
| ClerkPaginatedResponse, | ||
| DeletedObjectJSON, | ||
| DeletedObjectResource, | ||
| DirectorySyncJSON, | ||
| DirectorySyncJSONSnapshot, | ||
| DirectorySyncProvider, | ||
| DirectorySyncResource, | ||
| DirectorySyncUserJSON, | ||
| DirectorySyncUserResource, | ||
| GetDirectorySyncUsersParams, | ||
| UpdateDirectorySyncParams, | ||
| } from '@clerk/shared/types'; | ||
| import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams'; | ||
| import { unixEpochToDate } from '../../utils/date'; | ||
| import { BaseResource } from './Base'; | ||
| import { DeletedObject } from './DeletedObject'; | ||
| export class DirectorySync extends BaseResource implements DirectorySyncResource { | ||
| id!: string; | ||
| name!: string; | ||
| organizationId!: string; | ||
| enterpriseConnectionId!: string; | ||
| endpointUrl!: string; | ||
| provider!: DirectorySyncProvider; | ||
| enabled!: boolean; | ||
| groupRoleMappingEnabled!: boolean; | ||
| attributeMapping: Record<string, string> = {}; | ||
| apiKey: string | null = null; | ||
| createdAt: Date | null = null; | ||
| updatedAt: Date | null = null; | ||
| constructor(data: DirectorySyncJSON | DirectorySyncJSONSnapshot | null, organizationId: string) { | ||
| super(); | ||
| this.organizationId = organizationId; | ||
| this.fromJSON(data); | ||
| } | ||
| private get directoryPath(): string { | ||
| return `/organizations/${this.organizationId}/enterprise_connections/${this.enterpriseConnectionId}/directory`; | ||
| } | ||
| update = async (params: UpdateDirectorySyncParams): Promise<DirectorySyncResource> => { | ||
| const body: Record<string, string | boolean> = {}; | ||
| if (params.enabled !== undefined) { | ||
| body.enabled = params.enabled; | ||
| } | ||
| if (params.attributeMapping !== undefined) { | ||
| body.attribute_mapping = JSON.stringify(params.attributeMapping); | ||
| } | ||
| const json = ( | ||
| await BaseResource._fetch<DirectorySyncJSON>({ | ||
| path: this.directoryPath, | ||
| method: 'PATCH', | ||
| body: body as any, | ||
| }) | ||
| )?.response as unknown as DirectorySyncJSON; | ||
| return new DirectorySync(json, this.organizationId); | ||
| }; | ||
| rotateToken = async (): Promise<DirectorySyncResource> => { | ||
| const json = ( | ||
| await BaseResource._fetch<DirectorySyncJSON>({ | ||
| path: `${this.directoryPath}/rotate_api_key`, | ||
| method: 'POST', | ||
| }) | ||
| )?.response as unknown as DirectorySyncJSON; | ||
| return new DirectorySync(json, this.organizationId); | ||
| }; | ||
| delete = async (): Promise<DeletedObjectResource> => { | ||
| const json = ( | ||
| await BaseResource._fetch<DeletedObjectJSON>({ | ||
| path: this.directoryPath, | ||
| method: 'DELETE', | ||
| }) | ||
| )?.response as unknown as DeletedObjectJSON; | ||
| return new DeletedObject(json); | ||
| }; | ||
| getUsers = async ( | ||
| params?: GetDirectorySyncUsersParams, | ||
| ): Promise<ClerkPaginatedResponse<DirectorySyncUserResource>> => { | ||
| const res = await BaseResource._fetch({ | ||
| path: `${this.directoryPath}/users`, | ||
| method: 'GET', | ||
| search: convertPageToOffsetSearchParams(params), | ||
| }); | ||
| const payload = res?.response as unknown as ClerkPaginatedResponse<DirectorySyncUserJSON> | undefined; | ||
| return { | ||
| total_count: payload?.total_count ?? 0, | ||
| data: (payload?.data ?? []).map(row => new DirectorySyncUser(row)), | ||
| }; | ||
| }; | ||
| protected fromJSON(data: DirectorySyncJSON | DirectorySyncJSONSnapshot | null): this { | ||
| if (!data) { | ||
| return this; | ||
| } | ||
| this.id = data.id; | ||
| this.name = data.name; | ||
| this.enterpriseConnectionId = data.enterprise_connection_id; | ||
| this.endpointUrl = data.endpoint_url; | ||
| this.provider = data.provider; | ||
| this.enabled = data.enabled; | ||
| this.groupRoleMappingEnabled = data.group_role_mapping_enabled; | ||
| this.attributeMapping = data.attribute_mapping ?? {}; | ||
| this.apiKey = data.api_key ?? null; | ||
| this.createdAt = unixEpochToDate(data.created_at); | ||
| this.updatedAt = unixEpochToDate(data.updated_at); | ||
| return this; | ||
| } | ||
| public __internal_toSnapshot(): DirectorySyncJSONSnapshot { | ||
| return { | ||
| object: 'directory', | ||
| id: this.id, | ||
| name: this.name, | ||
| enterprise_connection_id: this.enterpriseConnectionId, | ||
| endpoint_url: this.endpointUrl, | ||
| provider: this.provider, | ||
| enabled: this.enabled, | ||
| group_role_mapping_enabled: this.groupRoleMappingEnabled, | ||
| attribute_mapping: this.attributeMapping, | ||
| // The bearer token is deliberately absent: snapshots may be persisted | ||
| // and the secret must never outlive the response it arrived on. | ||
| created_at: this.createdAt?.getTime() ?? 0, | ||
| updated_at: this.updatedAt?.getTime() ?? 0, | ||
| }; | ||
| } | ||
| } | ||
| export class DirectorySyncUser extends BaseResource implements DirectorySyncUserResource { | ||
| id!: string; | ||
| userId!: string; | ||
| firstName: string | null = null; | ||
| lastName: string | null = null; | ||
| identifier: string | null = null; | ||
| imageUrl!: string; | ||
| hasImage!: boolean; | ||
| active!: boolean; | ||
| provisionedAt: Date | null = null; | ||
| updatedAt: Date | null = null; | ||
| constructor(data: DirectorySyncUserJSON | null) { | ||
| super(); | ||
| this.fromJSON(data); | ||
| } | ||
| protected fromJSON(data: DirectorySyncUserJSON | null): this { | ||
| if (!data) { | ||
| return this; | ||
| } | ||
| this.id = data.id; | ||
| this.userId = data.user_id; | ||
| this.firstName = data.first_name; | ||
| this.lastName = data.last_name; | ||
| this.identifier = data.identifier; | ||
| this.imageUrl = data.image_url; | ||
| this.hasImage = data.has_image; | ||
| this.active = data.active; | ||
| this.provisionedAt = unixEpochToDate(data.provisioned_at); | ||
| this.updatedAt = unixEpochToDate(data.updated_at); | ||
| return this; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -2,11 +2,14 @@ import type { | ||
| AddMemberParams, | ||
| ClerkPaginatedResponse, | ||
| ClerkResourceReloadParams, | ||
| CreateDirectorySyncParams, | ||
| CreateOrganizationDomainParams, | ||
| CreateOrganizationEnterpriseConnectionParams, | ||
| CreateOrganizationParams, | ||
| DeletedObjectJSON, | ||
| DeletedObjectResource, | ||
| DirectorySyncJSON, | ||
| DirectorySyncResource, | ||
| EnterpriseConnectionJSON, | ||
| EnterpriseConnectionResource, | ||
| EnterpriseConnectionTestRunInitJSON, | ||
| @@ -49,6 +52,7 @@ import { addPaymentMethod, getPaymentMethods, initializePaymentMethod } from '.. | ||
| import { | ||
| BaseResource, | ||
| DeletedObject, | ||
| DirectorySync, | ||
| EnterpriseConnection, | ||
| EnterpriseConnectionTestRun, | ||
| OrganizationInvitation, | ||
| @@ -274,6 +278,32 @@ export class Organization extends BaseResource implements OrganizationResource { | ||
| }; | ||
| }; | ||
| getDirectorySync = async (enterpriseConnectionId: string): Promise<DirectorySyncResource> => { | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Document the new public Directory Sync APIs. Add JSDoc for both methods. Document parameters, return values, errors, and an example. For
As per coding guidelines, “All public APIs must be documented with JSDoc.” 📍 Affects 1 file
🤖 Prompt for AI AgentsSource: Coding guidelines | ||
| const json = ( | ||
| await BaseResource._fetch<DirectorySyncJSON>({ | ||
| path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, | ||
| method: 'GET', | ||
| }) | ||
| )?.response as unknown as DirectorySyncJSON; | ||
| return new DirectorySync(json, this.id); | ||
| }; | ||
| createDirectorySync = async ( | ||
| enterpriseConnectionId: string, | ||
| params?: CreateDirectorySyncParams, | ||
| ): Promise<DirectorySyncResource> => { | ||
| const json = ( | ||
| await BaseResource._fetch<DirectorySyncJSON>({ | ||
| path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, | ||
| method: 'POST', | ||
| body: { name: params?.name } as any, | ||
| }) | ||
| )?.response as unknown as DirectorySyncJSON; | ||
| return new DirectorySync(json, this.id); | ||
| }; | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| getMembershipRequests = async ( | ||
| getRequestParam?: GetMembershipRequestParams, | ||
| ): Promise<ClerkPaginatedResponse<OrganizationMembershipRequestResource>> => { | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import type { DirectorySyncJSON } from '@clerk/shared/types'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { BaseResource, DirectorySync } from '../internal'; | ||
| const ORG_ID = 'org_123'; | ||
| const DIRECTORY_PATH = `/organizations/${ORG_ID}/enterprise_connections/ec_123/directory`; | ||
| const directoryJSON: DirectorySyncJSON = { | ||
| object: 'directory', | ||
| id: 'scimdir_1', | ||
| name: 'Acme Okta', | ||
| enterprise_connection_id: 'ec_123', | ||
| endpoint_url: 'https://api.example.com/scim/v2', | ||
| provider: 'okta', | ||
| enabled: false, | ||
| group_role_mapping_enabled: false, | ||
| attribute_mapping: { 'name.givenName': 'first_name' }, | ||
| created_at: 1700000000000, | ||
| updated_at: 1700000000000, | ||
| }; | ||
| function createDirectorySync(): DirectorySync { | ||
| return new DirectorySync(directoryJSON, ORG_ID); | ||
| } | ||
| describe('DirectorySync', () => { | ||
| it('scopes itself to the owning organization and connection', () => { | ||
| const directory = createDirectorySync(); | ||
| expect(directory.organizationId).toBe(ORG_ID); | ||
| expect(directory.enterpriseConnectionId).toBe('ec_123'); | ||
| expect(directory.apiKey).toBeNull(); | ||
| expect(directory.__internal_toSnapshot()).not.toHaveProperty('api_key'); | ||
| }); | ||
| it('updates the directory, serializing the attribute mapping as JSON', async () => { | ||
| // @ts-ignore | ||
| BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: { ...directoryJSON, enabled: true } })); | ||
| const result = await createDirectorySync().update({ | ||
| enabled: true, | ||
| attributeMapping: { 'name.familyName': 'last_name', 'name.givenName': null }, | ||
| }); | ||
| // @ts-ignore | ||
| expect(BaseResource._fetch).toHaveBeenCalledWith({ | ||
| method: 'PATCH', | ||
| path: DIRECTORY_PATH, | ||
| body: { | ||
| enabled: true, | ||
| attribute_mapping: JSON.stringify({ 'name.familyName': 'last_name', 'name.givenName': null }), | ||
| }, | ||
| }); | ||
| expect(result.enabled).toBe(true); | ||
| expect(result.organizationId).toBe(ORG_ID); | ||
| }); | ||
| it('rotates the bearer token', async () => { | ||
| // @ts-ignore | ||
| BaseResource._fetch = vi | ||
| .fn() | ||
| .mockReturnValue(Promise.resolve({ response: { ...directoryJSON, api_key: 'ak_new' } })); | ||
| const result = await createDirectorySync().rotateToken(); | ||
| // @ts-ignore | ||
| expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'POST', path: `${DIRECTORY_PATH}/rotate_api_key` }); | ||
| expect(result.apiKey).toBe('ak_new'); | ||
| }); | ||
| it('deletes the directory', async () => { | ||
| // @ts-ignore | ||
| BaseResource._fetch = vi | ||
| .fn() | ||
| .mockReturnValue(Promise.resolve({ response: { object: 'directory', id: 'scimdir_1', deleted: true } })); | ||
| const result = await createDirectorySync().delete(); | ||
| // @ts-ignore | ||
| expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'DELETE', path: DIRECTORY_PATH }); | ||
| expect(result.id).toBe('scimdir_1'); | ||
| expect(result.deleted).toBe(true); | ||
| }); | ||
| it('lists provisioned directory users with pagination', async () => { | ||
| const paginated = { | ||
| data: [ | ||
| { | ||
| object: 'directory_user' as const, | ||
| id: 'scimdu_1', | ||
| user_id: 'user_1', | ||
| first_name: 'Ada', | ||
| last_name: 'Lovelace', | ||
| identifier: 'ada@example.com', | ||
| image_url: '', | ||
| has_image: false, | ||
| active: true, | ||
| provisioned_at: 1700000000000, | ||
| updated_at: 1700000000000, | ||
| }, | ||
| ], | ||
| total_count: 1, | ||
| }; | ||
| // @ts-ignore | ||
| BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: paginated })); | ||
| const result = await createDirectorySync().getUsers({ initialPage: 2, pageSize: 10 }); | ||
| // @ts-ignore | ||
| const call = BaseResource._fetch.mock.calls[0][0]; | ||
| expect(call.method).toBe('GET'); | ||
| expect(call.path).toBe(`${DIRECTORY_PATH}/users`); | ||
| expect(call.search.get('limit')).toBe('10'); | ||
| expect(call.search.get('offset')).toBe('10'); | ||
| expect(result.total_count).toBe(1); | ||
| expect(result.data[0].userId).toBe('user_1'); | ||
| expect(result.data[0].identifier).toBe('ada@example.com'); | ||
| expect(result.data[0].active).toBe(true); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.