diff --git a/docs/docs/cmd/entra/group/group-user-add.mdx b/docs/docs/cmd/entra/group/group-user-add.mdx index c220b3c9f1d..489eb42c247 100644 --- a/docs/docs/cmd/entra/group/group-user-add.mdx +++ b/docs/docs/cmd/entra/group/group-user-add.mdx @@ -2,7 +2,7 @@ import Global from '/docs/cmd/_global.mdx'; # entra group user add -Adds a user to a Microsoft Entra ID group +Adds users to a Microsoft Entra ID group ## Usage diff --git a/docs/docs/cmd/entra/group/group-user-remove.mdx b/docs/docs/cmd/entra/group/group-user-remove.mdx new file mode 100644 index 00000000000..15af85e580c --- /dev/null +++ b/docs/docs/cmd/entra/group/group-user-remove.mdx @@ -0,0 +1,84 @@ +import Global from '/docs/cmd/_global.mdx'; + +# entra group user remove + +Removes users from a Microsoft Entra ID group + +## Usage + +```sh +m365 entra group user remove [options] +``` + +## Options + +```md definition-list +`-i, --groupId [groupId]` +: The ID of the Entra ID group. Specify `groupId` or `groupDisplayName` but not both. + +`-n, --groupDisplayName [groupDisplayName]` +: The display name of the Entra ID group. Specify `groupId` or `groupDisplayName` but not both. + +`--ids [ids]` +: Entra ID IDs of users. You can also pass a comma-separated list of IDs. Specify either `ids` or `userNames` but not both. + +`--userNames [userNames]` +: The user principal names of users. You can also pass a comma-separated list of UPNs. Specify either `ids` or `userNames` but not both. + +`-r, --role [role]` +: The role to be removed from the users. Valid values: `Owner`, `Member`. Defaults to both. + +`--suppressNotFound` +: Suppress errors when a user was not found in a group. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Remarks + +:::tip + +When you use the `suppressNotFound` option, the command will not return an error if a user is not found as either an owner or a member of the group. +This feature proves useful when you need to remove a user from a group, but you are uncertain whether the user holds the role of a member or an owner within that group. +Without using this option, you would need to manually verify the user's role in the group before proceeding with removal. + +::: + +## Examples + +Remove a single user specified by ID as member from a group specified by display name + +```sh +m365 entra group user remove --groupDisplayName Developers --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --role Member +``` + +Remove multiple users specified by ID from a group specified by ID + +```sh +m365 entra group user remove --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --ids "098b9f52-f48c-4401-819f-29c33794c3f5,f1e06e31-3abf-4746-83c2-1513d71f38b8" +``` + +Remove a single user specified by UPN as an owner from a group specified by display name + +```sh +m365 entra group user remove --groupDisplayName Developers --userNames john.doe@contoso.com --role Owner +``` + +Remove multiple users specified by UPN from a group specified by ID + +```sh +m365 entra group user remove --groupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --userNames "john.doe@contoso.com,adele.vance@contoso.com" +``` + +Remove a single user specified by ID as owner and member of the group and suppress errors when the user was not found as owner or member + +```sh +m365 entra group user remove --groupDisplayName Developers --ids 098b9f52-f48c-4401-819f-29c33794c3f5 --suppressNotFound +``` + +## Response + +The command won't return a response on success. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 50bd2573aeb..7bb972df07d 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -358,6 +358,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'group user list', id: 'cmd/entra/group/group-user-list' + }, + { + type: 'doc', + label: 'group user remove', + id: 'cmd/entra/group/group-user-remove' } ] }, diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts index ccb4b00f200..5d2676ca4c3 100644 --- a/src/m365/entra/commands.ts +++ b/src/m365/entra/commands.ts @@ -41,6 +41,7 @@ export default { GROUP_REMOVE: `${prefix} group remove`, GROUP_USER_ADD: `${prefix} group user add`, GROUP_USER_LIST: `${prefix} group user list`, + GROUP_USER_REMOVE: `${prefix} group user remove`, GROUPSETTING_ADD: `${prefix} groupsetting add`, GROUPSETTING_GET: `${prefix} groupsetting get`, GROUPSETTING_LIST: `${prefix} groupsetting list`, diff --git a/src/m365/entra/commands/group/group-user-add.ts b/src/m365/entra/commands/group/group-user-add.ts index b3dc6b3b7f7..a33a3bcad17 100644 --- a/src/m365/entra/commands/group/group-user-add.ts +++ b/src/m365/entra/commands/group/group-user-add.ts @@ -3,6 +3,7 @@ import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { entraUser } from '../../../../utils/entraUser.js'; +import { GraphBatchRequest, GraphBatchRequestItem } from '../../../../utils/types.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; @@ -27,7 +28,7 @@ class EntraGroupUserAddCommand extends GraphCommand { } public get description(): string { - return 'Adds a user to a Microsoft Entra ID group'; + return 'Adds users to a Microsoft Entra ID group'; } constructor() { @@ -75,11 +76,11 @@ class EntraGroupUserAddCommand extends GraphCommand { #initValidators(): void { this.validators.push( async (args: CommandArgs) => { - if (args.options.groupId && !validation.isValidGuid(args.options.groupId)) { - return `${args.options.groupId} is not a valid GUID for option groupId.`; + if (args.options.groupId !== undefined && !validation.isValidGuid(args.options.groupId)) { + return `'${args.options.groupId}' is not a valid GUID for option 'groupId'.`; } - if (args.options.ids) { + if (args.options.ids !== undefined) { const ids = args.options.ids.split(',').map(i => i.trim()); if (!validation.isValidGuidArray(ids)) { const invalidGuid = ids.find(id => !validation.isValidGuid(id)); @@ -87,7 +88,7 @@ class EntraGroupUserAddCommand extends GraphCommand { } } - if (args.options.userNames) { + if (args.options.userNames !== undefined) { const isValidUserPrincipalNameArray = validation.isValidUserPrincipalNameArray(args.options.userNames.split(',').map(u => u.trim())); if (isValidUserPrincipalNameArray !== true) { return `User principal name '${isValidUserPrincipalNameArray}' is invalid for option 'userNames'.`; @@ -133,7 +134,7 @@ class EntraGroupUserAddCommand extends GraphCommand { responseType: 'json', data: { requests: [] - } + } as GraphBatchRequest }; for (let j = 0; j < userIdsBatch.length; j += 20) { @@ -148,7 +149,7 @@ class EntraGroupUserAddCommand extends GraphCommand { body: { [`${args.options.role === 'Member' ? 'members' : 'owners'}@odata.bind`]: userIdsChunk.map(u => `${this.resource}/v1.0/directoryObjects/${u}`) } - }); + } as GraphBatchRequestItem); } const res = await request.post<{ responses: { status: number; body: any }[] }>(requestOptions); @@ -170,7 +171,7 @@ class EntraGroupUserAddCommand extends GraphCommand { } if (this.verbose) { - await logger.logToStderr(`Retrieving ID of group ${options.groupDisplayName}...`); + await logger.logToStderr(`Retrieving ID of group '${options.groupDisplayName}'...`); } return entraGroup.getGroupIdByDisplayName(options.groupDisplayName!); diff --git a/src/m365/entra/commands/group/group-user-remove.spec.ts b/src/m365/entra/commands/group/group-user-remove.spec.ts new file mode 100644 index 00000000000..b745324e1c7 --- /dev/null +++ b/src/m365/entra/commands/group/group-user-remove.spec.ts @@ -0,0 +1,346 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import commands from '../../commands.js'; +import { telemetry } from '../../../../telemetry.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import command from './group-user-remove.js'; +import request from '../../../../request.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { CommandError } from '../../../../Command.js'; + +describe(commands.GROUP_USER_REMOVE, () => { + const groupId = '630dfae3-6904-4154-acc2-812e11205351'; + const upns = ['user1@contoso.com', 'user2@contoso.com', 'user3@contoso.com', 'user4@contoso.com', 'user5@contoso.com', 'user6@contoso.com', 'user7@contoso.com', 'user8@contoso.com', 'user9@contoso.com', 'user10@contoso.com']; + const userIds = ['3f2504e0-4f89-11d3-9a0c-0305e82c3301', '6dcd4ce0-4f89-11d3-9a0c-0305e82c3302', '9b76f130-4f89-11d3-9a0c-0305e82c3303', 'c835f5e0-4f89-11d3-9a0c-0305e82c3304', 'f4f3fa90-4f89-11d3-9a0c-0305e82c3305', '2230f6a0-4f8a-11d3-9a0c-0305e82c3306', '4f6df5b0-4f8a-11d3-9a0c-0305e82c3307', '7caaf4c0-4f8a-11d3-9a0c-0305e82c3308', 'a9e8f3d0-4f8a-11d3-9a0c-0305e82c3309', 'd726f2e0-4f8a-11d3-9a0c-0305e82c330a']; + + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + + 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; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.post, + entraGroup.getGroupIdByDisplayName, + entraUser.getUserIdsByUpns, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.GROUP_USER_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if groupId is not a valid GUID', async () => { + const actual = await command.validate({ options: { groupId: 'foo', ids: userIds[0] } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if ids contains an invalid GUID', async () => { + const actual = await command.validate({ options: { groupId: groupId, ids: `${userIds[0]},foo` } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if userNames contains an invalid UPN', async () => { + const actual = await command.validate({ options: { groupId: groupId, userNames: `${upns[0]},foo` } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if role is not a valid role', async () => { + const actual = await command.validate({ options: { groupId: groupId, ids: userIds.join(','), role: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with ids', async () => { + const actual = await command.validate({ options: { groupId: groupId, ids: userIds.join(',') } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with ids with leading spaces', async () => { + const actual = await command.validate({ options: { groupId: groupId, ids: userIds.map(i => ' ' + i).join(','), role: 'Member' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with names', async () => { + const actual = await command.validate({ options: { groupDisplayName: 'IT department', userNames: upns.join(',') } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with names with trailing spaces', async () => { + const actual = await command.validate({ options: { groupDisplayName: 'IT department', userNames: upns.map(u => u + ' ').join(','), role: 'Owner' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('prompts before removing the specified users when confirm option not passed', async () => { + const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); + + await command.action(logger, { options: { groupDisplayName: 'IT department', ids: userIds.join(',') } }); + + assert(confirmationStub.calledOnce); + }); + + it('aborts removing users when prompt not confirmed', async () => { + sinon.stub(cli, 'promptForConfirmation').resolves(false); + + const postSpy = sinon.stub(request, 'post').resolves(); + + await command.action(logger, { options: { groupId: groupId, ids: userIds.join(',') } }); + assert(postSpy.notCalled); + }); + + it('successfully removes owners and members from the group with ids after confirming prompt', async () => { + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(20).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + method: 'DELETE', + url: `/groups/${groupId}/${index >= 10 ? 'members' : 'owners'}/${userIds[index % 10]}/$ref`, + headers: { 'content-type': 'application/json;odata.metadata=none' } + }))); + }); + + it('successfully removes owners and members from the group with ids with trailing spaces', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(20).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupId: groupId, ids: userIds.map(i => i + ' ').join(','), force: true, verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + method: 'DELETE', + url: `/groups/${groupId}/${index >= 10 ? 'members' : 'owners'}/${userIds[index % 10]}/$ref`, + headers: { 'content-type': 'application/json;odata.metadata=none' } + }))); + }); + + it('successfully removes owners and members from the group by using names after confirming', async () => { + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); + sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(20).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupDisplayName: 'Contoso', userNames: upns.join(','), verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + method: 'DELETE', + url: `/groups/${groupId}/${index >= 10 ? 'members' : 'owners'}/${userIds[index % 10]}/$ref`, + headers: { 'content-type': 'application/json;odata.metadata=none' } + }))); + }); + + it('successfully removes owners and members from the group by using names with leading spaces', async () => { + sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); + sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(20).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupDisplayName: 'Contoso', userNames: upns.map(u => + ' ' + u).join(','), force: true, verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + method: 'DELETE', + url: `/groups/${groupId}/${index >= 10 ? 'members' : 'owners'}/${userIds[index % 10]}/$ref`, + headers: { 'content-type': 'application/json;odata.metadata=none' } + }))); + }); + + it('successfully removes owners from the group with ids', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(10).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), role: 'Owner', force: true, verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 10 }, (_, index) => ({ + id: index + 1, + method: 'DELETE', + url: `/groups/${groupId}/owners/${userIds[index]}/$ref`, + headers: { 'content-type': 'application/json;odata.metadata=none' } + }))); + }); + + it('successfully removes members from the group by using names', async () => { + sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); + sinon.stub(entraUser, 'getUserIdsByUpns').resolves(userIds); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(10).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupDisplayName: 'Contoso', userNames: upns.join(','), role: 'Member', force: true, verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, Array.from({ length: 10 }, (_, index) => ({ + id: index + 1, + method: 'DELETE', + url: `/groups/${groupId}/members/${userIds[index]}/$ref`, + headers: { 'content-type': 'application/json;odata.metadata=none' } + }))); + }); + + it('handles API errors correctly', async () => { + const errorMessage = `Resource '${groupId}' does not exist or one of its queried reference-property objects are not present.`; + + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array.from({ length: 20 }, (_, index) => { + if (index < 10) { + return { + status: 204, + body: {} + }; + } + return { + status: 404, + body: { + error: { + code: 'Request_ResourceNotFound', + message: errorMessage + } + } + }; + }) + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), force: true, verbose: true } }), + new CommandError(errorMessage)); + }); + + it('correctly suppresses not found requests', async () => { + const errorMessage = `Resource '${groupId}' does not exist or one of its queried reference-property objects are not present.`; + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array.from({ length: 20 }, (_, index) => { + if (index < 10) { + return { + status: 204, + body: {} + }; + } + return { + status: 404, + body: { + error: { + code: 'Request_ResourceNotFound', + message: errorMessage + } + } + }; + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { groupId: groupId, ids: userIds.join(','), suppressNotFound: true, force: true, verbose: true } }); + assert(postStub.calledOnce); + }); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/group/group-user-remove.ts b/src/m365/entra/commands/group/group-user-remove.ts new file mode 100644 index 00000000000..5cf78c5e727 --- /dev/null +++ b/src/m365/entra/commands/group/group-user-remove.ts @@ -0,0 +1,222 @@ +import { cli } from '../../../../cli/cli.js'; +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { GraphBatchRequest, GraphBatchRequestResponse } from '../../../../utils/types.js'; +import { validation } from '../../../../utils/validation.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + groupId?: string; + groupDisplayName?: string; + ids?: string; + userNames?: string; + role?: string; + suppressNotFound?: boolean; + force?: boolean; +} + +class EntraGroupUserRemoveCommand extends GraphCommand { + private readonly roleValues = ['Owner', 'Member']; + + public get name(): string { + return commands.GROUP_USER_REMOVE; + } + + public get description(): string { + return 'Removes users from a Microsoft Entra ID group'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + groupId: typeof args.options.groupId !== 'undefined', + groupDisplayName: typeof args.options.groupDisplayName !== 'undefined', + ids: typeof args.options.ids !== 'undefined', + userNames: typeof args.options.userNames !== 'undefined', + role: typeof args.options.role !== 'undefined', + suppressNotFound: !!args.options.suppressNotFound, + force: !!args.options.force + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-i, --groupId [groupId]' + }, + { + option: '-n, --groupDisplayName [groupDisplayName]' + }, + { + option: '--ids [ids]' + }, + { + option: '--userNames [userNames]' + }, + { + option: '-r, --role [role]', + autocomplete: this.roleValues + }, + { + option: '--suppressNotFound' + }, + { + option: '-f, --force' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.groupId !== undefined && !validation.isValidGuid(args.options.groupId)) { + return `'${args.options.groupId}' is not a valid GUID for option 'groupId'.`; + } + + if (args.options.ids !== undefined) { + const ids = args.options.ids.split(',').map(i => i.trim()); + if (!validation.isValidGuidArray(ids)) { + const invalidGuid = ids.find(id => !validation.isValidGuid(id)); + return `'${invalidGuid}' is not a valid GUID for option 'ids'.`; + } + } + + if (args.options.userNames !== undefined) { + const isValidUserPrincipalNameArray = validation.isValidUserPrincipalNameArray(args.options.userNames.split(',').map(u => u.trim())); + if (isValidUserPrincipalNameArray !== true) { + return `User principal name '${isValidUserPrincipalNameArray}' is invalid for option 'userNames'.`; + } + } + + if (args.options.role !== undefined && this.roleValues.indexOf(args.options.role) === -1) { + return `Option 'role' must be one of the following values: ${this.roleValues.join(', ')}.`; + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push( + { options: ['groupId', 'groupDisplayName'] }, + { options: ['ids', 'userNames'] } + ); + } + + #initTypes(): void { + this.types.string.push('groupId', 'groupDisplayName', 'ids', 'userNames', 'role'); + this.types.boolean.push('force', 'suppressNotFound'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + const removeUsers = async (): Promise => { + if (this.verbose) { + await logger.logToStderr(`Removing user(s) ${args.options.ids || args.options.userNames} from group ${args.options.groupId || args.options.groupDisplayName}...`); + } + + const groupId = await this.getGroupId(logger, args.options); + const userIds = await this.getUserIds(logger, args.options); + + const endpoints = []; + if (!args.options.role || args.options.role === 'Owner') { + endpoints.push(...userIds.map(id => `/groups/${groupId}/owners/${id}/$ref`)); + } + if (!args.options.role || args.options.role === 'Member') { + endpoints.push(...userIds.map(id => `/groups/${groupId}/members/${id}/$ref`)); + } + + for (let i = 0; i < endpoints.length; i += 20) { + const endpointsBatch = endpoints.slice(i, i + 20); + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/$batch`, + headers: { + 'content-type': 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + requests: endpointsBatch.map((ep, index) => ({ + id: index + 1, + method: 'DELETE', + url: ep, + headers: { + 'content-type': 'application/json;odata.metadata=none' + } + })) + } as GraphBatchRequest + }; + + const res = await request.post(requestOptions); + for (const response of res.responses) { + // Suppress 404 errors if suppressNotFound is set + if (response.status !== 204 && (!args.options.suppressNotFound || response.status !== 404)) { + throw response.body; + } + } + } + }; + + if (args.options.force) { + await removeUsers(); + } + else { + const users = args.options.ids || args.options.userNames; + const userList = users!.split(','); + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove ${userList.length} user(s) from group '${args.options.groupId || args.options.groupDisplayName}'?` }); + + if (result) { + await removeUsers(); + } + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getGroupId(logger: Logger, options: Options): Promise { + if (options.groupId) { + return options.groupId; + } + + if (this.verbose) { + await logger.logToStderr(`Retrieving ID of group '${options.groupDisplayName}'...`); + } + + return entraGroup.getGroupIdByDisplayName(options.groupDisplayName!); + } + + private async getUserIds(logger: Logger, options: Options): Promise { + if (options.ids) { + return options.ids.split(',').map(i => i.trim()); + } + + if (this.verbose) { + await logger.logToStderr('Retrieving ID(s) of user(s)...'); + } + + return entraUser.getUserIdsByUpns(options.userNames!.split(',').map(u => u.trim())); + } +} + +export default new EntraGroupUserRemoveCommand(); \ No newline at end of file diff --git a/src/utils/types.ts b/src/utils/types.ts index 6f15c69e259..54343e415c8 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,7 +1,30 @@ export interface Dictionary { - [key: string] : T; + [key: string]: T; } export interface Hash { - [key: string] : string; -} \ No newline at end of file + [key: string]: string; +} + +// #region Graph types +export interface GraphBatchRequest { + requests: GraphBatchRequestItem[]; +} + +export interface GraphBatchRequestItem { + id: number | string; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + url: string; + headers?: { [key: string]: string }; + body?: any; +} + +export interface GraphBatchRequestResponse { + responses: { + id: number | string; + status: number; + headers?: { [key: string]: string }; + body?: any; + }[]; +} +// #endregion \ No newline at end of file