diff --git a/docs/resource-specific-documentation.md b/docs/resource-specific-documentation.md index ea7cfc090..0a399a365 100644 --- a/docs/resource-specific-documentation.md +++ b/docs/resource-specific-documentation.md @@ -121,6 +121,61 @@ Contents of `promptName_screenName.json` } ``` +## Connections (Google Workspace directory provisioning) + +The Deploy CLI supports managing the `directory_provisioning_configuration` for Google Workspace (`google-apps`) connections. Only `google-apps` connections are processed for directory provisioning; other strategies will ignore this block. Deleting directory provisioning requires `AUTH0_ALLOW_DELETE=true`. + +The `mapping` array pairs Auth0 user fields with IdP fields, and `synchronize_automatically` controls whether Auth0 runs scheduled sync jobs for the connection. + +**YAML Example** + +```yaml +connections: + - name: google-workspace + strategy: google-apps + options: + domain: example.com + tenant_domain: example.com + client_id: 'some_client_id' + client_secret: 'some_client_secret' + api_enable_users: true + directory_provisioning_configuration: + mapping: + - auth0: email + idp: mail + - auth0: name + idp: displayName + synchronize_automatically: false +``` + +**Directory Example** + +``` +./connections/google-apps-directory-provisioning.json +``` + +```json +{ + "name": "google-apps-directory-provisioning", + "strategy": "google-apps", + "enabled_clients": ["My SPA"], + "options": { + "domain": "example.com", + "tenant_domain": "example.com", + "client_id": "some_client_id", + "client_secret": "some_client_secret", + "api_enable_users": true + }, + "directory_provisioning_configuration": { + "mapping": [ + { "auth0": "email", "idp": "mail" }, + { "auth0": "name", "idp": "displayName" } + ], + "synchronize_automatically": false + } +} +``` + ## Databases When managing database connections, the values of `options.customScripts` point to specific javascript files relative to diff --git a/examples/directory/connections/google-apps.json b/examples/directory/connections/google-apps.json new file mode 100644 index 000000000..9bc0cbd47 --- /dev/null +++ b/examples/directory/connections/google-apps.json @@ -0,0 +1,21 @@ +{ + "name": "google-apps-directory-provisioning", + "strategy": "google-apps", + "enabled_clients": [ + "My SPA" + ], + "options": { + "domain": "example.com", + "tenant_domain": "example.com", + "client_id": "some_client_id", + "client_secret": "some_client_secret", + "api_enable_users": true + }, + "directory_provisioning_configuration": { + "mapping": [ + { "auth0": "email", "idp": "mail" }, + { "auth0": "name", "idp": "displayName" } + ], + "synchronize_automatically": false + } +} diff --git a/examples/yaml/tenant.yaml b/examples/yaml/tenant.yaml index 1aed8a753..d74af510f 100644 --- a/examples/yaml/tenant.yaml +++ b/examples/yaml/tenant.yaml @@ -111,6 +111,24 @@ connections: ext_groups: true # Add other connection settings (https://auth0.com/docs/api/management/v2#!/Connections/post_connections) + - name: "google-workspace" + strategy: "google-apps" + enabled_clients: + - "My SPA" + options: + domain: "example.com" + tenant_domain: "example.com" + client_id: 'some_client_id' + client_secret: 'some_client_secret' + api_enable_users: true + directory_provisioning_configuration: + mapping: + - auth0: "email" + idp: "mail" + - auth0: "name" + idp: "displayName" + synchronize_automatically: false + resourceServers: - diff --git a/src/tools/auth0/handlers/connections.ts b/src/tools/auth0/handlers/connections.ts index 5f9eca789..04b0fc44a 100644 --- a/src/tools/auth0/handlers/connections.ts +++ b/src/tools/auth0/handlers/connections.ts @@ -1,6 +1,6 @@ import dotProp from 'dot-prop'; import { chunk, keyBy } from 'lodash'; -import { Management } from 'auth0'; +import { Management, ManagementError } from 'auth0'; import DefaultAPIHandler, { order } from './default'; import { filterExcluded, convertClientNameToId, getEnabledClients, sleep } from '../../utils'; import { CalculatedChanges, Asset, Assets, Auth0APIClient } from '../../../types'; @@ -52,12 +52,36 @@ export const schema = { required: ['active'], additionalProperties: false, }, + directory_provisioning_configuration: { + type: 'object', + properties: { + mapping: { + type: 'array', + items: { + type: 'object', + properties: { + auth0: { type: 'string', description: 'The field location in the Auth0 schema' }, + idp: { type: 'string', description: 'The field location in the IDP schema' }, + }, + }, + }, + synchronize_automatically: { + type: 'boolean', + description: 'The field whether periodic automatic synchronization is enabled', + }, + }, + }, }, required: ['name', 'strategy'], }, }; -export type Connection = Management.ConnectionForList; +type DirectoryProvisioningConfig = Management.GetDirectoryProvisioningResponseContent; + +export type Connection = Management.ConnectionForList & { + enabled_clients?: string[]; + directory_provisioning_configuration?: DirectoryProvisioningConfig; +}; // addExcludedConnectionPropertiesToChanges superimposes excluded properties on the `options` object. The Auth0 API // will overwrite the options property when updating connections, so it is necessary to add excluded properties back in to prevent those excluded properties from being deleted. @@ -305,6 +329,213 @@ export default class ConnectionsHandler extends DefaultAPIHandler { } } + /** + * Retrieves directory provisioning configuration for a specific Auth0 connection. + * @param connectionId - The unique identifier of the connection + * @returns A promise that resolves to the configuration object, or null if not configured/supported + */ + async getConnectionDirectoryProvisioning( + connectionId: string + ): Promise { + if (!connectionId) return null; + + const creates = [connectionId]; + let config: DirectoryProvisioningConfig | null = null; + + try { + await this.client.pool + .addEachTask({ + data: creates || [], + generator: async (id: string) => + this.client.connections.directoryProvisioning + .get(id) + .then((resp) => { + config = resp; + }) + .catch((err) => { + throw new ManagementError(err); + }), + }) + .promise(); + + const stripKeysFromOutput = [ + 'connection_id', + 'connection_name', + 'strategy', + 'created_at', + 'updated_at', + ]; + + stripKeysFromOutput.forEach((key) => { + if (config && key in config) { + delete (config as Partial)[key]; + } + }); + + return config; + } catch (error) { + const errLog = `Unable to fetch directory provisioning for connection '${connectionId}'. `; + if (error instanceof ManagementError) { + const bodyMessage = (error.body as any)?.message; + log.warn(errLog + bodyMessage); + } else { + log.error(errLog, error?.message); + } + return null; + } + } + + /** + * Creates directory provisioning configuration for a connection. + */ + private async createConnectionDirectoryProvisioning( + connectionId: string, + payload: Management.CreateDirectoryProvisioningRequestContent + ): Promise { + if (!connectionId) { + throw new Error('Connection ID is required to create directory provisioning configuration.'); + } + const createPayload: Management.CreateDirectoryProvisioningRequestContent = { + mapping: payload.mapping, + synchronize_automatically: payload.synchronize_automatically, + }; + await this.client.connections.directoryProvisioning.create(connectionId, createPayload); + log.debug(`Created directory provisioning for connection '${connectionId}'`); + } + + /** + * Updates directory provisioning configuration for a connection. + */ + private async updateConnectionDirectoryProvisioning( + connectionId: string, + payload: Management.UpdateDirectoryProvisioningRequestContent + ): Promise { + if (!connectionId) { + throw new Error('Connection ID is required to update directory provisioning configuration.'); + } + + const updatePayload: Management.UpdateDirectoryProvisioningRequestContent = { + mapping: payload.mapping, + synchronize_automatically: payload.synchronize_automatically, + }; + + await this.client.connections.directoryProvisioning.update(connectionId, updatePayload); + log.debug(`Updated directory provisioning for connection '${connectionId}'`); + } + + /** + * Deletes directory provisioning configuration for a connection. + */ + private async deleteConnectionDirectoryProvisioning(connectionId: string): Promise { + if (!connectionId) { + throw new Error('Connection ID is required to delete directory provisioning configuration.'); + } + await this.client.connections.directoryProvisioning.delete(connectionId); + log.debug(`Deleted directory provisioning for connection '${connectionId}'`); + } + + /** + * This function processes directory provisioning for create, update, and conflict operations. + * Directory provisioning is only supported for google-apps strategy connections. + * + * @param changes - Object containing arrays of connections to create, update, and resolve conflicts for + */ + async processConnectionDirectoryProvisioning(changes: CalculatedChanges): Promise { + const { create, update, conflicts } = changes; + + // Build a map of existing connections by ID for quick lookup + const existingConnectionsMap = keyBy(this.existing || [], 'id'); + + // Filter to only google-apps connections + const googleAppsWithDirProvFilter = (conn: Asset) => conn.strategy === 'google-apps'; + + const connectionsToProcess = [ + ...update.filter(googleAppsWithDirProvFilter), + ...create.filter(googleAppsWithDirProvFilter), + ...conflicts.filter(googleAppsWithDirProvFilter), + ]; + + if (connectionsToProcess.length === 0) { + return; + } + + const directoryConnectionsToUpdate: Connection[] = []; + const directoryConnectionsToCreate: Connection[] = []; + const directoryConnectionsToDelete: Connection[] = []; + + for (const conn of connectionsToProcess) { + if (!conn.id) continue; + + const existingConn = existingConnectionsMap[conn.id]; + const existingConfig = existingConn?.directory_provisioning_configuration; + const proposedConfig = conn.directory_provisioning_configuration; + + if (existingConfig && proposedConfig) { + directoryConnectionsToUpdate.push(conn); + } else if (!existingConfig && proposedConfig) { + directoryConnectionsToCreate.push(conn); + } else if (existingConfig && !proposedConfig) { + directoryConnectionsToDelete.push(conn); + } + } + + // Process updates first + await this.client.pool + .addEachTask({ + data: directoryConnectionsToUpdate || [], + generator: (conn) => + this.updateConnectionDirectoryProvisioning( + conn.id!, + conn.directory_provisioning_configuration! + ).catch((err) => { + throw new Error( + `Failed to update directory provisioning for connection '${conn.id}':\n${err}` + ); + }), + }) + .promise(); + + // Process creates + await this.client.pool + .addEachTask({ + data: directoryConnectionsToCreate || [], + generator: (conn) => + this.createConnectionDirectoryProvisioning( + conn.id!, + conn.directory_provisioning_configuration! + ).catch((err) => { + throw new Error( + `Failed to create directory provisioning for connection '${conn.id}':\n${err}` + ); + }), + }) + .promise(); + + // Process deletes + if ( + this.config('AUTH0_ALLOW_DELETE') === 'true' || + this.config('AUTH0_ALLOW_DELETE') === true + ) { + await this.client.pool + .addEachTask({ + data: directoryConnectionsToDelete || [], + generator: (conn) => + this.deleteConnectionDirectoryProvisioning(conn.id!).catch((err) => { + throw new Error( + `Failed to delete directory provisioning for connection '${conn.id}':\n${err}` + ); + }), + }) + .promise(); + } else if (directoryConnectionsToDelete.length) { + log.warn( + `Detected directory provisioning configs to delete. Deletes are disabled (set 'AUTH0_ALLOW_DELETE' to true to allow).\n${directoryConnectionsToDelete + .map((i) => this.objString(i)) + .join('\n')}` + ); + } + } + async getType(): Promise { if (this.existing) return this.existing; @@ -332,10 +563,22 @@ export default class ConnectionsHandler extends DefaultAPIHandler { filteredConnections.map(async (con) => { if (!con?.id) return con; const enabledClients = await getConnectionEnabledClients(this.client, con.id); + + // Cast to Asset to allow adding properties + let connection: Connection = { ...con }; + if (enabledClients && enabledClients?.length) { - return { ...con, enabled_clients: enabledClients }; + connection.enabled_clients = enabledClients; } - return con; + + if (connection.strategy === 'google-apps') { + const dirProvConfig = await this.getConnectionDirectoryProvisioning(con.id); + if (dirProvConfig) { + connection.directory_provisioning_configuration = dirProvConfig; + } + } + + return connection; }) ); @@ -417,5 +660,8 @@ export default class ConnectionsHandler extends DefaultAPIHandler { this.type, filterExcluded(changes, excludedConnections) ); + + // process directory provisioning + await this.processConnectionDirectoryProvisioning(filterExcluded(changes, excludedConnections)); } } diff --git a/src/tools/auth0/handlers/scimHandler.ts b/src/tools/auth0/handlers/scimHandler.ts index 560488672..2de82d8b4 100644 --- a/src/tools/auth0/handlers/scimHandler.ts +++ b/src/tools/auth0/handlers/scimHandler.ts @@ -286,6 +286,7 @@ export default class ScimHandler { // Remove `scim_configuration` from `bodyParams`, because `connections.update` doesn't accept it. const { scim_configuration: scimBodyParams } = bodyParams; delete bodyParams.scim_configuration; + delete bodyParams.directory_provisioning_configuration; // First, update `connections`. const updated = await this.connectionsManager.update(requestParams.id, bodyParams); @@ -323,6 +324,7 @@ export default class ScimHandler { // Remove `scim_configuration` from `bodyParams`, because `connections.create` doesn't accept it. const { scim_configuration: scimBodyParams } = bodyParams; delete bodyParams.scim_configuration; + delete bodyParams.directory_provisioning_configuration; // First, create the new `connection`. const data = await this.connectionsManager.create( diff --git a/test/tools/auth0/handlers/connections.tests.js b/test/tools/auth0/handlers/connections.tests.js index b010be25c..7b5d50b7e 100644 --- a/test/tools/auth0/handlers/connections.tests.js +++ b/test/tools/auth0/handlers/connections.tests.js @@ -155,6 +155,34 @@ describe('#connections handler', () => { expect(getEnabledClientsCalledOnce).to.equal(true); }); + it('should include directory provisioning configuration for google-apps connections', async () => { + const auth0 = { + connections: { + list: (params) => + mockPagedData(params, 'connections', [ + { id: 'con1', strategy: 'google-apps', name: 'gsuite', options: {} }, + ]), + }, + clients: { + list: (params) => mockPagedData(params, 'clients', []), + }, + pool, + }; + + const handler = new connections.default({ client: pageClient(auth0), config }); + sinon.stub(connections, 'getConnectionEnabledClients').resolves(undefined); + const dirProvConfig = { mapping: [{ auth0: 'email', idp: 'mail' }] }; + sinon.stub(handler, 'getConnectionDirectoryProvisioning').resolves(dirProvConfig); + handler.scimHandler.applyScimConfiguration = sinon.stub().resolves(); + + const data = await handler.getType(); + + expect(handler.getConnectionDirectoryProvisioning.calledOnceWith('con1')).to.be.true; + expect(data[0]) + .to.have.property('directory_provisioning_configuration') + .that.deep.equals(dirProvConfig); + }); + it('should update connection', async () => { const auth0 = { connections: { @@ -316,6 +344,174 @@ describe('#connections handler', () => { await stageFn.apply(handler, [{ connections: data }]); }); + it('should process directory provisioning create and update operations', async () => { + const poolExecutor = { + addEachTask: ({ data, generator }) => ({ + promise: async () => { + // run all tasks sequentially to preserve order + for (const item of data || []) { + await generator(item); + } + }, + }), + }; + + const auth0 = { + connections: { + directoryProvisioning: {}, + }, + clients: { + list: (params) => mockPagedData(params, 'clients', []), + }, + pool: poolExecutor, + }; + + const handler = new connections.default({ client: pageClient(auth0), config }); + handler.existing = [ + { + id: 'con1', + name: 'gsuite-existing', + strategy: 'google-apps', + directory_provisioning_configuration: { mapping: [{ auth0: 'email', idp: 'mail' }] }, + }, + ]; + + const updateStub = sinon + .stub(handler, 'updateConnectionDirectoryProvisioning') + .resolves(undefined); + const createStub = sinon + .stub(handler, 'createConnectionDirectoryProvisioning') + .resolves(undefined); + const deleteStub = sinon + .stub(handler, 'deleteConnectionDirectoryProvisioning') + .resolves(undefined); + + await handler.processConnectionDirectoryProvisioning({ + create: [ + { + id: 'con2', + name: 'gsuite-new', + strategy: 'google-apps', + directory_provisioning_configuration: { + mapping: [{ auth0: 'name', idp: 'displayName' }], + }, + }, + ], + update: [ + { + id: 'con1', + name: 'gsuite-existing', + strategy: 'google-apps', + directory_provisioning_configuration: { mapping: [{ auth0: 'email', idp: 'mail' }] }, + }, + ], + conflicts: [], + del: [], + }); + + expect(updateStub.calledOnceWith('con1', sinon.match.object)).to.be.true; + expect(createStub.calledOnceWith('con2', sinon.match.object)).to.be.true; + expect(deleteStub.called).to.be.false; + }); + + describe('directory provisioning helpers', () => { + const sampleMapping = [ + { idp: 'id', auth0: 'external_id' }, + { idp: 'primaryEmail', auth0: 'email' }, + { idp: 'name.givenName', auth0: 'given_name' }, + { idp: 'name.familyName', auth0: 'family_name' }, + ]; + + it('should create directory provisioning configuration (POST)', async () => { + const createStub = sinon.stub().resolves(); + const auth0 = { + connections: { + directoryProvisioning: { + create: createStub, + }, + }, + pool, + }; + + const handler = new connections.default({ client: pageClient(auth0), config }); + await handler.createConnectionDirectoryProvisioning('con-post', { + mapping: sampleMapping, + }); + + expect(createStub.calledOnceWith('con-post', sinon.match.has('mapping', sampleMapping))).to + .be.true; + }); + + it('should retrieve directory provisioning configuration (GET)', async () => { + const dirProvConfig = { mapping: sampleMapping, synchronize_automatically: true }; + const getStub = sinon.stub().resolves(dirProvConfig); + const poolExecutor = { + addEachTask: ({ data, generator }) => ({ + promise: async () => { + for (const item of data || []) { + await generator(item); + } + }, + }), + }; + + const auth0 = { + connections: { + directoryProvisioning: { + get: getStub, + }, + }, + pool: poolExecutor, + }; + + const handler = new connections.default({ client: pageClient(auth0), config }); + const result = await handler.getConnectionDirectoryProvisioning('con-get'); + + expect(getStub.calledOnceWith('con-get')).to.be.true; + expect(result).to.deep.equal(dirProvConfig); + }); + + it('should update directory provisioning configuration (PATCH)', async () => { + const updateStub = sinon.stub().resolves(); + const auth0 = { + connections: { + directoryProvisioning: { + update: updateStub, + }, + }, + }; + + const handler = new connections.default({ client: pageClient(auth0), config }); + await handler.updateConnectionDirectoryProvisioning('con-patch', { + mapping: sampleMapping, + synchronize_automatically: false, + }); + + expect( + updateStub.calledOnceWith('con-patch', { + mapping: sampleMapping, + synchronize_automatically: false, + }) + ).to.be.true; + }); + + it('should delete directory provisioning configuration (DELETE)', async () => { + const deleteStub = sinon.stub().resolves(); + const auth0 = { + connections: { + directoryProvisioning: { + delete: deleteStub, + }, + }, + }; + + const handler = new connections.default({ client: pageClient(auth0), config }); + await handler.deleteConnectionDirectoryProvisioning('con-del'); + + expect(deleteStub.calledOnceWith('con-del')).to.be.true; + }); + }); + it('should keep client ID in idpinitiated.client_id', async () => { const auth0 = { connections: { diff --git a/test/tools/auth0/handlers/scimHandler.tests.js b/test/tools/auth0/handlers/scimHandler.tests.js index 5e1cbb22f..1338365c9 100644 --- a/test/tools/auth0/handlers/scimHandler.tests.js +++ b/test/tools/auth0/handlers/scimHandler.tests.js @@ -250,6 +250,31 @@ describe('ScimHandler', () => { expect(handler.updateScimConfiguration.calledOnce).to.be.true; }); + it('should remove directory provisioning configuration before updating connection', async () => { + const requestParams = { id: 'con_dirprov' }; + const bodyParams = { + name: 'google-apps-connection', + scim_configuration: { mapping: [], user_id_attribute: 'id' }, + directory_provisioning_configuration: { mapping: [{ auth0: 'email', idp: 'mail' }] }, + }; + + handler.idMap.set('con_dirprov', { + strategy: 'samlp', + scimConfiguration: bodyParams.scim_configuration, + }); + + mockConnectionsManager.update.resolves({ id: 'con_dirprov' }); + handler.updateScimConfiguration = sinon.stub().resolves({ connection_id: 'con_dirprov' }); + + await handler.updateOverride(requestParams, { ...bodyParams }); + + const [, updateBody] = mockConnectionsManager.update.firstCall.args; + expect(updateBody).to.not.have.property('directory_provisioning_configuration'); + expect(updateBody).to.not.have.property('scim_configuration'); + expect(updateBody).to.have.property('name', 'google-apps-connection'); + expect(handler.updateScimConfiguration.calledOnce).to.be.true; + }); + it('should create SCIM configuration when updating connection during updateOverride', async () => { const requestParams = { id: 'con_kzpLY0Afi4I8lvwM' }; const bodyParams = { scim_configuration: { mapping: [], user_id_attribute: 'id' } };