From 76dfd1611d434ee32cb5c05b8ff95df7cd60ea3f Mon Sep 17 00:00:00 2001
From: Milan Holemans <11723921+milanholemans@users.noreply.github.com>
Date: Sat, 10 Feb 2024 16:53:23 +0100
Subject: [PATCH] Adds 'entra group member remove' command. Closes #5472
---
.../docs/cmd/entra/group/group-member-add.mdx | 2 +-
.../cmd/entra/group/group-member-remove.mdx | 84 +++++
docs/src/config/sidebars.ts | 5 +
src/m365/entra/commands.ts | 1 +
.../entra/commands/group/group-member-add.ts | 2 +-
.../group/group-member-remove.spec.ts | 346 ++++++++++++++++++
.../commands/group/group-member-remove.ts | 221 +++++++++++
src/utils/types.ts | 29 +-
8 files changed, 685 insertions(+), 5 deletions(-)
create mode 100644 docs/docs/cmd/entra/group/group-member-remove.mdx
create mode 100644 src/m365/entra/commands/group/group-member-remove.spec.ts
create mode 100644 src/m365/entra/commands/group/group-member-remove.ts
diff --git a/docs/docs/cmd/entra/group/group-member-add.mdx b/docs/docs/cmd/entra/group/group-member-add.mdx
index 59d17a13ad1..9abf1f1b38a 100644
--- a/docs/docs/cmd/entra/group/group-member-add.mdx
+++ b/docs/docs/cmd/entra/group/group-member-add.mdx
@@ -2,7 +2,7 @@ import Global from '/docs/cmd/_global.mdx';
# entra group member add
-Adds a member to a Microsoft Entra ID group
+Adds members to a Microsoft Entra group
## Usage
diff --git a/docs/docs/cmd/entra/group/group-member-remove.mdx b/docs/docs/cmd/entra/group/group-member-remove.mdx
new file mode 100644
index 00000000000..6c533667777
--- /dev/null
+++ b/docs/docs/cmd/entra/group/group-member-remove.mdx
@@ -0,0 +1,84 @@
+import Global from '/docs/cmd/_global.mdx';
+
+# entra group member remove
+
+Removes members from a Microsoft Entra group
+
+## Usage
+
+```sh
+m365 entra group member 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 member 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 member 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 member 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 member 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 member 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 c995ffe5bdb..4a3a0fcee16 100644
--- a/docs/src/config/sidebars.ts
+++ b/docs/src/config/sidebars.ts
@@ -373,6 +373,11 @@ const sidebars: SidebarsConfig = {
label: 'group member list',
id: 'cmd/entra/group/group-member-list'
},
+ {
+ type: 'doc',
+ label: 'group member remove',
+ id: 'cmd/entra/group/group-member-remove'
+ },
{
type: 'doc',
label: 'group member set',
diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts
index 4b51b5d02e4..5afeb13afda 100644
--- a/src/m365/entra/commands.ts
+++ b/src/m365/entra/commands.ts
@@ -40,6 +40,7 @@ export default {
GROUP_ADD: `${prefix} group add`,
GROUP_GET: `${prefix} group get`,
GROUP_LIST: `${prefix} group list`,
+ GROUP_MEMBER_REMOVE: `${prefix} group member remove`,
GROUP_REMOVE: `${prefix} group remove`,
GROUP_SET: `${prefix} group set`,
GROUP_MEMBER_ADD: `${prefix} group member add`,
diff --git a/src/m365/entra/commands/group/group-member-add.ts b/src/m365/entra/commands/group/group-member-add.ts
index 8e515bc077b..2856a6842d2 100644
--- a/src/m365/entra/commands/group/group-member-add.ts
+++ b/src/m365/entra/commands/group/group-member-add.ts
@@ -27,7 +27,7 @@ class EntraGroupMemberAddCommand extends GraphCommand {
}
public get description(): string {
- return 'Adds a member to a Microsoft Entra ID group';
+ return 'Adds members to a Microsoft Entra group';
}
constructor() {
diff --git a/src/m365/entra/commands/group/group-member-remove.spec.ts b/src/m365/entra/commands/group/group-member-remove.spec.ts
new file mode 100644
index 00000000000..ee941ddcef2
--- /dev/null
+++ b/src/m365/entra/commands/group/group-member-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-member-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_MEMBER_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_MEMBER_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-member-remove.ts b/src/m365/entra/commands/group/group-member-remove.ts
new file mode 100644
index 00000000000..cfa81c780a3
--- /dev/null
+++ b/src/m365/entra/commands/group/group-member-remove.ts
@@ -0,0 +1,221 @@
+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 EntraGroupMemberRemoveCommand extends GraphCommand {
+ private readonly roleValues = ['Owner', 'Member'];
+
+ public get name(): string {
+ return commands.GROUP_MEMBER_REMOVE;
+ }
+
+ public get description(): string {
+ return 'Removes members from a Microsoft Entra 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 invalidGuids = validation.isValidGuidArray(args.options.ids);
+ if (invalidGuids !== true) {
+ return `Invalid GUIDs found for option 'ids': ${invalidGuids}.`;
+ }
+ }
+
+ if (args.options.userNames !== undefined) {
+ const invalidUpns = validation.isValidUserPrincipalNameArray(args.options.userNames);
+ if (invalidUpns !== true) {
+ return `Invalid UPNs found for option 'userNames': ${invalidUpns}.`;
+ }
+ }
+
+ 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 EntraGroupMemberRemoveCommand();
\ 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