diff --git a/docs/docs/cmd/entra/multitenant/multitenant-remove.mdx b/docs/docs/cmd/entra/multitenant/multitenant-remove.mdx new file mode 100644 index 00000000000..88567367402 --- /dev/null +++ b/docs/docs/cmd/entra/multitenant/multitenant-remove.mdx @@ -0,0 +1,58 @@ +import Global from '/docs/cmd/_global.mdx'; + +# entra multitenant remove + +Removes a multitenant organization + +## Usage + +```sh +m365 entra multitenant remove [options] +``` + +## options + +```md definition-list +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Remarks + +:::info + +To use this command you must be at least **Security Administrator**. + +::: + +:::info + +When removing a Multi-Tenant Organization, all associations with other tenants will be removed too. + +The removing process can take up to 2 hours to complete. + +::: + +## Examples + +Remove the multitenant organization + +```sh +m365 entra multitenant remove +``` + +Remove the multitenant organization without prompting for confirmation + +```sh +m365 entra multitenant remove --force +``` + +## Response + +The command won't return a response on success + +## More information + +- Multitenant organization: https://learn.microsoft.com/entra/identity/multi-tenant-organizations/overview diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 53a4f416d31..9f7bea6012f 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -545,6 +545,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'multitenant get', id: 'cmd/entra/multitenant/multitenant-get' + }, + { + type: 'doc', + label: 'multitenant remove', + id: 'cmd/entra/multitenant/multitenant-remove' } ] }, diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts index c490d77bd88..1bd5a070219 100644 --- a/src/m365/entra/commands.ts +++ b/src/m365/entra/commands.ts @@ -74,6 +74,7 @@ export default { M365GROUP_USER_REMOVE: `${prefix} m365group user remove`, M365GROUP_USER_SET: `${prefix} m365group user set`, MULTITENANT_GET: `${prefix} multitenant get`, + MULTITENANT_REMOVE: `${prefix} multitenant remove`, OAUTH2GRANT_ADD: `${prefix} oauth2grant add`, OAUTH2GRANT_LIST: `${prefix} oauth2grant list`, OAUTH2GRANT_REMOVE: `${prefix} oauth2grant remove`, diff --git a/src/m365/entra/commands/multitenant/multitenant-remove.spec.ts b/src/m365/entra/commands/multitenant/multitenant-remove.spec.ts new file mode 100644 index 00000000000..76dd83610b5 --- /dev/null +++ b/src/m365/entra/commands/multitenant/multitenant-remove.spec.ts @@ -0,0 +1,229 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { Logger } from '../../../../cli/Logger.js'; +import commands from '../../commands.js'; +import { cli } from '../../../../cli/cli.js'; +import request from '../../../../request.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import command from './multitenant-remove.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { CommandError } from '../../../../Command.js'; + +describe(commands.MULTITENANT_REMOVE, () => { + const tenantId = "526dcbd1-4f42-469e-be90-ba4a7c0b7802"; + const organization = { + "id": "526dcbd1-4f42-469e-be90-ba4a7c0b7802" + }; + const multitenantOrganizationMembers = [ + { + "tenantId": "526dcbd1-4f42-469e-be90-ba4a7c0b7802" + }, + { + "tenantId": "6babcaad-604b-40ac-a9d7-9fd97c0b779f" + } + ]; + let log: string[]; + let logger: Logger; + let promptIssued: boolean; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + sinon.stub(cli, 'promptForConfirmation').callsFake(() => { + promptIssued = true; + return Promise.resolve(false); + }); + + promptIssued = false; + }); + + afterEach(() => { + sinonUtil.restore([ + request.delete, + request.get, + cli.handleMultipleResultsFound, + cli.promptForConfirmation, + global.setTimeout + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.MULTITENANT_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('removes the multitenant organization without prompting for confirmation', async () => { + let i = 0; + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/organization?$select=id`) { + return { + value: [ + organization + ] + }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/tenantRelationships/multiTenantOrganization/tenants?$select=tenantId`) { + if (i++ < 2) { + return { + value: multitenantOrganizationMembers + }; + } + return { + value: [ + multitenantOrganizationMembers[0] + ] + }; + } + + throw 'Invalid request'; + }); + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/tenantRelationships/multiTenantOrganization/tenants/${multitenantOrganizationMembers[0].tenantId}` + || opts.url === `https://graph.microsoft.com/v1.0/tenantRelationships/multiTenantOrganization/tenants/${multitenantOrganizationMembers[1].tenantId}`) { + return; + } + + throw 'Invalid request'; + }); + + sinon.stub(global, 'setTimeout').callsFake((fn) => { + fn(); + return {} as any; + }); + + await command.action(logger, { options: { force: true, verbose: true } }); + assert(deleteRequestStub.calledTwice); + }); + + it('removes the multitenant organization while prompting for confirmation', async () => { + let i = 0; + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/organization?$select=id`) { + return { + value: [ + organization + ] + }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/tenantRelationships/multiTenantOrganization/tenants?$select=tenantId`) { + if (i++ < 2) { + return { + value: multitenantOrganizationMembers + }; + } + return { + value: [ + multitenantOrganizationMembers[0] + ] + }; + } + + throw 'Invalid request'; + }); + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/tenantRelationships/multiTenantOrganization/tenants/${multitenantOrganizationMembers[0].tenantId}` + || opts.url === `https://graph.microsoft.com/v1.0/tenantRelationships/multiTenantOrganization/tenants/${multitenantOrganizationMembers[1].tenantId}`) { + return; + } + + throw 'Invalid request'; + }); + + sinon.stub(global, 'setTimeout').callsFake((fn) => { + fn(); + return {} as any; + }); + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: { } }); + assert(deleteRequestStub.calledTwice); + }); + + it('prompts before removing the multitenant organization when prompt option not passed', async () => { + await command.action(logger, { options: { } }); + + assert(promptIssued); + }); + + it('aborts removing the multitenant organization when prompt not confirmed', async () => { + const deleteSpy = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { options: { } }); + assert(deleteSpy.notCalled); + }); + + it('throws an error when one of the tenant cannot be found', async () => { + const error = { + error: { + code: 'Request_ResourceNotFound', + message: `Resource '${tenantId}' does not exist or one of its queried reference-property objects are not present.`, + innerError: { + date: '2024-05-07T06:59:51', + 'request-id': 'b7dee9ee-d85b-4e7a-8686-74852cbfd85b', + 'client-request-id': 'b7dee9ee-d85b-4e7a-8686-74852cbfd85b' + } + } + }; + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/organization?$select=id`) { + return { + value: [ + organization + ] + }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/tenantRelationships/multiTenantOrganization/tenants?$select=tenantId`) { + return { + value: multitenantOrganizationMembers + }; + } + + throw 'Invalid request'; + }); + + sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/tenantRelationships/multiTenantOrganization/tenants/${multitenantOrganizationMembers[1].tenantId}`) { + throw error; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { force: true } }), + new CommandError(error.error.message)); + }); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/multitenant/multitenant-remove.ts b/src/m365/entra/commands/multitenant/multitenant-remove.ts new file mode 100644 index 00000000000..7c4fff142da --- /dev/null +++ b/src/m365/entra/commands/multitenant/multitenant-remove.ts @@ -0,0 +1,153 @@ +import GlobalOptions from '../../../../GlobalOptions.js'; +import { Organization } from '@microsoft/microsoft-graph-types'; +import { Logger } from '../../../../cli/Logger.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { odata } from '../../../../utils/odata.js'; +import { cli } from '../../../../cli/cli.js'; + +interface MultitenantOrganizationMember { + tenantId?: string; +} + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + force?: boolean; +} + +class EntraMultitenantRemoveCommand extends GraphCommand { + public get name(): string { + return commands.MULTITENANT_REMOVE; + } + + public get description(): string { + return 'Removes a multitenant organization'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + force: !!args.options.force + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-f, --force' + } + ); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + + const removeMultitenantOrg = async (): Promise => { + try { + const tenantId = await this.getCurrentTenantId(); + + let tenantsId = await this.getAllTenantsIds(); + const tenantsCount = tenantsId.length; + + if (tenantsCount > 0) { + const tasks = tenantsId + .filter(x => x !== tenantId) + .map(t => this.removeTenant(logger, t)); + await Promise.all(tasks); + + do { + if (this.verbose) { + await logger.logToStderr(`Waiting 30 seconds...`); + } + + await new Promise(resolve => setTimeout(resolve, 30000)); + + // from current behavior, removing tenant can take a few seconds + // current tenant must be removed once all previous ones were removed + if (this.verbose) { + await logger.logToStderr(`Checking all tenants were removed...`); + } + + tenantsId = await this.getAllTenantsIds(); + + if (this.verbose) { + await logger.logToStderr(`Number of removed tenants: ${tenantsCount - tenantsId.length}`); + } + } + while (tenantsId.length !== 1); + + // current tenant must be removed as the last one + await this.removeTenant(logger, tenantId); + await logger.logToStderr('Your Multi-Tenant organization is being removed; this can take up to 2 hours.'); + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await removeMultitenantOrg(); + } + else { + const result = await cli.promptForConfirmation({ message: 'Are you sure you want to remove multitenant organization?' }); + + if (result) { + await removeMultitenantOrg(); + } + } + } + + private async getAllTenantsIds(): Promise { + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/tenantRelationships/multiTenantOrganization/tenants?$select=tenantId`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + const tenants = await odata.getAllItems(requestOptions); + return tenants.map(x => x.tenantId!); + } + + private async getCurrentTenantId(): Promise { + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/organization?$select=id`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + const organizations = await odata.getAllItems(requestOptions); + return organizations[0].id!; + } + + private async removeTenant(logger: Logger, tenantId: string): Promise { + if (this.verbose) { + await logger.logToStderr(`Removing tenant: ${tenantId}`); + } + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/tenantRelationships/multiTenantOrganization/tenants/${tenantId}`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + await request.delete(requestOptions); + } +} + +export default new EntraMultitenantRemoveCommand(); \ No newline at end of file