From 3ba77386366de61307902f8cf86e3658af7c98a1 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Mon, 16 Sep 2024 11:50:04 +0200 Subject: [PATCH] Enhances 'entra m365group user remove' command. Closes #6058 --- .../entra/m365group/m365group-user-remove.mdx | 36 +- .../m365group/m365group-user-remove.spec.ts | 430 +++++++----------- .../m365group/m365group-user-remove.ts | 178 +++++--- 3 files changed, 295 insertions(+), 349 deletions(-) diff --git a/docs/docs/cmd/entra/m365group/m365group-user-remove.mdx b/docs/docs/cmd/entra/m365group/m365group-user-remove.mdx index e560924f197..930fc5df09f 100644 --- a/docs/docs/cmd/entra/m365group/m365group-user-remove.mdx +++ b/docs/docs/cmd/entra/m365group/m365group-user-remove.mdx @@ -14,13 +14,25 @@ m365 entra m365group user remove [options] ```md definition-list `-i, --groupId [groupId]` -: The ID of the Microsoft 365 Group from which to remove the user. +: The ID of the Microsoft 365 group. Specify only one of the following: `groupId`, `groupName`, `teamId`, or `teamName`. + +`--groupName [groupName]` +: The display name of the Microsoft 365 group. Specify only one of the following: `groupId`, `groupName`, `teamId`, or `teamName`.. `--teamId [teamId]` -: The ID of the Microsoft Teams team from which to remove the user. +: The ID of the Microsoft Teams team. Specify only one of the following: `groupId`, `groupName`, `teamId`, or `teamName`. + +`--teamName [teamName]` +: The display name of the Microsoft Teams team. Specify only one of the following: `groupId`, `groupName`, `teamId`, or `teamName`. + +`-n, --userName [userName]` +: (deprecated) User's UPN (user principal name), eg. `johndoe@example.com`.. Specify only one of the following: `userName`, `ids` or `userNames`. + +`--ids [ids]` +: Microsoft Entra IDs of users. You can also pass a comma-separated list of IDs. Specify only one of the following `userName`, `ids` or `userNames`. -`-n, --userName ` -: User's UPN (user principal name), eg. `johndoe@example.com`. +`--userNames [userNames]` +: The user principal names of users. You can also pass a comma-separated list of UPNs. Specify only one of the following `userName`, `ids` or `userNames`. `-f, --force` : Don't prompt for confirming removing the user from the specified Microsoft 365 Group or Microsoft Teams team. @@ -37,19 +49,25 @@ You can remove users from a Microsoft 365 Group or Microsoft Teams team if you a Removes user from the specified Microsoft 365 Group. ```sh -m365 entra m365group user remove --groupId '00000000-0000-0000-0000-000000000000' --userName 'anne.matthews@contoso.onmicrosoft.com' +m365 entra m365group user remove --groupId '5b8e4cb1-ea40-484b-a94e-02a4313fefb4' --userNames 'anne.matthews@contoso.onmicrosoft.com' +``` + +Removes user from the specified Microsoft 365 Team specified by id without confirmation. + +```sh +m365 entra m365group user remove --teamId '5b8e4cb1-ea40-484b-a94e-02a4313fefb4' --userNames 'anne.matthews@contoso.onmicrosoft.com' --force ``` -Removes user from the specified Microsoft 365 Group without confirmation. +Removes users specified by a comma separated list of user principal names from the specified Microsoft Teams team specified by name ```sh -m365 entra m365group user remove --groupId '00000000-0000-0000-0000-000000000000' --userName 'anne.matthews@contoso.onmicrosoft.com' --force +m365 entra m365group user remove --teamName 'Project Team' --userNames 'anne.matthews@contoso.onmicrosoft.com,john@contoso.com' ``` -Removes user from the specified Microsoft Teams team. +Removes users specified by a comma separated list of user ids from the specified Microsoft 365 group specified by name. ```sh -m365 entra teams user remove --teamId '00000000-0000-0000-0000-000000000000' --userName 'anne.matthews@contoso.onmicrosoft.com' +m365 entra m365group user remove --groupName 'Project Team' --ids '5b8e4cb1-ea40-484b-a94e-02a4313fefb4,be7a56d8-b045-4938-af35-917ab6e5309f' ``` ## Response diff --git a/src/m365/entra/commands/m365group/m365group-user-remove.spec.ts b/src/m365/entra/commands/m365group/m365group-user-remove.spec.ts index 6b2b25dfd02..5c9345823fd 100644 --- a/src/m365/entra/commands/m365group/m365group-user-remove.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-user-remove.spec.ts @@ -12,10 +12,15 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './m365group-user-remove.js'; -import { settingsNames } from '../../../../settingsNames.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; +import { entraUser } from '../../../../utils/entraUser.js'; describe(commands.M365GROUP_USER_REMOVE, () => { + const userName = 'adelev@contoso.com'; + const groupOrTeamId = '80ecb711-2501-4262-b29a-838d30bd3387'; + const userId = '8b38aeff-1642-47e4-b6ef-9d50d29638b7'; + const groupOrTeamName = 'Project Team'; + let log: string[]; let logger: Logger; let commandInfo: CommandInfo; @@ -26,7 +31,6 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinon.stub(telemetry, 'trackEvent').returns(); sinon.stub(pid, 'getProcessName').returns(''); sinon.stub(session, 'getId').returns(''); - sinon.stub(entraGroup, 'isUnifiedGroup').resolves(true); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); }); @@ -44,20 +48,24 @@ describe(commands.M365GROUP_USER_REMOVE, () => { log.push(msg); } }; - sinon.stub(cli, 'promptForConfirmation').callsFake(() => { + sinon.stub(cli, 'promptForConfirmation').callsFake(async () => { promptIssued = true; - return Promise.resolve(false); + return false; }); promptIssued = false; + sinon.stub(entraGroup, 'isUnifiedGroup').resolves(true); + sinon.stub(entraUser, 'getUserIdsByUpns').resolves([userId]); }); afterEach(() => { sinonUtil.restore([ request.get, request.delete, - global.setTimeout, cli.promptForConfirmation, - cli.getSettingWithDefaultValue + cli.getSettingWithDefaultValue, + entraUser.getUserIdsByUpns, + entraGroup.isUnifiedGroup, + entraGroup.getGroupIdByDisplayName ]); }); @@ -74,80 +82,95 @@ describe(commands.M365GROUP_USER_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if the groupId is not a valid guid.', async () => { + it('fails validation if userName is not a valid upn', async () => { const actual = await command.validate({ options: { - groupId: 'not-c49b-4fd4-8223-28f0ac3a6402', - userName: 'anne.matthews@contoso.onmicrosoft.com' + groupId: groupOrTeamId, + userName: 'invalid' } }, commandInfo); assert.notStrictEqual(actual, true); }); - it('fails validation if the teamId is not a valid guid.', async () => { + it('fails validation if the groupId is not a valid guid', async () => { const actual = await command.validate({ options: { - teamId: 'not-c49b-4fd4-8223-28f0ac3a6402', - userName: 'anne.matthews@contoso.onmicrosoft.com' + groupId: 'invalid', + userName: userName } }, commandInfo); assert.notStrictEqual(actual, true); }); - it('fails validation if neither the groupId nor the teamID are provided.', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; + it('fails validation if the teamId is not a valid guid', async () => { + const actual = await command.validate({ + options: { + teamId: 'invalid', + userName: userName } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); - return defaultValue; - }); - + it('fails validation if ids contain an invalid guid', async () => { const actual = await command.validate({ options: { - userName: 'anne.matthews@contoso.onmicrosoft.com' + teamId: groupOrTeamId, + ids: `invalid,${userId}` } }, commandInfo); assert.notStrictEqual(actual, true); }); - it('fails validation when both groupId and teamId are specified', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; + it('fails validation if userNames contain an invalid upn', async () => { + const actual = await command.validate({ + options: { + teamId: groupOrTeamId, + userNames: `invalid,${userName}` } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); - return defaultValue; - }); + it('passes validation when a valid teamId and userName are specified', async () => { + const actual = await command.validate({ + options: { + teamId: groupOrTeamId, + userName: userName + } + }, commandInfo); + assert.strictEqual(actual, true); + }); + it('passes validation when a valid teamId and userNames are specified', async () => { const actual = await command.validate({ options: { - groupId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - teamId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - userName: 'anne.matthews@contoso.onmicrosoft.com' + teamId: groupOrTeamId, + userNames: `${userName},john@contoso.com` } }, commandInfo); - assert.notStrictEqual(actual, true); + assert.strictEqual(actual, true); }); - it('passes validation when valid groupId and userName are specified', async () => { + it('passes validation when a valid teamId and ids are specified', async () => { const actual = await command.validate({ options: { - teamId: '6703ac8a-c49b-4fd4-8223-28f0ac3a6402', - userName: 'anne.matthews@contoso.onmicrosoft.com' + teamId: groupOrTeamId, + ids: `${userId},8b38aeff-1642-47e4-b6ef-9d50d29638b7` } }, commandInfo); assert.strictEqual(actual, true); }); + it('prompts before removing the specified user from the specified Microsoft 365 Group when force option not passed', async () => { - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com" } }); + await command.action(logger, { options: { groupId: groupOrTeamId, userName: userName } }); assert(promptIssued); }); it('prompts before removing the specified user from the specified Team when force option not passed (debug)', async () => { - await command.action(logger, { options: { debug: true, teamId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com" } }); + await command.action(logger, { options: { debug: true, teamId: "00000000-0000-0000-0000-000000000000", userName: userName } }); assert(promptIssued); }); @@ -157,7 +180,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com" } }); + await command.action(logger, { options: { groupId: groupOrTeamId, userName: userName } }); assert(postSpy.notCalled); }); @@ -166,47 +189,22 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(false); - await command.action(logger, { options: { debug: true, groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com" } }); + await command.action(logger, { options: { debug: true, groupId: groupOrTeamId, userName: userName } }); assert(postSpy.notCalled); }); it('removes the specified owner from owners and members endpoint of the specified Microsoft 365 Group with accepted prompt', async () => { let memberDeleteCallIssued = false; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews%40contoso.onmicrosoft.com/id`) { - return { - "value": "00000000-0000-0000-0000-000000000001" - }; - } - - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/id`) { - return { - response: { - status: 200, - data: { - value: "00000000-0000-0000-0000-000000000000" - } - } - }; - } - - throw 'Invalid request'; - }); - sinon.stub(request, 'delete').callsFake(async (opts) => { memberDeleteCallIssued = true; - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/owners/00000000-0000-0000-0000-000000000001/$ref`) { - return { - "value": [{ "id": "00000000-0000-0000-0000-000000000000" }] - }; + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { + return; } - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/members/00000000-0000-0000-0000-000000000001/$ref`) { - return { - "value": [{ "id": "00000000-0000-0000-0000-000000000000" }] - }; + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/members/${userId}/$ref`) { + return; } throw 'Invalid request'; @@ -215,87 +213,39 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com" } }); + await command.action(logger, { options: { groupId: groupOrTeamId, userName: userName } }); assert(memberDeleteCallIssued); }); it('removes the specified owner from owners and members endpoint of the specified Microsoft 365 Group when prompt confirmed', async () => { let memberDeleteCallIssued = false; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews%40contoso.onmicrosoft.com/id`) { - return { - "value": "00000000-0000-0000-0000-000000000001" - }; - } - - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/id`) { - return { - response: { - status: 200, - data: { - value: "00000000-0000-0000-0000-000000000000" - } - } - }; - } - - throw 'Invalid request'; - }); - - sinon.stub(request, 'delete').callsFake(async (opts) => { memberDeleteCallIssued = true; - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/owners/00000000-0000-0000-0000-000000000001/$ref`) { - return { - "value": [{ "id": "00000000-0000-0000-0000-000000000000" }] - }; + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { + return; } - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/members/00000000-0000-0000-0000-000000000001/$ref`) { - return { - "value": [{ "id": "00000000-0000-0000-0000-000000000000" }] - }; + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/members/${userId}/$ref`) { + return; } throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com", force: true } }); + await command.action(logger, { options: { groupId: groupOrTeamId, userName: userName, force: true } }); assert(memberDeleteCallIssued); }); it('removes the specified member from members endpoint of the specified Microsoft 365 Group when prompt confirmed', async () => { let memberDeleteCallIssued = false; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews%40contoso.onmicrosoft.com/id`) { - return { - "value": "00000000-0000-0000-0000-000000000001" - }; - } - - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/id`) { - return { - response: { - status: 200, - data: { - value: "00000000-0000-0000-0000-000000000000" - } - } - }; - } - - throw 'Invalid request'; - }); - - sinon.stub(request, 'delete').callsFake(async (opts) => { memberDeleteCallIssued = true; - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/owners/00000000-0000-0000-0000-000000000001/$ref`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { throw { "response": { "status": 404 @@ -303,37 +253,30 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }; } - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/members/00000000-0000-0000-0000-000000000001/$ref`) { - return { - "value": [{ "id": "00000000-0000-0000-0000-000000000000" }] - }; + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/members/${userId}/$ref`) { + return; } throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com", force: true } }); + await command.action(logger, { options: { groupId: groupOrTeamId, userName: userName, force: true } }); assert(memberDeleteCallIssued); }); - it('removes the specified owners from owners endpoint of the specified Microsoft 365 Group when prompt confirmed', async () => { - let memberDeleteCallIssued = false; + it('removes the specified members of the specified Microsoft 365 Group specified by teamName', async () => { + sinon.stub(entraGroup, 'getGroupIdByDisplayName').withArgs(groupOrTeamName).resolves(groupOrTeamId); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews%40contoso.onmicrosoft.com/id`) { - return { - "value": "00000000-0000-0000-0000-000000000001" - }; + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { + return; } - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/id`) { - return { - response: { - status: 200, - data: { - value: "00000000-0000-0000-0000-000000000000" - } + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/members/${userId}/$ref`) { + throw { + "response": { + "status": 404 } }; } @@ -341,17 +284,22 @@ describe(commands.M365GROUP_USER_REMOVE, () => { throw 'Invalid request'; }); + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); - sinon.stub(request, 'delete').callsFake(async (opts) => { - memberDeleteCallIssued = true; + await command.action(logger, { options: { teamName: groupOrTeamName, userName: userName, verbose: true } }); + assert(deleteStub.calledTwice); + }); - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/owners/00000000-0000-0000-0000-000000000001/$ref`) { - return { - "value": [{ "id": "00000000-0000-0000-0000-000000000000" }] - }; + it('removes the specified members specified by ids of the specified Microsoft 365 Team specified by teamId', async () => { + sinon.stub(entraGroup, 'getGroupIdByDisplayName').withArgs(groupOrTeamName).resolves(groupOrTeamId); + + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { + return; } - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/members/00000000-0000-0000-0000-000000000001/$ref`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/members/${userId}/$ref`) { throw { "response": { "status": 404 @@ -363,39 +311,44 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }); - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com", force: true } }); - assert(memberDeleteCallIssued); + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: { teamId: groupOrTeamId, ids: userId, verbose: true } }); + assert(deleteStub.calledTwice); }); - it('does not fail if the user is not owner or member of the specified Microsoft 365 Group when prompt confirmed', async () => { - let memberDeleteCallIssued = false; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews%40contoso.onmicrosoft.com/id`) { - return { - "value": "00000000-0000-0000-0000-000000000001" - }; + it('removes the specified members of the specified Microsoft 365 Group specified by groupName', async () => { + sinon.stub(entraGroup, 'getGroupIdByDisplayName').withArgs(groupOrTeamName).resolves(groupOrTeamId); + + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { + return; } - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/id`) { - return { - response: { - status: 200, - data: { - value: "00000000-0000-0000-0000-000000000000" - } - } - }; + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/members/${userId}/$ref`) { + return; } throw 'Invalid request'; + }); + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: { teamName: groupOrTeamName, userNames: userName } }); + assert(deleteStub.calledTwice); + }); + + it('does not fail if the user is not owner or member of the specified Microsoft 365 Group when prompt confirmed', async () => { + let memberDeleteCallIssued = false; sinon.stub(request, 'delete').callsFake(async (opts) => { memberDeleteCallIssued = true; - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/owners/00000000-0000-0000-0000-000000000001/$ref`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { return { "response": { "status": 404 @@ -403,7 +356,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }; } - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/members/00000000-0000-0000-0000-000000000001/$ref`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/members/${userId}/$ref`) { throw { "response": { "status": 404 @@ -416,40 +369,18 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }); - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com", force: true } }); + await command.action(logger, { options: { groupId: groupOrTeamId, userName: userName, force: true } }); assert(memberDeleteCallIssued); }); it('stops removal if an unknown error message is thrown when deleting the owner', async () => { let memberDeleteCallIssued = false; - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews%40contoso.onmicrosoft.com/id`) { - return { - "value": "00000000-0000-0000-0000-000000000001" - }; - } - - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/id`) { - return { - response: { - status: 200, - data: { - value: "00000000-0000-0000-0000-000000000000" - } - } - }; - } - - throw 'Invalid request'; - }); - - sinon.stub(request, 'delete').callsFake(async (opts) => { memberDeleteCallIssued = true; // for example... you must have at least one owner - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/owners/00000000-0000-0000-0000-000000000001/$ref`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { return { "response": { "status": 400 @@ -457,7 +388,7 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }; } - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/members/00000000-0000-0000-0000-000000000001/$ref`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/members/${userId}/$ref`) { throw { "response": { "status": 404 @@ -469,55 +400,38 @@ describe(commands.M365GROUP_USER_REMOVE, () => { }); - await command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com", force: true } }); + await command.action(logger, { options: { groupId: groupOrTeamId, userName: userName, force: true } }); assert(memberDeleteCallIssued); }); it('correctly retrieves user but does not find the Group Microsoft 365 group', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews%40contoso.onmicrosoft.com/id`) { - return { - "value": "00000000-0000-0000-0000-000000000001" - }; - } - - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/id`) { - throw "Invalid object identifier"; - } - - throw 'Invalid request'; - }); + const errorMessage = `Resource '${groupOrTeamId}' does not exist or one of its queried reference-property objects are not present.`; sinonUtil.restore(cli.promptForConfirmation); - sinon.stub(cli, 'promptForConfirmation').resolves(true); - await assert.rejects(command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com" } } as any), - new CommandError('Invalid object identifier')); - }); - - it('correctly retrieves user and handle error removing owner from specified Microsoft 365 group', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews%40contoso.onmicrosoft.com/id`) { - return { - "value": "00000000-0000-0000-0000-000000000001" - }; - } + sinonUtil.restore(entraGroup.isUnifiedGroup); - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/id`) { - return { - response: { - status: 200, - data: { - value: "00000000-0000-0000-0000-000000000000" - } + sinon.stub(entraGroup, 'isUnifiedGroup').rejects( + { + error: { + code: 'Request_ResourceNotFound', + message: errorMessage, + innerError: { + date: '2024-09-16T22:06:30', + 'request-id': 'c43610b0-70c0-4c00-8c40-ff26b5f37f00', + 'client-request-id': 'c43610b0-70c0-4c00-8c40-ff26b5f37f00' } - }; + } } + ); + sinon.stub(cli, 'promptForConfirmation').resolves(true); - throw 'Invalid request'; - }); + await assert.rejects(command.action(logger, { options: { groupId: groupOrTeamId, userName: userName } } as any), + new CommandError(errorMessage)); + }); + it('correctly retrieves user and handle error removing owner from specified Microsoft 365 group', async () => { sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/owners/00000000-0000-0000-0000-000000000001/$ref`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { throw { response: { status: 400, @@ -534,44 +448,23 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await assert.rejects(command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com" } } as any), + await assert.rejects(command.action(logger, { options: { groupId: groupOrTeamId, userName: userName } } as any), new CommandError('Invalid object identifier')); }); it('correctly retrieves user and handle error removing member from specified Microsoft 365 group', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews%40contoso.onmicrosoft.com/id`) { - return { - "value": "00000000-0000-0000-0000-000000000001" - }; - } - - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/id`) { - return { - response: { - status: 200, - data: { - value: "00000000-0000-0000-0000-000000000000" - } - } - }; - } - - throw 'Invalid request'; - }); - sinon.stub(request, 'delete').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/owners/00000000-0000-0000-0000-000000000001/$ref`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/owners/${userId}/$ref`) { return { - "err": { - "response": { - "status": 404 + err: { + response: { + status: 404 } } }; } - if (opts.url === `https://graph.microsoft.com/v1.0/groups/00000000-0000-0000-0000-000000000000/members/00000000-0000-0000-0000-000000000001/$ref`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupOrTeamId}/members/${userId}/$ref`) { throw { response: { status: 400, @@ -588,34 +481,15 @@ describe(commands.M365GROUP_USER_REMOVE, () => { sinonUtil.restore(cli.promptForConfirmation); sinon.stub(cli, 'promptForConfirmation').resolves(true); - await assert.rejects(command.action(logger, { options: { groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com" } } as any), + await assert.rejects(command.action(logger, { options: { groupId: groupOrTeamId, userName: userName } } as any), new CommandError('Invalid object identifier')); }); - it('correctly skips execution when specified user is not found', async () => { - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/anne.matthews.not.found%40contoso.onmicrosoft.com/id`) { - throw "Resource 'anne.matthews.not.found%40contoso.onmicrosoft.com' does not exist or one of its queried reference-property objects are not present."; - } - - throw 'Invalid request'; - }); - - sinon.stub(request, 'delete').resolves(); - - sinonUtil.restore(cli.promptForConfirmation); - sinon.stub(cli, 'promptForConfirmation').resolves(true); - - await assert.rejects(command.action(logger, { options: { debug: true, groupId: "00000000-0000-0000-0000-000000000000", userName: "anne.matthews@contoso.onmicrosoft.com" } } as any), new CommandError("Invalid request")); - }); - it('throws error when the group is not a unified group', async () => { - const groupId = '3f04e370-cbc6-4091-80fe-1d038be2ad06'; - sinonUtil.restore(entraGroup.isUnifiedGroup); sinon.stub(entraGroup, 'isUnifiedGroup').resolves(false); - await assert.rejects(command.action(logger, { options: { groupId: groupId, userName: 'anne.matthews@contoso.onmicrosoft.com', force: true } } as any), - new CommandError(`Specified group with id '${groupId}' is not a Microsoft 365 group.`)); + await assert.rejects(command.action(logger, { options: { groupId: groupOrTeamId, userName: 'anne.matthews@contoso.onmicrosoft.com', force: true } } as any), + new CommandError(`Specified group with id '${groupOrTeamId}' is not a Microsoft 365 group.`)); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-user-remove.ts b/src/m365/entra/commands/m365group/m365group-user-remove.ts index 71d14fd1852..380cc76c50e 100644 --- a/src/m365/entra/commands/m365group/m365group-user-remove.ts +++ b/src/m365/entra/commands/m365group/m365group-user-remove.ts @@ -3,6 +3,7 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request from '../../../../request.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; +import { entraUser } from '../../../../utils/entraUser.js'; import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; @@ -12,14 +13,14 @@ interface CommandArgs { options: Options; } -interface UserResponse { - value: string -} - interface Options extends GlobalOptions { teamId?: string; + teamName?: string; groupId?: string; - userName: string; + groupName?: string; + userName?: string; + ids?: string; + userNames?: string; force?: boolean; } @@ -39,14 +40,20 @@ class EntraM365GroupUserRemoveCommand extends GraphCommand { this.#initOptions(); this.#initValidators(); this.#initOptionSets(); + this.#initTypes(); } #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { Object.assign(this.telemetryProperties, { - force: (!(!args.options.force)).toString(), + force: !!args.options.force, teamId: typeof args.options.teamId !== 'undefined', - groupId: typeof args.options.groupId !== 'undefined' + groupId: typeof args.options.groupId !== 'undefined', + teamName: typeof args.options.teamName !== 'undefined', + groupName: typeof args.options.groupName !== 'undefined', + userName: typeof args.options.userName !== 'undefined', + ids: typeof args.options.ids !== 'undefined', + userNames: typeof args.options.userNames !== 'undefined' }); }); } @@ -54,13 +61,25 @@ class EntraM365GroupUserRemoveCommand extends GraphCommand { #initOptions(): void { this.options.unshift( { - option: "-i, --groupId [groupId]" + option: '-i, --groupId [groupId]' + }, + { + option: '--groupName [groupName]' + }, + { + option: '--teamId [teamId]' + }, + { + option: '--teamName [teamName]' }, { - option: "--teamId [teamId]" + option: '-n, --userName [userName]' }, { - option: '-n, --userName ' + option: '--ids [ids]' + }, + { + option: '--userNames [userNames]' }, { option: '-f, --force' @@ -72,11 +91,29 @@ class EntraM365GroupUserRemoveCommand extends GraphCommand { this.validators.push( async (args: CommandArgs) => { if (args.options.teamId && !validation.isValidGuid(args.options.teamId as string)) { - return `${args.options.teamId} is not a valid GUID`; + return `${args.options.teamId} is not a valid GUID for option 'teamId'.`; } if (args.options.groupId && !validation.isValidGuid(args.options.groupId as string)) { - return `${args.options.groupId} is not a valid GUID`; + return `${args.options.groupId} is not a valid GUID for option 'groupId'.`; + } + + if (args.options.ids) { + const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.ids); + if (isValidGUIDArrayResult !== true) { + return `The following GUIDs are invalid for the option 'ids': ${isValidGUIDArrayResult}.`; + } + } + + if (args.options.userNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.userNames); + if (isValidUPNArrayResult !== true) { + return `The following user principal names are invalid for the option 'userNames': ${isValidUPNArrayResult}.`; + } + } + + if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { + return `The specified userName '${args.options.userName}' is not a valid user principal name.`; } return true; @@ -85,68 +122,40 @@ class EntraM365GroupUserRemoveCommand extends GraphCommand { } #initOptionSets(): void { - this.optionSets.push({ options: ['groupId', 'teamId'] }); + this.optionSets.push( + { + options: ['groupId', 'teamId', 'groupName', 'teamName'] + }, + { + options: ['userName', 'ids', 'userNames'] + } + ); + } + + #initTypes(): void { + this.types.string.push('groupId', 'groupName', 'teamId', 'teamName', 'userName', 'ids', 'userNames'); + this.types.boolean.push('force'); } public async commandAction(logger: Logger, args: CommandArgs): Promise { - const groupId: string = (typeof args.options.groupId !== 'undefined') ? args.options.groupId : args.options.teamId as string; + if (args.options.userName) { + await this.warn(logger, `Option 'userName' is deprecated. Please use 'ids' or 'userNames' instead.`); + } const removeUser = async (): Promise => { try { + const groupId: string = await this.getGroupId(logger, args.options.groupId, args.options.teamId, args.options.groupName, args.options.teamName); const isUnifiedGroup = await entraGroup.isUnifiedGroup(groupId); if (!isUnifiedGroup) { throw Error(`Specified group with id '${groupId}' is not a Microsoft 365 group.`); } - // retrieve user - const user: UserResponse = await request.get({ - url: `${this.resource}/v1.0/users/${formatting.encodeQueryParameter(args.options.userName)}/id`, - headers: { - accept: 'application/json;odata.metadata=none' - }, - responseType: 'json' - }); - - // used to verify if the group exists or not - await request.get({ - url: `${this.resource}/v1.0/groups/${groupId}/id`, - headers: { - 'accept': 'application/json;odata.metadata=none' - } - }); - - try { - // try to delete the user from the owners. Accepted error is 404 - await request.delete({ - url: `${this.resource}/v1.0/groups/${groupId}/owners/${user.value}/$ref`, - headers: { - 'accept': 'application/json;odata.metadata=none' - } - }); - } - catch (err: any) { - // the 404 error is accepted - if (err.response.status !== 404) { - throw err.response.data; - } - } + const userNames = args.options.userNames || args.options.userName; + const userIds: string[] = await this.getUserIds(logger, args.options.ids, userNames); - // try to delete the user from the members. Accepted error is 404 - try { - await request.delete({ - url: `${this.resource}/v1.0/groups/${groupId}/members/${user.value}/$ref`, - headers: { - 'accept': 'application/json;odata.metadata=none' - } - }); - } - catch (err: any) { - // the 404 error is accepted - if (err.response.status !== 404) { - throw err.response.data; - } - } + await this.removeUsersFromGroup(groupId, userIds, 'owners'); + await this.removeUsersFromGroup(groupId, userIds, 'members'); } catch (err: any) { this.handleRejectedODataJsonPromise(err); @@ -157,13 +166,58 @@ class EntraM365GroupUserRemoveCommand extends GraphCommand { await removeUser(); } else { - const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove ${args.options.userName} from the ${(typeof args.options.groupId !== 'undefined' ? 'group' : 'team')} ${groupId}?` }); + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove ${args.options.userName || args.options.userNames || args.options.ids} from ${args.options.groupId || args.options.groupName || args.options.teamId || args.options.teamName}?` }); if (result) { await removeUser(); } } } + + private async getGroupId(logger: Logger, groupId?: string, teamId?: string, groupName?: string, teamName?: string): Promise { + const id = groupId || teamId; + if (id) { + return id; + } + + const name = groupName ?? teamName; + if (this.verbose) { + await logger.logToStderr(`Retrieving Group ID by display name ${name}...`); + } + + return entraGroup.getGroupIdByDisplayName(name!); + } + + private async getUserIds(logger: Logger, userIds?: string, userNames?: string): Promise { + if (userIds) { + return formatting.splitAndTrim(userIds); + } + + if (this.verbose) { + await logger.logToStderr(`Retrieving user IDs for {userNames}...`); + } + + return entraUser.getUserIdsByUpns(formatting.splitAndTrim(userNames!)); + } + + private async removeUsersFromGroup(groupId: string, userIds: string[], role: string): Promise { + for (const userId of userIds) { + try { + await request.delete({ + url: `${this.resource}/v1.0/groups/${groupId}/${role}/${userId}/$ref`, + headers: { + 'accept': 'application/json;odata.metadata=none' + } + }); + } + catch (err: any) { + // the 404 error is accepted + if (err.response.status !== 404) { + throw err.response.data; + } + } + } + } } export default new EntraM365GroupUserRemoveCommand(); \ No newline at end of file