From 193de18f48319b22afc4307328deb5daba87c3b3 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 user remove' command. Closes #5472
---
docs/docs/cmd/entra/group/group-user-add.mdx | 2 +-
.../cmd/entra/group/group-user-remove.mdx | 84 +++++
docs/src/config/sidebars.ts | 5 +
src/m365/entra/commands.ts | 1 +
.../entra/commands/group/group-user-add.ts | 17 +-
.../commands/group/group-user-remove.spec.ts | 346 ++++++++++++++++++
.../entra/commands/group/group-user-remove.ts | 222 +++++++++++
src/utils/types.ts | 29 +-
8 files changed, 694 insertions(+), 12 deletions(-)
create mode 100644 docs/docs/cmd/entra/group/group-user-remove.mdx
create mode 100644 src/m365/entra/commands/group/group-user-remove.spec.ts
create mode 100644 src/m365/entra/commands/group/group-user-remove.ts
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