From aea90656b5b09b103a1fa2824e76fd62435b06df Mon Sep 17 00:00:00 2001 From: Nanddeep Nachan Date: Tue, 24 Sep 2024 17:48:16 +0000 Subject: [PATCH] Updates handling group members in 'entra m365group set'. Closes #6061 --- .../cmd/entra/m365group/m365group-set.mdx | 48 ++- .../commands/m365group/m365group-set.spec.ts | 316 +++++++++++++----- .../entra/commands/m365group/m365group-set.ts | 241 ++++++++----- 3 files changed, 429 insertions(+), 176 deletions(-) diff --git a/docs/docs/cmd/entra/m365group/m365group-set.mdx b/docs/docs/cmd/entra/m365group/m365group-set.mdx index ae58af1f63..d3e3db2cd9 100644 --- a/docs/docs/cmd/entra/m365group/m365group-set.mdx +++ b/docs/docs/cmd/entra/m365group/m365group-set.mdx @@ -20,28 +20,34 @@ m365 aad m365group set [options] ```md definition-list `-i, --id [id]` -: The ID of the Microsoft 365 Group to update +: The ID of the Microsoft 365 Group to update. `-n, --displayName [displayName]` -: Display name of the Microsoft 365 Group to update +: Display name of the Microsoft 365 Group to update. `--newDisplayName [newDisplayName]` -: New display name for the Microsoft 365 Group +: New display name for the Microsoft 365 Group. `-d, --description [description]` -: Description for the Microsoft 365 Group +: Description for the Microsoft 365 Group. -`--owners [owners]` -: Comma-separated list of Microsoft 365 Group owners to add +`--ownerIds [ownerIds]` +: Comma-separated list of IDs of Microsoft Entra ID users that will be group owners. Specify either `ownerIds` or `ownerUserNames`, but not both. -`--members [members]` -: Comma-separated list of Microsoft 365 Group members to add +`--ownerUserNames [ownerUserNames]` +: Comma-separated list of UPNs of Microsoft Entra ID users that will be group owners. Specify either `ownerIds` or `ownerUserNames`, but not both. + +`--memberIds [memberIds]` +: Comma-separated list of IDs of Microsoft Entra ID users that will be group members. Specify either `memberIds` or `memberUserNames`, but not both. + +`--memberUserNames [memberUserNames]` +: Comma-separated list of UPNs of Microsoft Entra ID users that will be group members. Specify either `memberIds` or `memberUserNames`, but not both. `--isPrivate [isPrivate]` : Set to `true` if the Microsoft 365 Group should be private and `false` if it should be public. `-l, --logoPath [logoPath]` -: Local path to the image file to use as group logo +: Local path to the image file to use as group logo. `--allowExternalSenders [allowExternalSenders]` : Indicates if people external to the organization can send messages to the group. Valid values: `true`, `false`. @@ -60,7 +66,7 @@ m365 aad m365group set [options] ## Remarks -When updating group's owners and members, the command will add newly specified users to the previously set owners and members. The previously set users will not be replaced. +When updating group's owners and members, the command will remove existing owners/members from the group, and the specified users will be added. When specifying the path to the logo image you can use both relative and absolute paths. Note, that ~ in the path, will not be resolved and will most likely result in an error. @@ -84,16 +90,28 @@ Change Microsoft 365 Group visibility to public. m365 entra m365group set --id 28beab62-7540-4db1-a23f-29a6018a3848 --isPrivate `false` ``` -Add new Microsoft 365 Group owners of group. +Updates the list of Microsoft 365 Group owners with a list of users by UPN. + +```sh +m365 entra m365group set --id 28beab62-7540-4db1-a23f-29a6018a3848 --ownerUserNames "DebraB@contoso.onmicrosoft.com,DiegoS@contoso.onmicrosoft.com" +``` + +Updates the list of Microsoft 365 Group owners with a list of users by ID. + +```sh +m365 entra m365group set --id 28beab62-7540-4db1-a23f-29a6018a3848 --ownerIds "3527dada-9368-4cdd-a958-5460f5658e0e,e94b2cb8-7c9a-4651-b1af-207d81a010b6" +``` + +Updates the list of Microsoft 365 Group members with a list of users by UPN. ```sh -m365 entra m365group set --displayName 'Project Team' --owners "DebraB@contoso.onmicrosoft.com,DiegoS@contoso.onmicrosoft.com" +m365 entra m365group set --id 28beab62-7540-4db1-a23f-29a6018a3848 --memberUserNames "DebraB@contoso.onmicrosoft.com,DiegoS@contoso.onmicrosoft.com" ``` -Add new Microsoft 365 Group members. +Updates the list of Microsoft 365 Group members with a list of users by ID. ```sh -m365 entra m365group set --displayName 'Project Team' --members "DebraB@contoso.onmicrosoft.com,DiegoS@contoso.onmicrosoft.com" +m365 entra m365group set --id 28beab62-7540-4db1-a23f-29a6018a3848 --memberIds "3527dada-9368-4cdd-a958-5460f5658e0e,e94b2cb8-7c9a-4651-b1af-207d81a010b6" ``` Update Microsoft 365 Group logo. @@ -116,4 +134,4 @@ m365 entra m365group set --id 28beab62-7540-4db1-a23f-29a6018a3848 --autoSubscri ## Response -The command won't return a response on success. +The command won't return a response on success. \ No newline at end of file diff --git a/src/m365/entra/commands/m365group/m365group-set.spec.ts b/src/m365/entra/commands/m365group/m365group-set.spec.ts index e2a3a23501..f41d0376b5 100644 --- a/src/m365/entra/commands/m365group/m365group-set.spec.ts +++ b/src/m365/entra/commands/m365group/m365group-set.spec.ts @@ -47,6 +47,9 @@ describe(commands.M365GROUP_SET, () => { birthtime: new Date() }; + const userUpns = ['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', 'user11@contoso.com', 'user12@contoso.com', 'user13@contoso.com', 'user14@contoso.com', 'user15@contoso.com', 'user16@contoso.com', 'user17@contoso.com', 'user18@contoso.com', 'user19@contoso.com', 'user20@contoso.com', 'user21@contoso.com', 'user22@contoso.com', 'user23@contoso.com', 'user24@contoso.com', 'user25@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', '0484f1f0-4f8b-11d3-9a0c-0305e82c330b', '31e2f100-4f8b-11d3-9a0c-0305e82c330c', '5f40f010-4f8b-11d3-9a0c-0305e82c330d', '8c9eef20-4f8b-11d3-9a0c-0305e82c330e', 'b9fce030-4f8b-11d3-9a0c-0305e82c330f', 'e73cdf40-4f8b-11d3-9a0c-0305e82c3310', '1470ce50-4f8c-11d3-9a0c-0305e82c3311', '41a3cd60-4f8c-11d3-9a0c-0305e82c3312', '6ed6cc70-4f8c-11d3-9a0c-0305e82c3313', '9c09cb80-4f8c-11d3-9a0c-0305e82c3314', 'c93cca90-4f8c-11d3-9a0c-0305e82c3315', 'f66cc9a0-4f8c-11d3-9a0c-0305e82c3316', '2368c8b0-4f8d-11d3-9a0c-0305e82c3317', '5064c7c0-4f8d-11d3-9a0c-0305e82c3318', '7d60c6d0-4f8d-11d3-9a0c-0305e82c3319']; + let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; @@ -311,17 +314,9 @@ describe(commands.M365GROUP_SET, () => { new CommandError('An error has occurred')); }); - it('adds owner to Microsoft 365 Group', async () => { - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/owners/$ref' && - opts.data['@odata.id'] === 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8a') { - return; - } - - throw 'Invalid request'; - }); + it('adds members to Microsoft 365 Group by IDs', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users?$filter=userPrincipalName eq 'user@contoso.onmicrosoft.com'&$select=id`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/members/microsoft.graph.user?$select=id`) { return { value: [ { @@ -334,34 +329,48 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', owners: 'user@contoso.onmicrosoft.com' } }); - assert(loggerLogSpy.notCalled); - }); - - it('adds owners to Microsoft 365 Group (debug)', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/owners/$ref' && - opts.data['@odata.id'] === 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8a') { - return; + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; } - if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/owners/$ref' && - opts.data['@odata.id'] === 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8b') { - return; + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; } throw 'Invalid request'; }); + + await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', memberIds: '949b16c1-a032-453e-a8ae-89a52bfc1d8a', verbose: true } }); + assert(loggerLogSpy.notCalled); + }); + + it('adds members to Microsoft 365 Group by UPNs', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users?$filter=userPrincipalName eq 'user1@contoso.onmicrosoft.com' or userPrincipalName eq 'user2@contoso.onmicrosoft.com'&$select=id`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76?$select=groupTypes`) { return { - value: [ - { - id: '949b16c1-a032-453e-a8ae-89a52bfc1d8a' - }, - { - id: '949b16c1-a032-453e-a8ae-89a52bfc1d8b' - } + groupTypes: [ + 'Unified' + ] + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/members/microsoft.graph.user?$select=id') { + return { + "value": [ + { "id": "949b16c1-a032-453e-a8ae-89a52bfc1d8a", "displayName": "Anne Matthews", "userPrincipalName": "anne.matthews@contoso.onmicrosoft.com", "givenName": "Anne", "surname": "Matthews", "userType": "Member" } ] }; } @@ -369,21 +378,50 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }); - assert(loggerLogToStderrSpy.called); - }); - - it('adds member to Microsoft 365 Group', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/members/$ref' && - opts.data['@odata.id'] === 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8a') { - return; + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'GET') { + return { + responses: [ + { + id: userIds[0], + status: 200, + body: 1 + } + ] + }; } throw 'Invalid request'; }); + + await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', memberUserNames: 'user@contoso.onmicrosoft.com', verbose: true } }); + assert(loggerLogSpy.notCalled); + }); + + it('adds owners to Microsoft 365 Group by IDs', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users?$filter=userPrincipalName eq 'user@contoso.onmicrosoft.com'&$select=id`) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/owners/microsoft.graph.user?$select=id`) { return { value: [ { @@ -396,30 +434,84 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', members: 'user@contoso.onmicrosoft.com' } }); + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', ownerIds: '3527dada-9368-4cdd-a958-5460f5658e0e', verbose: true } }); assert(loggerLogSpy.notCalled); }); - it('adds members to Microsoft 365 Group (debug)', async () => { - sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/members/$ref' && - opts.data['@odata.id'] === 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8a') { - return; + it('adds owners to Microsoft 365 Group by UPNs', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76?$select=groupTypes`) { + return { + groupTypes: [ + 'Unified' + ] + }; } - if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/members/$ref' && - opts.data['@odata.id'] === 'https://graph.microsoft.com/v1.0/users/949b16c1-a032-453e-a8ae-89a52bfc1d8b') { - return; + if (opts.url === 'https://graph.microsoft.com/v1.0/groups/f3db5c2b-068f-480d-985b-ec78b9fa0e76/owners/microsoft.graph.user?$select=id') { + return { + "value": [ + { "id": "949b16c1-a032-453e-a8ae-89a52bfc1d8a", "displayName": "Anne Matthews", "userPrincipalName": "anne.matthews@contoso.onmicrosoft.com", "givenName": "Anne", "surname": "Matthews", "userType": "Member" } + ] + }; } throw 'Invalid request'; }); - sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users?$filter=userPrincipalName eq 'user1@contoso.onmicrosoft.com' or userPrincipalName eq 'user2@contoso.onmicrosoft.com'&$select=id`) { + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { return { - value: [ + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'GET') { + return { + responses: [ { - id: '949b16c1-a032-453e-a8ae-89a52bfc1d8a' + id: userIds[0], + status: 200, + body: 1 } ] }; @@ -428,8 +520,8 @@ describe(commands.M365GROUP_SET, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { debug: true, id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }); - assert(loggerLogToStderrSpy.called); + await command.action(logger, { options: { id: 'f3db5c2b-068f-480d-985b-ec78b9fa0e76', ownerUserNames: 'user@contoso.onmicrosoft.com', verbose: true } }); + assert(loggerLogSpy.notCalled); }); it('sets option allowExternalSenders when using delegated permissions', async () => { @@ -484,6 +576,71 @@ describe(commands.M365GROUP_SET, () => { assert.deepStrictEqual(JSON.parse(JSON.stringify(patchStub.firstCall.args[0].data)), { hideFromOutlookClients: true }); }); + it('handles API error when adding users to a group', async () => { + sinon.stub(request, 'get').resolves({ value: [] }); + sinon.stub(request, 'patch').resolves(); + sinon.stub(request, 'post').callsFake(async () => { + return { + responses: [ + { + id: 1, + status: 204, + body: {} + }, + { + id: 2, + status: 400, + body: { + error: { + message: `One or more added object references already exist for the following modified properties: 'members'.` + } + } + } + ] + }; + }); + + await assert.rejects(command.action(logger, { options: { id: groupId, ownerIds: userIds.join(',') } }), + new CommandError(`One or more added object references already exist for the following modified properties: 'members'.`)); + }); + + it('handles API error when removing users from a group', async () => { + sinon.stub(request, 'get').resolves({ value: [{ id: '717f1683-00fa-488c-b68d-5d0051f6bcfa' }] }); + sinon.stub(request, 'patch').resolves(); + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: [ + { + status: 500, + body: { + error: { + message: 'Service unavailable.' + } + } + } + ] + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { id: groupId, ownerIds: userIds.join(',') } }), + new CommandError('Service unavailable.')); + }); + it('correctly handles API OData error', async () => { sinon.stub(request, 'patch').rejects({ error: { @@ -516,7 +673,6 @@ describe(commands.M365GROUP_SET, () => { new CommandError(`Option 'allowExternalSenders' and 'autoSubscribeNewMembers' can only be used when using delegated permissions.`)); }); - it('throws error when we are trying to update autoSubscribeNewMembers and we are using application only permissions', async () => { sinonUtil.restore(accessToken.isAppOnlyAccessToken); sinon.stub(accessToken, 'isAppOnlyAccessToken').resolves(true); @@ -545,53 +701,47 @@ describe(commands.M365GROUP_SET, () => { assert.notStrictEqual(actual, true); }); - it('fails validation if one of the owners is invalid', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', owners: 'user' } }, commandInfo); + it('fails validation if ownerIds contains invalid GUID', async () => { + const ownerIds = ['7167b488-1ffb-43f1-9547-35969469bada', 'foo']; + const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerIds: ownerIds.join(',') } }, commandInfo); assert.notStrictEqual(actual, true); }); - it('passes validation if the owner is valid', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', owners: 'user@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); - }); - - it('passes validation with multiple owners, comma-separated', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', owners: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); - }); - - it('passes validation with multiple owners, comma-separated with an additional space', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', owners: 'user1@contoso.onmicrosoft.com, user2@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if ownerUserNames contains invalid user principal name', async () => { + const ownerUserNames = ['john.doe@contoso.com', 'foo']; + const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerUserNames: ownerUserNames.join(',') } }, commandInfo); + assert.notStrictEqual(actual, true); }); - it('fails validation if one of the members is invalid', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', members: 'user' } }, commandInfo); + it('fails validation if memberIds contains invalid GUID', async () => { + const memberIds = ['7167b488-1ffb-43f1-9547-35969469bada', 'foo']; + const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', memberIds: memberIds.join(',') } }, commandInfo); assert.notStrictEqual(actual, true); }); - it('passes validation if the member is valid', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', members: 'user@contoso.onmicrosoft.com' } }, commandInfo); - assert.strictEqual(actual, true); + it('fails validation if memberUserNames contains invalid user principal name', async () => { + const memberUserNames = ['john.doe@contoso.com', 'foo']; + const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', memberUserNames: memberUserNames.join(',') } }, commandInfo); + assert.notStrictEqual(actual, true); }); - it('passes validation with multiple members, comma-separated', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', members: 'user1@contoso.onmicrosoft.com,user2@contoso.onmicrosoft.com' } }, commandInfo); + it('passes validation if isPrivate is true', async () => { + const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: true } }, commandInfo); assert.strictEqual(actual, true); }); - it('passes validation with multiple members, comma-separated with an additional space', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', members: 'user1@contoso.onmicrosoft.com, user2@contoso.onmicrosoft.com' } }, commandInfo); + it('passes validation if isPrivate is false', async () => { + const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: false } }, commandInfo); assert.strictEqual(actual, true); }); - it('passes validation if isPrivate is true', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: true } }, commandInfo); + it('passes validation when all required parameters are valid with ids', async () => { + const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', ownerIds: userIds.join(',') } }, commandInfo); assert.strictEqual(actual, true); }); - it('passes validation if isPrivate is false', async () => { - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', isPrivate: false } }, commandInfo); + it('passes validation when all required parameters are valid with user names', async () => { + const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', memberUserNames: userUpns.join(',') } }, commandInfo); assert.strictEqual(actual, true); }); @@ -621,7 +771,7 @@ describe(commands.M365GROUP_SET, () => { sinon.stub(stats, 'isDirectory').returns(false); sinon.stub(fs, 'existsSync').returns(true); sinon.stub(fs, 'lstatSync').returns(stats); - const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', newDisplayName: 'Title', description: 'Description', logoPath: 'logo.png', owners: 'john@contoso.com', members: 'doe@contoso.com', isPrivate: false, allowExternalSenders: false, autoSubscribeNewMembers: false, hideFromAddressLists: false, hideFromOutlookClients: false } }, commandInfo); + const actual = await command.validate({ options: { id: '28beab62-7540-4db1-a23f-29a6018a3848', newDisplayName: 'Title', description: 'Description', logoPath: 'logo.png', ownerIds: userIds.join(','), memberIds: userIds.join(','), isPrivate: false, allowExternalSenders: false, autoSubscribeNewMembers: false, hideFromAddressLists: false, hideFromOutlookClients: false } }, commandInfo); assert.strictEqual(actual, true); }); }); diff --git a/src/m365/entra/commands/m365group/m365group-set.ts b/src/m365/entra/commands/m365group/m365group-set.ts index aec57984f6..435a8dd573 100644 --- a/src/m365/entra/commands/m365group/m365group-set.ts +++ b/src/m365/entra/commands/m365group/m365group-set.ts @@ -12,6 +12,10 @@ import { entraGroup } from '../../../../utils/entraGroup.js'; import aadCommands from '../../aadCommands.js'; import { accessToken } from '../../../../utils/accessToken.js'; import auth from '../../../../Auth.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { odata } from '../../../../utils/odata.js'; +import { User } from '@microsoft/microsoft-graph-types'; interface CommandArgs { options: Options; @@ -22,8 +26,10 @@ export interface Options extends GlobalOptions { displayName?: string; newDisplayName?: string; description?: string; - owners?: string; - members?: string; + ownerIds?: string; + ownerUserNames?: string; + memberIds?: string; + memberUserNames?: string; isPrivate?: boolean; logoPath?: string; allowExternalSenders?: boolean; @@ -65,8 +71,10 @@ class EntraM365GroupSetCommand extends GraphCommand { displayName: typeof args.options.displayName !== 'undefined', newDisplayName: typeof args.options.newDisplayName !== 'undefined', description: typeof args.options.description !== 'undefined', - owners: typeof args.options.owners !== 'undefined', - members: typeof args.options.members !== 'undefined', + ownerIds: typeof args.options.ownerIds !== 'undefined', + ownerUserNames: typeof args.options.ownerUserNames !== 'undefined', + memberIds: typeof args.options.memberIds !== 'undefined', + memberUserNames: typeof args.options.memberUserNames !== 'undefined', isPrivate: !!args.options.isPrivate, logoPath: typeof args.options.logoPath !== 'undefined', allowExternalSenders: !!args.options.allowExternalSenders, @@ -92,10 +100,16 @@ class EntraM365GroupSetCommand extends GraphCommand { option: '-d, --description [description]' }, { - option: '--owners [owners]' + option: '--ownerIds [ownerIds]' }, { - option: '--members [members]' + option: '--ownerUserNames [ownerUserNames]' + }, + { + option: '--memberIds [memberIds]' + }, + { + option: '--memberUserNames [memberUserNames]' }, { option: '--isPrivate [isPrivate]', @@ -125,11 +139,23 @@ class EntraM365GroupSetCommand extends GraphCommand { #initOptionSets(): void { this.optionSets.push({ options: ['id', 'displayName'] }); + this.optionSets.push({ + options: ['ownerIds', 'ownerUserNames'], + runsWhen: (args) => { + return args.options.ownerIds !== undefined || args.options.ownerUserNames !== undefined; + } + }); + this.optionSets.push({ + options: ['memberIds', 'memberUserNames'], + runsWhen: (args) => { + return args.options.memberIds !== undefined || args.options.memberUserNames !== undefined; + } + }); } #initTypes(): void { this.types.boolean.push('isPrivate', 'allowEternalSenders', 'autoSubscribeNewMembers', 'hideFromAddressLists', 'hideFromOutlookClients'); - this.types.string.push('id', 'displayName', 'newDisplayName', 'description', 'owners', 'members', 'logoPath'); + this.types.string.push('id', 'displayName', 'newDisplayName', 'description', 'ownerIds', 'ownerUserNames', 'memberIds', 'memberUserNames', 'logoPath'); } #initValidators(): void { @@ -137,10 +163,12 @@ class EntraM365GroupSetCommand extends GraphCommand { async (args: CommandArgs) => { if (!args.options.newDisplayName && args.options.description === undefined && - !args.options.members && - !args.options.owners && + args.options.ownerIds === undefined && + args.options.ownerUserNames === undefined && + args.options.memberIds === undefined && + args.options.memberUserNames === undefined && args.options.isPrivate === undefined && - !args.options.logoPath && + args.options.logoPath === undefined && args.options.allowExternalSenders === undefined && args.options.autoSubscribeNewMembers === undefined && args.options.hideFromAddressLists === undefined && @@ -152,17 +180,31 @@ class EntraM365GroupSetCommand extends GraphCommand { return `${args.options.id} is not a valid GUID`; } - if (args.options.owners) { - const isValidArray = validation.isValidUserPrincipalNameArray(args.options.owners); - if (isValidArray !== true) { - return `Option 'owners' contains one or more invalid UPNs: ${isValidArray}.`; + if (args.options.ownerIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.ownerIds); + if (isValidGUIDArrayResult !== true) { + return `The following GUIDs are invalid for the option 'ownerIds': ${isValidGUIDArrayResult}.`; + } + } + + if (args.options.ownerUserNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.ownerUserNames); + if (isValidUPNArrayResult !== true) { + return `The following user principal names are invalid for the option 'ownerUserNames': ${isValidUPNArrayResult}.`; } } - if (args.options.members) { - const isValidArray = validation.isValidUserPrincipalNameArray(args.options.members); - if (isValidArray !== true) { - return `Option 'members' contains one or more invalid UPNs: ${isValidArray}.`; + if (args.options.memberIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.memberIds); + if (isValidGUIDArrayResult !== true) { + return `The following GUIDs are invalid for the option 'memberIds': ${isValidGUIDArrayResult}.`; + } + } + + if (args.options.memberUserNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.memberUserNames); + if (isValidUPNArrayResult !== true) { + return `The following user principal names are invalid for the option 'memberUserNames': ${isValidUPNArrayResult}.`; } } @@ -264,68 +306,15 @@ class EntraM365GroupSetCommand extends GraphCommand { await logger.logToStderr('logoPath not set. Skipping'); } - if (args.options.owners) { - const owners: string[] = args.options.owners.split(',').map(o => o.trim()); - - if (this.verbose) { - await logger.logToStderr('Retrieving user information to set group owners...'); - } + const ownerIds: string[] = await this.getUserIds(logger, args.options.ownerIds, args.options.ownerUserNames); + const memberIds: string[] = await this.getUserIds(logger, args.options.memberIds, args.options.memberUserNames); - const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/users?$filter=${owners.map(o => `userPrincipalName eq '${o}'`).join(' or ')}&$select=id`, - headers: { - 'content-type': 'application/json' - }, - responseType: 'json' - }; - - const res = await request.get<{ value: { id: string; }[] }>(requestOptions); - - await Promise.all(res.value.map(u => request.post({ - url: `${this.resource}/v1.0/groups/${groupId}/owners/$ref`, - headers: { - 'content-type': 'application/json' - }, - responseType: 'json', - data: { - "@odata.id": `https://graph.microsoft.com/v1.0/users/${u.id}` - } - }))); - } - else if (this.debug) { - await logger.logToStderr('Owners not set. Skipping'); + if (ownerIds.length > 0) { + await this.updateUsers(logger, groupId, 'owners', ownerIds); } - if (args.options.members) { - const members: string[] = args.options.members.split(',').map(o => o.trim()); - - if (this.verbose) { - await logger.logToStderr('Retrieving user information to set group members...'); - } - - const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/users?$filter=${members.map(o => `userPrincipalName eq '${o}'`).join(' or ')}&$select=id`, - headers: { - 'content-type': 'application/json' - }, - responseType: 'json' - }; - - const res = await request.get<{ value: { id: string; }[] }>(requestOptions); - - await Promise.all(res.value.map(u => request.post({ - url: `${this.resource}/v1.0/groups/${groupId}/members/$ref`, - headers: { - 'content-type': 'application/json' - }, - responseType: 'json', - data: { - "@odata.id": `https://graph.microsoft.com/v1.0/users/${u.id}` - } - }))); - } - else if (this.debug) { - await logger.logToStderr('Members not set. Skipping'); + if (memberIds.length > 0) { + await this.updateUsers(logger, groupId, 'members', memberIds); } } catch (err: any) { @@ -360,6 +349,102 @@ class EntraM365GroupSetCommand extends GraphCommand { return 'image/jpeg'; } } + + private async getUserIds(logger: Logger, userIds?: string, userNames?: string): Promise { + if (userIds) { + return formatting.splitAndTrim(userIds); + } + + if (userNames) { + if (this.verbose) { + await logger.logToStderr(`Retrieving user IDs...`); + } + + return entraUser.getUserIdsByUpns(formatting.splitAndTrim(userNames)); + } + + return []; + } + + private async updateUsers(logger: Logger, groupId: string, role: 'members' | 'owners', userIds: string[]): Promise { + const groupUsers = await odata.getAllItems(`${this.resource}/v1.0/groups/${groupId}/${role}/microsoft.graph.user?$select=id`); + const userIdsToAdd = userIds.filter(userId => !groupUsers.some(groupUser => groupUser.id === userId)); + const userIdsToRemove = groupUsers.filter(groupUser => !userIds.some(userId => groupUser.id === userId)).map(user => user.id); + + if (this.verbose) { + await logger.logToStderr(`Adding ${userIdsToAdd.length} ${role}...`); + } + + for (let i = 0; i < userIdsToAdd.length; i += 400) { + const userIdsBatch = userIdsToAdd.slice(i, i + 400); + const batchRequestOptions = this.getBatchRequestOptions(); + + // only 20 requests per one batch are allowed + for (let j = 0; j < userIdsBatch.length; j += 20) { + // only 20 users can be added in one request + const userIdsChunk = userIdsBatch.slice(j, j + 20); + batchRequestOptions.data.requests.push({ + id: j + 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { + 'content-type': 'application/json;odata.metadata=none', + accept: 'application/json;odata.metadata=none' + }, + body: { + [`${role}@odata.bind`]: userIdsChunk.map(u => `${this.resource}/v1.0/directoryObjects/${u}`) + } + }); + } + + const res = await request.post<{ responses: { status: number; body: any }[] }>(batchRequestOptions); + for (const response of res.responses) { + if (response.status !== 204) { + throw response.body; + } + } + } + + if (this.verbose) { + await logger.logToStderr(`Removing ${userIdsToRemove.length} ${role}...`); + } + + for (let i = 0; i < userIdsToRemove.length; i += 20) { + const userIdsBatch = userIdsToRemove.slice(i, i + 20); + const batchRequestOptions = this.getBatchRequestOptions(); + + userIdsBatch.map(userId => { + batchRequestOptions.data.requests.push({ + id: userId, + method: 'DELETE', + url: `/groups/${groupId}/${role}/${userId}/$ref` + }); + }); + + const res = await request.post<{ responses: { id: string, status: number; body: any }[] }>(batchRequestOptions); + for (const response of res.responses) { + if (response.status !== 204) { + throw response.body; + } + } + } + } + + private getBatchRequestOptions(): CliRequestOptions { + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/$batch`, + headers: { + 'content-type': 'application/json;odata.metadata=none', + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + requests: [] + } + }; + + return requestOptions; + } } -export default new EntraM365GroupSetCommand(); +export default new EntraM365GroupSetCommand(); \ No newline at end of file