diff --git a/docs/docs/cmd/entra/group/group-add.mdx b/docs/docs/cmd/entra/group/group-add.mdx new file mode 100644 index 00000000000..f31278946d7 --- /dev/null +++ b/docs/docs/cmd/entra/group/group-add.mdx @@ -0,0 +1,231 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# entra group add + +Creates a Microsoft Entra group + +## Usage + +```sh +m365 entra group add [options] +``` + +## Alias + +```sh +m365 aad group add [options] +``` + +## Options + +```md definition-list +`-n, --displayName ` +: The name for the group. The maximum length is 256 characters. + +`-d, --description [description]` +: The description for the group. + +`-t, --type ` +: The group type. Valid values: `microsoft365` or `security`. + +`-m, --mailNickname [mailNickname]` +: The mail alias for the group (part before the @). Maximum length is 64 characters. + +`--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. + +`--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. + +`--visibility [visibility]` +: Specifies the group join policy and group content visibility for Microsoft 365 groups. Possible values are: `Private`, `Public`, or `HiddenMembership`. Defaults to `Public`. Specify only when creating a group of type `microsoft365`. +``` + + + +## Remarks + +:::info + +The `visibility` option affects the behavior of the group. + +With the `Public` visibility: +- Anyone can join the group without needing owner permission. +- Anyone can view the attributes of the group. +- Anyone can see the members of the group. + +With the `Private` visibilty: +- Owner permission is needed to join the group. +- Anyone can view the attributes of the group. +- Anyone can see the members of the group. + +With the `HiddenMembership` visibility: +- Owner permission is needed to join the group. +- Guest users cannot view the attributes of the group. +- Non-members cannot see the members of the group. This setting doesn't affect visibility of group owners. +- Administrators (global, company, user, and helpdesk) can view the membership of the group. +- The group appears in the global address book (GAL). + +::: + +:::note + +The `HiddenMembership` visibility can be set only for Microsoft 365 groups when the groups are created. It can't be updated later. + +::: + +This command allows using unknown options. For a comprehensive list of group properties, please refer to the [Graph documentation page](https://learn.microsoft.com/graph/api/resources/group?view=graph-rest-1.0#properties). + +If the specified option is not found, you will receive a `Resource 'xyz' does not exist or one of its queried reference-property objects are not present.` error. + +## Examples + +Create a security group without any owners or members + +```sh +m365 entra group add --displayName Developers --type security +``` + +Create a private Microsoft 365 group with owners and members and a custom mail address + +```sh +m365 entra group add --displayName Developers --type microsoft365 --mailNickname devs --ownerUserNames john.doe@contoso.com --memberUserNames "john.doe@contoso.com,adele.vance@contoso.com" --visibility Private +``` + +Create a public Microsoft 365 group without any owners or members + +```sh +m365 entra group add --displayName Developers --type microsoft365 --description "This group is for all developers in the company." --visibility Public +``` + + +## Response + + + + + ```json + { + "id": "ae0e8388-cd70-427f-9503-c57498ee3337", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2024-01-10T11:33:18Z", + "creationOptions": [], + "description": "This group is for all developers in the company.", + "displayName": "Developers", + "expirationDateTime": null, + "groupTypes": [ + "Unified" + ], + "isAssignableToRole": null, + "mail": "devs@contoso.com", + "mailEnabled": true, + "mailNickname": "devs", + "membershipRule": null, + "membershipRuleProcessingState": null, + "onPremisesDomainName": null, + "onPremisesLastSyncDateTime": null, + "onPremisesNetBiosName": null, + "onPremisesSamAccountName": null, + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "preferredLanguage": null, + "proxyAddresses": [ + "SMTP:devs@contoso.com" + ], + "renewedDateTime": "2024-01-10T11:33:18Z", + "resourceBehaviorOptions": [], + "resourceProvisioningOptions": [], + "securityEnabled": true, + "securityIdentifier": "S-1-12-1-2920186760-1115671920-1959068565-926150296", + "theme": null, + "visibility": "Public", + "onPremisesProvisioningErrors": [], + "serviceProvisioningErrors": [] + } + ``` + + + + + ```text + classification : null + createdDateTime : 2024-01-10T11:36:31Z + creationOptions : [] + deletedDateTime : null + description : This group is for all developers in the company. + displayName : Developers + expirationDateTime : null + groupTypes : ["Unified"] + id : f7bce6b2-c017-4a00-ba0c-fbbe458364aa + isAssignableToRole : null + mail : dev@contoso.com + mailEnabled : true + mailNickname : devs + membershipRule : null + membershipRuleProcessingState: null + onPremisesDomainName : null + onPremisesLastSyncDateTime : null + onPremisesNetBiosName : null + onPremisesProvisioningErrors : [] + onPremisesSamAccountName : null + onPremisesSecurityIdentifier : null + onPremisesSyncEnabled : null + preferredDataLocation : null + preferredLanguage : null + proxyAddresses : ["SMTP:devs@contoso.com"] + renewedDateTime : 2024-01-10T11:36:31Z + resourceBehaviorOptions : [] + resourceProvisioningOptions : [] + securityEnabled : true + securityIdentifier : S-1-12-1-4156352178-1241563159-3204123834-2858713925 + serviceProvisioningErrors : [] + theme : null + visibility : Public + ``` + + + + + ```csv + id,deletedDateTime,classification,createdDateTime,description,displayName,expirationDateTime,isAssignableToRole,mail,mailEnabled,mailNickname,membershipRule,membershipRuleProcessingState,onPremisesDomainName,onPremisesLastSyncDateTime,onPremisesNetBiosName,onPremisesSamAccountName,onPremisesSecurityIdentifier,onPremisesSyncEnabled,preferredDataLocation,preferredLanguage,renewedDateTime,securityEnabled,securityIdentifier,theme,visibility + 4c63e8cd-eb2b-4b0d-9251-d709cc75cf5e,,,2024-01-10T11:37:02Z,This group is for all developers in the company.,Developers,,,devs@contoso.com,1,devs,,,,,,,,,,,2024-01-10T11:37:02Z,1,S-1-12-1-1281616077-1259203371-165106066-1590654412,,Public + ``` + + + + + ```md + # entra group add --displayName "Developers" --type "microsoft365" --description "This group is for all developers in the company." --visibility "Public" --mailNickname "devs" + + Date: 1/10/2024 + + ## Developers (b3fd8e8f-eced-45c7-a366-178bc5c1db37) + + Property | Value + ---------|------- + id | b3fd8e8f-eced-45c7-a366-178bc5c1db37 + createdDateTime | 2024-01-10T11:37:39Z + description | This group is for all developers in the company. + displayName | Developers + mail | devs@contoso.com + mailEnabled | true + mailNickname | devs + renewedDateTime | 2024-01-10T11:37:39Z + securityEnabled | true + securityIdentifier | S-1-12-1-3019738767-1170730221-2333566627-937148869 + visibility | Public + ``` + + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index bf9a82bdae1..469eea0cd66 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -281,6 +281,11 @@ const sidebars: SidebarsConfig = { }, { group: [ + { + type: 'doc', + label: 'group add', + id: 'cmd/entra/group/group-add' + }, { type: 'doc', label: 'group get', diff --git a/src/m365/entra/aadCommands.ts b/src/m365/entra/aadCommands.ts index 8667fe0e4d4..98ca9e6616a 100644 --- a/src/m365/entra/aadCommands.ts +++ b/src/m365/entra/aadCommands.ts @@ -21,6 +21,7 @@ export default { APPROLEASSIGNMENT_ADD: `${prefix} approleassignment add`, APPROLEASSIGNMENT_LIST: `${prefix} approleassignment list`, APPROLEASSIGNMENT_REMOVE: `${prefix} approleassignment remove`, + GROUP_ADD: `${prefix} group add`, GROUP_GET: `${prefix} group get`, GROUP_LIST: `${prefix} group list`, GROUP_REMOVE: `${prefix} group remove`, diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts index 4b7e52efbe6..240e5a61a20 100644 --- a/src/m365/entra/commands.ts +++ b/src/m365/entra/commands.ts @@ -22,6 +22,7 @@ export default { APPROLEASSIGNMENT_ADD: `${prefix} approleassignment add`, APPROLEASSIGNMENT_LIST: `${prefix} approleassignment list`, APPROLEASSIGNMENT_REMOVE: `${prefix} approleassignment remove`, + GROUP_ADD: `${prefix} group add`, GROUP_GET: `${prefix} group get`, GROUP_LIST: `${prefix} group list`, GROUP_REMOVE: `${prefix} group remove`, diff --git a/src/m365/entra/commands/group/group-add.spec.ts b/src/m365/entra/commands/group/group-add.spec.ts new file mode 100644 index 00000000000..dab831e6153 --- /dev/null +++ b/src/m365/entra/commands/group/group-add.spec.ts @@ -0,0 +1,484 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import commands from '../../commands.js'; +import aadCommands from '../../aadCommands.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.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-add.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { CommandError } from '../../../../Command.js'; + +describe(commands.GROUP_ADD, () => { + const randomNumber = 0.8087050548125976; + 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']; + const microsoft365Group = { + "id": "7167b488-1ffb-43f1-9547-35969469bada", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2024-01-09T08:15:16Z", + "creationOptions": [], + "description": "Microsoft 365 group", + "displayName": "Microsoft 365 Group", + "expirationDateTime": null, + "groupTypes": [ + "Unified" + ], + "isAssignableToRole": null, + "mail": "Microsoft365Group@4wrvkx.onmicrosoft.com", + "mailEnabled": true, + "mailNickname": "Microsoft365Group", + "membershipRule": null, + "membershipRuleProcessingState": null, + "onPremisesDomainName": null, + "onPremisesLastSyncDateTime": null, + "onPremisesNetBiosName": null, + "onPremisesSamAccountName": null, + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "preferredLanguage": null, + "proxyAddresses": [ + "SMTP:Microsoft365Group@4wrvkx.onmicrosoft.com" + ], + "renewedDateTime": "2024-01-09T08:15:16Z", + "resourceBehaviorOptions": [], + "resourceProvisioningOptions": [], + "securityEnabled": true, + "securityIdentifier": "S-1-12-1-1902621832-1139875835-2520074133-3669649812", + "theme": null, + "visibility": "Public", + "onPremisesProvisioningErrors": [], + "serviceProvisioningErrors": [] + }; + const securityGroup = { + "id": "bc91082e-73ad-4a97-9852-e66004c7b0b6", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2024-01-09T08:16:28Z", + "creationOptions": [], + "description": "Security group", + "displayName": "Security Group", + "expirationDateTime": null, + "groupTypes": [], + "isAssignableToRole": null, + "mail": null, + "mailEnabled": false, + "mailNickname": "SecurityGroup", + "membershipRule": null, + "membershipRuleProcessingState": null, + "onPremisesDomainName": null, + "onPremisesLastSyncDateTime": null, + "onPremisesNetBiosName": null, + "onPremisesSamAccountName": null, + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "preferredLanguage": null, + "proxyAddresses": [], + "renewedDateTime": "2024-01-09T08:16:28Z", + "resourceBehaviorOptions": [], + "resourceProvisioningOptions": [], + "securityEnabled": true, + "securityIdentifier": "S-1-12-1-3163621422-1251439533-1625707160-3065038596", + "theme": null, + "visibility": "Public", + "onPremisesProvisioningErrors": [], + "serviceProvisioningErrors": [] + }; + const groupWithGeneratedMailNickname = { + "id": "7167b488-1ffb-43f1-9547-35969469bada", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2024-01-09T08:15:16Z", + "creationOptions": [], + "description": "Microsoft 365 group", + "displayName": "Microsoft 365 Group", + "expirationDateTime": null, + "groupTypes": [ + "Unified" + ], + "isAssignableToRole": null, + "mail": "Group808705@4wrvkx.onmicrosoft.com", + "mailEnabled": true, + "mailNickname": "Group808705", + "membershipRule": null, + "membershipRuleProcessingState": null, + "onPremisesDomainName": null, + "onPremisesLastSyncDateTime": null, + "onPremisesNetBiosName": null, + "onPremisesSamAccountName": null, + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "preferredLanguage": null, + "proxyAddresses": [ + "SMTP:Group808705@4wrvkx.onmicrosoft.com" + ], + "renewedDateTime": "2024-01-09T08:15:16Z", + "resourceBehaviorOptions": [], + "resourceProvisioningOptions": [], + "securityEnabled": true, + "securityIdentifier": "S-1-12-1-1902621832-1139875835-2520074133-3669649812", + "theme": null, + "visibility": "Public", + "onPremisesProvisioningErrors": [], + "serviceProvisioningErrors": [] + }; + const addOwnersRequest = [ + { + id: 1, + method: 'PATCH', + url: `/groups/${microsoft365Group.id}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'owners@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${microsoft365Group.id}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'owners@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]; + const addMembersRequest = [ + { + id: 1, + method: 'PATCH', + url: `/groups/${microsoft365Group.id}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${microsoft365Group.id}`, + headers: { 'content-type': 'application/json;odata.metadata=none' }, + body: { + 'members@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + 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.service.connected = 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); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.post, + entraUser.getUserIdsByUpns + ]); + }); + + after(() => { + sinon.restore(); + auth.service.connected = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.GROUP_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('defines alias', () => { + const alias = command.alias(); + assert.notStrictEqual(typeof alias, 'undefined'); + }); + + it('defines correct alias', () => { + const alias = command.alias(); + assert.deepStrictEqual(alias, [aadCommands.GROUP_ADD]); + }); + + it('fails validation if the length of displayName is more than 256 characters', async () => { + const displayName = 'lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum'; + const actual = await command.validate({ options: { displayName: displayName, type: 'security' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the length of mailNickname is more than 64 characters', async () => { + const mailNickname = 'loremipsumloremipsumloremipsumloremipsumloremipsumloremipsumloremipsumlorem'; + const actual = await command.validate({ options: { displayName: 'Cli group', mailNickname: mailNickname, type: 'security' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if mailNickname is not valid', async () => { + const mailNickname = 'lorem ipsum'; + const actual = await command.validate({ options: { displayName: 'Cli group', mailNickname: mailNickname, type: 'security' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if ownerIds contains invalid GUID', async () => { + const ownerIds = ['7167b488-1ffb-43f1-9547-35969469bada', 'foo']; + const actual = await command.validate({ options: { displayName: 'Cli group', ownerIds: ownerIds.join(','), type: 'security' } }, commandInfo); + assert.notStrictEqual(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: { displayName: 'Cli group', ownerUserNames: ownerUserNames.join(','), type: 'security' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if memberIds contains invalid GUID', async () => { + const memberIds = ['7167b488-1ffb-43f1-9547-35969469bada', 'foo']; + const actual = await command.validate({ options: { displayName: 'Cli group', memberIds: memberIds.join(','), type: 'security' } }, commandInfo); + assert.notStrictEqual(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: { displayName: 'Cli group', memberUserNames: memberUserNames.join(','), type: 'security' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if visibility contains invalid value', async () => { + const actual = await command.validate({ options: { displayName: 'Cli group', visibility: 'foo', type: 'microsoft365' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if type contains invalid value', async () => { + const actual = await command.validate({ options: { displayName: 'Cli group', visibility: 'Public', type: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if type is microsoft365 but visibility is not specified', async () => { + const actual = await command.validate({ options: { displayName: 'Cli group', type: 'microsoft365' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with ids', async () => { + const actual = await command.validate({ options: { displayName: 'Cli group', ownerIds: userIds.join(','), memberIds: userIds.join(','), type: 'security' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with user names', async () => { + const actual = await command.validate({ options: { displayName: 'Cli group', ownerUserNames: userUpns.join(','), memberUserNames: userUpns.join(','), type: 'security' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('successfully creates Microsoft 365 group without owners and members', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { + return microsoft365Group; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', type: 'microsoft365' } }); + assert(loggerLogSpy.calledWith(microsoft365Group)); + }); + + it('successfully creates security group without owners and members', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { + return securityGroup; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Security Group', description: 'Security Group', mailNickname: 'SecurityGroup', type: 'security' } }); + assert(loggerLogSpy.calledWith(securityGroup)); + }); + + it('successfully creates group with owners specified by ids', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { + return microsoft365Group; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', type: 'microsoft365', ownerIds: userIds.join(',') } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, addOwnersRequest); + }); + + it('successfully creates group with members specified by ids', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { + return microsoft365Group; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', type: 'microsoft365', memberIds: userIds.join(',') } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, addMembersRequest); + }); + + it('successfully creates group with owners specified by user names', async () => { + sinon.stub(entraUser, 'getUserIdsByUpns').withArgs(userUpns).resolves(userIds); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { + return microsoft365Group; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', type: 'microsoft365', ownerUserNames: userUpns.join(',') } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, addOwnersRequest); + }); + + it('successfully creates group with members specified by user names', async () => { + sinon.stub(entraUser, 'getUserIdsByUpns').withArgs(userUpns).resolves(userIds); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { + return microsoft365Group; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', type: 'microsoft365', memberUserNames: userUpns.join(','), verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, addMembersRequest); + }); + + it('successfully creates group with generated mailNickname', async () => { + sinon.stub(Math, 'random').resolves(randomNumber); + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { + return groupWithGeneratedMailNickname; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', visibility: 'Public', type: 'microsoft365' } }); + assert(loggerLogSpy.calledWith(groupWithGeneratedMailNickname)); + }); + + it('handles API error when adding users to a group', async () => { + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/groups') { + return microsoft365Group; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch') { + 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'.` + } + } + } + ] + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', type: 'microsoft365', ownerIds: userIds.join(',') } }), + new CommandError(`One or more added object references already exist for the following modified properties: 'members'.`)); + }); + + it('correctly handles API OData error', async () => { + sinon.stub(request, 'post').rejects({ + error: { + 'odata.error': { + code: '-1, InvalidOperationException', + message: { + value: 'Invalid request' + } + } + } + }); + + await assert.rejects(command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', type: 'microsoft365', ownerIds: userIds.join(',') } }), + new CommandError('Invalid request')); + }); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/group/group-add.ts b/src/m365/entra/commands/group/group-add.ts new file mode 100644 index 00000000000..4729dc0aac5 --- /dev/null +++ b/src/m365/entra/commands/group/group-add.ts @@ -0,0 +1,287 @@ +import { Group } from '@microsoft/microsoft-graph-types'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import aadCommands from '../../aadCommands.js'; +import { validation } from '../../../../utils/validation.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { entraUser } from '../../../../utils/entraUser.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + displayName: string; + description?: string; + type: string; + mailNickname?: string; + ownerIds?: string; + ownerUserNames?: string; + memberIds?: string; + memberUserNames?: string; + visibility?: string; +} + +class EntraGroupAddCommand extends GraphCommand { + public get name(): string { + return commands.GROUP_ADD; + } + + public get description(): string { + return 'Creates a Microsoft Entra group'; + } + + public alias(): string[] | undefined { + return [aadCommands.GROUP_ADD]; + } + + public allowUnknownOptions(): boolean | undefined { + return true; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-n, --displayName ' + }, + { + option: '-d, --description [description]' + }, + { + option: '-t, --type ', + autocomplete: ['microsoft365', 'security'] + }, + { + option: '-m, --mailNickname [mailNickname]' + }, + { + option: '--ownerIds [ownerIds]' + }, + { + option: '--ownerUserNames [ownerUserNames]' + }, + { + option: '--memberIds [memberIds]' + }, + { + option: '--memberUserNames [memberUserNames]' + }, + { + option: '--visibility [visibility]', + autocomplete: ['Public', 'Private', 'HiddenMembership'] + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.displayName.length > 256) { + return `The maximum amount of characters for 'displayName' is 256.`; + } + + if (args.options.mailNickname) { + if (!validation.isValidMailNickname(args.options.mailNickname)) { + return `Value for option 'mailNickname' must contain only characters in the ASCII character set 0-127 except the following: @ () \ [] " ; : <> , SPACE.`; + } + + if (args.options.mailNickname.length > 64) { + return `The maximum amount of characters for 'mailNickname' is 64.`; + } + } + + if (args.options.ownerIds) { + const ids = args.options.ownerIds.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 'ownerIds'.`; + } + } + + if (args.options.ownerUserNames) { + const isValidUserPrincipalNameArray = validation.isValidUserPrincipalNameArray(args.options.ownerUserNames.split(',').map(u => u.trim())); + if (isValidUserPrincipalNameArray !== true) { + return `User principal name '${isValidUserPrincipalNameArray}' is invalid for option 'ownerUserNames'.`; + } + } + + if (args.options.memberIds) { + const ids = args.options.memberIds.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 'memberIds'.`; + } + } + + if (args.options.memberUserNames) { + const isValidUserPrincipalNameArray = validation.isValidUserPrincipalNameArray(args.options.memberUserNames.split(',').map(u => u.trim())); + if (isValidUserPrincipalNameArray !== true) { + return `User principal name '${isValidUserPrincipalNameArray}' is invalid for option 'memberUserNames'.`; + } + } + + if (['microsoft365', 'security'].indexOf(args.options.type) === -1) { + return `Option 'type' must be one of the following values: microsoft365, security.`; + } + + if (args.options.type === 'microsoft365' && !args.options.visibility) { + return `Option 'visibility' must be specified if the option 'type' is set to microsoft365`; + } + + if (args.options.visibility && ['Public', 'Private', 'HiddenMembership'].indexOf(args.options.visibility!) === -1) { + return `Option 'visibility' must be one of the following values: Public, Private, HiddenMembership.`; + } + + return true; + } + ); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + displayName: typeof args.options.displayName !== 'undefined', + description: typeof args.options.description !== 'undefined', + type: typeof args.options.type !== 'undefined', + mailNickname: typeof args.options.mailNickname !== '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', + visibility: typeof args.options.visibility !== 'undefined' + }); + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + let group: Group; + let ownerIds: string[] = []; + let memberIds: string[] = []; + + try { + const manifest = this.createRequestBody(args.options); + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/groups`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: manifest + }; + + ownerIds = await this.getUserIds(logger, args.options.ownerIds, args.options.ownerUserNames); + memberIds = await this.getUserIds(logger, args.options.memberIds, args.options.memberUserNames); + + group = await request.post(requestOptions); + + if (ownerIds.length !== 0) { + await this.addUsers(group.id!, 'owners', ownerIds); + } + + if (memberIds.length !== 0) { + await this.addUsers(group.id!, 'members', memberIds); + } + + await logger.log(group); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + private createRequestBody(options: Options): any { + const requestBody: any = { + displayName: options.displayName, + description: options.description, + mailNickName: options.mailNickname ?? this.generateMailNickname(), + visibility: options.visibility ?? 'Public', + groupTypes: options.type === 'microsoft365' ? ['Unified'] : [], + mailEnabled: options.type === 'security' ? false : true, + securityEnabled: true + }; + + this.addUnknownOptionsToPayload(requestBody, options); + return requestBody; + } + + private generateMailNickname(): string { + return `Group${Math.floor(Math.random() * 1000000)}`; + } + + private async getUserIds(logger: Logger, userIds: string | undefined, userNames: string | undefined): Promise { + if (userIds) { + return userIds.split(',').map(o => o.trim()); + } + + if (!userNames) { + if (this.verbose) { + await logger.logToStderr('No users to validate, skipping.'); + } + return []; + } + + if (this.verbose) { + await logger.logToStderr('Retrieving user information.'); + } + + const userArr: string[] = userNames.split(',').map(o => o.trim()); + + if (this.verbose) { + await logger.logToStderr('Retrieving ID(s) of user(s)...'); + } + + return entraUser.getUserIdsByUpns(userArr); + } + + private async addUsers(groupId: string, role: string, userIds: string[]): Promise { + for (let i = 0; i < userIds.length; i += 400) { + const userIdsBatch = userIds.slice(i, i + 400); + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/$batch`, + headers: { + 'content-type': 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + requests: [] + } + }; + + // 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); + requestOptions.data.requests.push({ + id: j + 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { + 'content-type': '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 }[] }>(requestOptions); + for (const response of res.responses) { + if (response.status !== 204) { + throw response.body; + } + } + } + } +} + +export default new EntraGroupAddCommand(); \ No newline at end of file diff --git a/src/utils/validation.spec.ts b/src/utils/validation.spec.ts index c9435f3f72a..1d8754b7196 100644 --- a/src/utils/validation.spec.ts +++ b/src/utils/validation.spec.ts @@ -476,4 +476,74 @@ describe('validation/validation', () => { const expected = false; assert.strictEqual(actual, expected); }); + + it('isValidMailNickname returns true when mailNickname is valid', () => { + const result = validation.isValidMailNickname('nickname'); + assert.strictEqual(result, true); + }); + + it('isValidMailNickname returns false when mailNickname contains \\', () => { + const result = validation.isValidMailNickname('nicknam\\e'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains <', () => { + const result = validation.isValidMailNickname('', () => { + const result = validation.isValidMailNickname('nickname>'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains (', () => { + const result = validation.isValidMailNickname('(nickname'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains )', () => { + const result = validation.isValidMailNickname('nickname)'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains [', () => { + const result = validation.isValidMailNickname('[nickname'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains ]', () => { + const result = validation.isValidMailNickname('nickname]'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains @', () => { + const result = validation.isValidMailNickname('nick@name'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains space', () => { + const result = validation.isValidMailNickname('nick name'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains "', () => { + const result = validation.isValidMailNickname('nick"name'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains ;', () => { + const result = validation.isValidMailNickname('nick;name'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains :', () => { + const result = validation.isValidMailNickname('nick:name'); + assert.strictEqual(result, false); + }); + + it('isValidMailNickname returns false when mailNickname contains ,', () => { + const result = validation.isValidMailNickname('nick,name'); + assert.strictEqual(result, false); + }); }); \ No newline at end of file diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 7cab922abe9..602606dd289 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -370,5 +370,11 @@ export const validation = { } return false; + }, + + isValidMailNickname(mailNickname: string): boolean { + const mailNicknameRegEx = new RegExp(/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]*$/i); + + return mailNicknameRegEx.test(mailNickname); } }; \ No newline at end of file