diff --git a/docs/docs/cmd/spo/user/user-ensure.mdx b/docs/docs/cmd/spo/user/user-ensure.mdx index 12551b5ef98..438235b8c36 100644 --- a/docs/docs/cmd/spo/user/user-ensure.mdx +++ b/docs/docs/cmd/spo/user/user-ensure.mdx @@ -18,29 +18,56 @@ m365 spo user ensure [options] `-u, --webUrl ` : Absolute URL of the site. -`--entraId [--entraId]` -: Id of the user in Entra. Specify either `entraId` or `userName`. +`--entraId [entraId]` +: Id of the user in Entra ID. Specify either `entraId`, `userName`, `loginName`, `entraGroupId` or `entraGroupName`. `--userName [userName]` -: User's UPN (user principal name, e.g. john@contoso.com). Specify either `entraId` or `userName`. +: User's UPN (user principal name, e.g. john@contoso.com). Specify either `entraId`, `userName`, `loginName`, `entraGroupId` or `entraGroupName`. + +`--loginName [loginName]` +: The login name of the principal. Specify either `entraId`, `userName`, `loginName`, `entraGroupId` or `entraGroupName`. + +`--entraGroupId [entraGroupId]` +: ID of the Microsoft Entra group. Specify either `entraId`, `userName`, `loginName`, `entraGroupId` or `entraGroupName`. + +`--entraGroupName [entraGroupName]` +: Display name of the Microsoft Entra group. Specify either `entraId`, `userName`, `loginName`, `entraGroupId` or `entraGroupName`. ``` ## Examples -Ensures a user by its Entra Id. +Ensure a user by its Entra Id. ```sh m365 spo user ensure --webUrl https://contoso.sharepoint.com/sites/project --entraId e254750a-eaa4-44f6-9517-b74f65cdb747 ``` -Ensures a user by its user principal name. +Ensure a user by its user principal name. ```sh m365 spo user ensure --webUrl https://contoso.sharepoint.com/sites/project --userName john@contoso.com ``` +Ensure a user by its login name. + +```sh +m365 spo user ensure --webUrl https://contoso.sharepoint.com/sites/Marketing --loginName "i:0#.f|membership|john.doe@contoso.com" +``` + +Ensure a Microsoft Entra group by ID. + +```sh +m365 spo user ensure --webUrl https://contoso.sharepoint.com/sites/Marketing --entraGroupId e08e899f-ba40-4e91-ab36-44d4fbaa454e +``` + +Ensure a Microsoft Entra group by display name. + +```sh +m365 spo user ensure --webUrl https://contoso.sharepoint.com/sites/Marketing --entraGroupName "Marketing team" +``` + ## Response @@ -119,4 +146,3 @@ m365 spo user ensure --webUrl https://contoso.sharepoint.com/sites/project --use - diff --git a/src/m365/spo/commands/user/user-ensure.spec.ts b/src/m365/spo/commands/user/user-ensure.spec.ts index 9e8382f0723..e9bb68d901c 100644 --- a/src/m365/spo/commands/user/user-ensure.spec.ts +++ b/src/m365/spo/commands/user/user-ensure.spec.ts @@ -13,11 +13,16 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './user-ensure.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; describe(commands.USER_ENSURE, () => { const validUserName = 'john@contoso.com'; const validEntraId = '2056d2f6-3257-4253-8cfc-b73393e414e5'; const validWebUrl = 'https://contoso.sharepoint.com'; + const validEntraGroupId = '2056d2f6-3257-4253-8cfc-b73393e414e5'; + const validEntraGroupName = 'Finance'; + const validEntraSecurityGroupName = 'EntraGroupTest'; + const validLoginName = `i:0#.f|membership|${validUserName}`; const ensuredUserResponse = { Id: 35, IsHiddenInUI: false, @@ -36,6 +41,103 @@ describe(commands.USER_ENSURE, () => { UserPrincipalName: validUserName }; + const groupM365Response = { + value: [{ + "id": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2017-11-29T03:27:05Z", + "description": "This is the Contoso Finance Group. Please come here and check out the latest news, posts, files, and more.", + "displayName": "Finance", + "groupTypes": [ + "Unified" + ], + "mail": "finance@contoso.onmicrosoft.com", + "mailEnabled": true, + "mailNickname": "finance", + "onPremisesLastSyncDateTime": null, + "onPremisesProvisioningErrors": [], + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "proxyAddresses": [ + "SMTP:finance@contoso.onmicrosoft.com" + ], + "renewedDateTime": "2017-11-29T03:27:05Z", + "securityEnabled": false, + "visibility": "Public" + }] + }; + + const ensuredGroupResponse = { + Id: 35, + IsHiddenInUI: false, + LoginName: `c:0o.c|federateddirectoryclaimprovider|${validEntraGroupId}`, + Title: validEntraGroupName, + PrincipalType: 4, + Email: 'finance@contoso.com', + Expiration: '', + IsEmailAuthenticationGuestUser: false, + IsShareByEmailGuestUser: false, + IsSiteAdmin: false, + UserId: null, + UserPrincipalName: null + }; + + const groupSecurityResponse = { + value: [{ + "id": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2024-01-27T16:02:56Z", + "creationOptions": [], + "description": "Entra Group Test", + "displayName": "EntraGroupTest", + "expirationDateTime": null, + "groupTypes": [], + "isAssignableToRole": true, + "mail": null, + "mailEnabled": false, + "mailNickname": "f45205a2-d", + "membershipRule": null, + "membershipRuleProcessingState": null, + "onPremisesDomainName": null, + "onPremisesLastSyncDateTime": null, + "onPremisesNetBiosName": null, + "onPremisesSamAccountName": null, + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "preferredLanguage": null, + "proxyAddresses": [], + "renewedDateTime": "2024-01-27T16:02:56Z", + "resourceBehaviorOptions": [], + "resourceProvisioningOptions": [], + "securityEnabled": true, + "securityIdentifier": "S-1-12-1-1968173404-1154184881-1694549896-3083850660", + "theme": null, + "visibility": "Private", + "onPremisesProvisioningErrors": [], + "serviceProvisioningErrors": [] + }] + }; + + const ensuredSecurityGroupResponse = { + logonName: 'c:0t.c|tenant|2056d2f6-3257-4253-8cfc-b73393e414e5', + Id: 35, + IsHiddenInUI: false, + LoginName: `c:0t.c|tenant||${validEntraGroupId}`, + Title: validEntraGroupName, + PrincipalType: 4, + Email: null, + Expiration: '', + IsEmailAuthenticationGuestUser: false, + IsShareByEmailGuestUser: false, + IsSiteAdmin: false, + UserId: null, + UserPrincipalName: null + }; + let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; @@ -68,8 +170,11 @@ describe(commands.USER_ENSURE, () => { afterEach(() => { sinonUtil.restore([ + request.get, request.post, - entraUser.getUpnByUserId + entraUser.getUpnByUserId, + entraGroup.getGroupById, + entraGroup.getGroupByDisplayName ]); }); @@ -86,7 +191,7 @@ describe(commands.USER_ENSURE, () => { assert.notStrictEqual(command.description, null); }); - it('ensures user for a specific web by userPrincipalName', async () => { + it('ensures user in a specific web by userPrincipalName', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `${validWebUrl}/_api/web/ensureuser`) { return ensuredUserResponse; @@ -99,7 +204,7 @@ describe(commands.USER_ENSURE, () => { assert(loggerLogSpy.calledWith(ensuredUserResponse)); }); - it('ensures user for a specific web by entraId', async () => { + it('ensures user in a specific web by entraId', async () => { sinon.stub(entraUser, 'getUpnByUserId').callsFake(async () => { return validUserName; }); @@ -116,6 +221,70 @@ describe(commands.USER_ENSURE, () => { assert(loggerLogSpy.calledWith(ensuredUserResponse)); }); + it('ensures user in a specific web by loginName', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${validWebUrl}/_api/web/ensureuser`) { + return ensuredUserResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, loginName: validLoginName } }); + assert(loggerLogSpy.calledWith(ensuredUserResponse)); + }); + + it('ensures user in a specific web by entraGroupId', async () => { + sinon.stub(entraGroup, 'getGroupById').callsFake(async () => { + return groupM365Response.value[0]; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${validWebUrl}/_api/web/ensureuser`) { + return ensuredGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraGroupId: validEntraGroupId } }); + assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'c:0o.c|federateddirectoryclaimprovider|2056d2f6-3257-4253-8cfc-b73393e414e5' }); + }); + + it('ensures security group in a specific web by entraGroupName', async () => { + sinon.stub(entraGroup, 'getGroupByDisplayName').callsFake(async () => { + return groupSecurityResponse.value[0]; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${validWebUrl}/_api/web/ensureuser`) { + return ensuredSecurityGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraGroupName: validEntraSecurityGroupName } }); + assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'c:0t.c|tenant|2056d2f6-3257-4253-8cfc-b73393e414e5' }); + }); + + it('ensures group in a specific web by entraGroupName', async () => { + sinon.stub(entraGroup, 'getGroupByDisplayName').callsFake(async () => { + return groupM365Response.value[0]; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${validWebUrl}/_api/web/ensureuser`) { + return ensuredGroupResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { verbose: true, webUrl: validWebUrl, entraGroupName: validEntraGroupName } }); + assert.deepStrictEqual(postStub.firstCall.args[0].data, { logonName: 'c:0o.c|federateddirectoryclaimprovider|2056d2f6-3257-4253-8cfc-b73393e414e5' }); + }); + it('throws error message when no user was found with a specific id', async () => { sinon.stub(entraUser, 'getUpnByUserId').callsFake(async (id) => { throw { @@ -148,6 +317,7 @@ describe(commands.USER_ENSURE, () => { } } }; + sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `${validWebUrl}/_api/web/ensureuser`) { throw error; @@ -174,6 +344,11 @@ describe(commands.USER_ENSURE, () => { assert.notStrictEqual(actual, true); }); + it('fails validation if entraGroupId is not a valid id', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + it('passes validation if the url is valid and entraId is a valid id', async () => { const actual = await command.validate({ options: { webUrl: validWebUrl, entraId: validEntraId } }, commandInfo); assert.strictEqual(actual, true); @@ -183,4 +358,19 @@ describe(commands.USER_ENSURE, () => { const actual = await command.validate({ options: { webUrl: validWebUrl, userName: validUserName } }, commandInfo); assert.strictEqual(actual, true); }); + + it('passes validation if the url is valid and loginName is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, loginName: validLoginName } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the url is valid and entraGroupName is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupName: validEntraGroupName } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the url is valid and entraGroupId is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: validEntraGroupId } }, commandInfo); + assert.strictEqual(actual, true); + }); }); diff --git a/src/m365/spo/commands/user/user-ensure.ts b/src/m365/spo/commands/user/user-ensure.ts index c4f681d15aa..8e45cf9e426 100644 --- a/src/m365/spo/commands/user/user-ensure.ts +++ b/src/m365/spo/commands/user/user-ensure.ts @@ -1,10 +1,12 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; -import { entraUser } from '../../../../utils/entraUser.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; +import { Group } from '@microsoft/microsoft-graph-types'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +import { entraUser } from '../../../../utils/entraUser.js'; interface CommandArgs { options: Options; @@ -14,6 +16,9 @@ interface Options extends GlobalOptions { webUrl: string; entraId?: string; userName?: string; + loginName?: string; + entraGroupId?: string; + entraGroupName?: string; } class SpoUserEnsureCommand extends SpoCommand { @@ -32,13 +37,17 @@ class SpoUserEnsureCommand extends SpoCommand { this.#initOptions(); this.#initValidators(); this.#initOptionSets(); + this.#initTypes(); } #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { Object.assign(this.telemetryProperties, { entraId: typeof args.options.entraId !== 'undefined', - userName: typeof args.options.userName !== 'undefined' + userName: typeof args.options.userName !== 'undefined', + loginName: typeof args.options.loginName !== 'undefined', + entraGroupId: typeof args.options.entraGroupId !== 'undefined', + entraGroupName: typeof args.options.entraGroupName !== 'undefined' }); }); } @@ -53,6 +62,15 @@ class SpoUserEnsureCommand extends SpoCommand { }, { option: '--userName [userName]' + }, + { + option: '--loginName [loginName]' + }, + { + option: '--entraGroupId [entraGroupId]' + }, + { + option: '--entraGroupName [entraGroupName]' } ); } @@ -73,23 +91,31 @@ class SpoUserEnsureCommand extends SpoCommand { return `${args.options.userName} is not a valid userName.`; } + if (args.options.entraGroupId && !validation.isValidGuid(args.options.entraGroupId)) { + return `${args.options.entraGroupId} is not a valid GUID for option 'entraGroupId'.`; + } + return true; } ); } #initOptionSets(): void { - this.optionSets.push({ options: ['entraId', 'userName'] }); + this.optionSets.push({ options: ['entraId', 'userName', 'loginName', 'entraGroupId', 'entraGroupName'] }); + } + + #initTypes(): void { + this.types.string.push('webUrl', 'entraId', 'userName', 'loginName', 'entraGroupId', 'entraGroupName'); } public async commandAction(logger: Logger, args: CommandArgs): Promise { if (this.verbose) { - await logger.logToStderr(`Ensuring user ${args.options.entraId || args.options.userName} at site ${args.options.webUrl}`); + await logger.logToStderr(`Ensuring user ${args.options.entraId || args.options.userName || args.options.loginName || args.options.entraGroupId || args.options.entraGroupName} at site ${args.options.webUrl}`); } try { const requestBody = { - logonName: args.options.userName || await this.getUpnByUserId(args.options.entraId!, logger) + logonName: await this.getUpn(args.options) }; const requestOptions: CliRequestOptions = { @@ -109,13 +135,35 @@ class SpoUserEnsureCommand extends SpoCommand { } } - private async getUpnByUserId(entraId: string, logger: Logger): Promise { - if (this.verbose) { - await logger.logToStderr(`Retrieving user principal name for user with id ${entraId}`); + private async getUpn(options: Options): Promise { + if (options.userName) { + return options.userName; + } + + if (options.entraId) { + return entraUser.getUpnByUserId(options.entraId); + } + + if (options.loginName) { + return options.loginName; + } + + let upn: string = ''; + if (options.entraGroupId || options.entraGroupName) { + const entraGroup = await this.getEntraGroup(options.entraGroupId, options.entraGroupName); + upn = entraGroup.mailEnabled ? `c:0o.c|federateddirectoryclaimprovider|${entraGroup.id}` : `c:0t.c|tenant|${entraGroup.id}`; + } + + return upn; + } + + private async getEntraGroup(entraGroupId?: string, entraGroupName?: string): Promise { + if (entraGroupId) { + return entraGroup.getGroupById(entraGroupId); } - return await entraUser.getUpnByUserId(entraId); + return entraGroup.getGroupByDisplayName(entraGroupName!); } } -export default new SpoUserEnsureCommand(); \ No newline at end of file +export default new SpoUserEnsureCommand();