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