diff --git a/docs/docs/cmd/entra/user/user-license-add.mdx b/docs/docs/cmd/entra/user/user-license-add.mdx index c46d0902802..67f52096129 100644 --- a/docs/docs/cmd/entra/user/user-license-add.mdx +++ b/docs/docs/cmd/entra/user/user-license-add.mdx @@ -35,6 +35,25 @@ The user must have a `usageLocation` value in order to assign a license to it. ::: +## Permissions + + + + + | Resource | Permissions | + |-----------------|---------------------------------| + | Microsoft Graph | LicenseAssignment.ReadWrite.All | + + + + + | Resource | Permissions | + |-----------------|---------------------------------| + | Microsoft Graph | LicenseAssignment.ReadWrite.All | + + + + ## Examples Assign specific licenses to a specific user by UPN. diff --git a/docs/docs/cmd/entra/user/user-license-list.mdx b/docs/docs/cmd/entra/user/user-license-list.mdx index a663f9fe694..4cb15838e8a 100644 --- a/docs/docs/cmd/entra/user/user-license-list.mdx +++ b/docs/docs/cmd/entra/user/user-license-list.mdx @@ -28,10 +28,27 @@ m365 entra user license list [options] :::tip -If you don't specify any option, the command will list the license details of the current logged in user. This does not work when using application permissions. +If you don't specify any option, the command will list the license details of the current logged in user. ::: +## Permissions + + + + + | Resource | Permissions | + |-----------------|----------------------------| + | Microsoft Graph | LicenseAssignment.Read.All | + + + + + This command does not support application permissions. + + + + ## Examples List license details of the current logged in user. diff --git a/docs/docs/cmd/entra/user/user-license-remove.mdx b/docs/docs/cmd/entra/user/user-license-remove.mdx index 86f15dcd705..faef4e0ff08 100644 --- a/docs/docs/cmd/entra/user/user-license-remove.mdx +++ b/docs/docs/cmd/entra/user/user-license-remove.mdx @@ -1,4 +1,6 @@ import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; # entra user license remove @@ -28,6 +30,25 @@ m365 entra user license remove [options] +## Permissions + + + + + | Resource | Permissions | + |-----------------|---------------------------------| + | Microsoft Graph | LicenseAssignment.ReadWrite.All | + + + + + | Resource | Permissions | + |-----------------|---------------------------------| + | Microsoft Graph | LicenseAssignment.ReadWrite.All | + + + + ## Examples Remove specific licenses from a specific user by UPN. diff --git a/src/m365/entra/commands/user/user-license-add.spec.ts b/src/m365/entra/commands/user/user-license-add.spec.ts index 54e73f7f877..511cdfe61b3 100644 --- a/src/m365/entra/commands/user/user-license-add.spec.ts +++ b/src/m365/entra/commands/user/user-license-add.spec.ts @@ -9,6 +9,7 @@ import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; +import { formatting } from '../../../../utils/formatting.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './user-license-add.js'; @@ -107,7 +108,7 @@ describe(commands.USER_LICENSE_ADD, () => { it('adds licenses to a user by userId', async () => { sinon.stub(request, 'post').callsFake(async opts => { - if ((opts.url === `https://graph.microsoft.com/v1.0/users/${validUserId}/assignLicense`)) { + if ((opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(validUserId)}/assignLicense`)) { return userLicenseResponse; } @@ -120,7 +121,7 @@ describe(commands.USER_LICENSE_ADD, () => { it('adds licenses to a user by userName', async () => { sinon.stub(request, 'post').callsFake(async opts => { - if ((opts.url === `https://graph.microsoft.com/v1.0/users/${validUserName}/assignLicense`)) { + if ((opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(validUserName)}/assignLicense`)) { return userLicenseResponse; } diff --git a/src/m365/entra/commands/user/user-license-add.ts b/src/m365/entra/commands/user/user-license-add.ts index 9a8ed9aecc7..19767800b39 100644 --- a/src/m365/entra/commands/user/user-license-add.ts +++ b/src/m365/entra/commands/user/user-license-add.ts @@ -1,6 +1,7 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; @@ -83,7 +84,7 @@ class EntraUserLicenseAddCommand extends GraphCommand { const requestBody = { "addLicenses": addLicenses, "removeLicenses": [] }; const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/users/${args.options.userId || args.options.userName}/assignLicense`, + url: `${this.resource}/v1.0/users/${formatting.encodeQueryParameter(args.options.userId || args.options.userName)}/assignLicense`, headers: { accept: 'application/json;odata.metadata=none' }, diff --git a/src/m365/entra/commands/user/user-license-list.spec.ts b/src/m365/entra/commands/user/user-license-list.spec.ts index c449779eb2c..5cf4f07e801 100644 --- a/src/m365/entra/commands/user/user-license-list.spec.ts +++ b/src/m365/entra/commands/user/user-license-list.spec.ts @@ -10,6 +10,7 @@ import { telemetry } from '../../../../telemetry.js'; import { accessToken } from '../../../../utils/accessToken.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; +import { formatting } from '../../../../utils/formatting.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './user-license-list.js'; @@ -51,6 +52,7 @@ describe(commands.USER_LICENSE_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let assertAccessTokenTypeStub: sinon.SinonStub; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -79,13 +81,13 @@ describe(commands.USER_LICENSE_LIST, () => { } }; loggerLogSpy = sinon.spy(logger, 'log'); - sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + assertAccessTokenTypeStub = sinon.stub(accessToken, 'assertAccessTokenType').withArgs('delegated').resolves(); }); afterEach(() => { sinonUtil.restore([ request.get, - accessToken.isAppOnlyAccessToken + accessToken.assertAccessTokenType ]); }); @@ -126,13 +128,11 @@ describe(commands.USER_LICENSE_LIST, () => { assert.strictEqual(actual, true); }); - it('throws an error when using application permissions and no option is specified', async () => { - sinonUtil.restore(accessToken.isAppOnlyAccessToken); - sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + it('ensures delegated permissions are enforced', async () => { + sinon.stub(request, 'get').resolves(licenseResponse); - await assert.rejects(command.action(logger, { - options: {} - }), new CommandError(`Specify at least 'userId' or 'userName' when using application permissions.`)); + await command.action(logger, { options: { userId: userId } }); + assert(assertAccessTokenTypeStub.calledOnceWithExactly('delegated')); }); it('retrieves license details of the current logged in user', async () => { @@ -150,7 +150,7 @@ describe(commands.USER_LICENSE_LIST, () => { it('retrieves license details of a specific user by its ID', async () => { sinon.stub(request, 'get').callsFake(async opts => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/licenseDetails`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userId)}/licenseDetails`) { return licenseResponse; } @@ -163,7 +163,7 @@ describe(commands.USER_LICENSE_LIST, () => { it('retrieves license details of a specific user by its UPN', async () => { sinon.stub(request, 'get').callsFake(async opts => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${userName}/licenseDetails`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(userName)}/licenseDetails`) { return licenseResponse; } diff --git a/src/m365/entra/commands/user/user-license-list.ts b/src/m365/entra/commands/user/user-license-list.ts index bcb2a8d09de..546e20d403f 100644 --- a/src/m365/entra/commands/user/user-license-list.ts +++ b/src/m365/entra/commands/user/user-license-list.ts @@ -1,11 +1,10 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; -import { accessToken } from '../../../../utils/accessToken.js'; import { odata } from '../../../../utils/odata.js'; +import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; -import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; -import auth from '../../../../Auth.js'; +import GraphDelegatedCommand from '../../../base/GraphDelegatedCommand.js'; interface CommandArgs { options: Options; @@ -16,7 +15,7 @@ interface Options extends GlobalOptions { userName?: string; } -class EntraUserLicenseListCommand extends GraphCommand { +class EntraUserLicenseListCommand extends GraphDelegatedCommand { public get name(): string { return commands.USER_LICENSE_LIST; } @@ -82,18 +81,13 @@ class EntraUserLicenseListCommand extends GraphCommand { } public async commandAction(logger: Logger, args: CommandArgs): Promise { - const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[this.resource].accessToken); - if (isAppOnlyAccessToken && !args.options.userId && !args.options.userName) { - this.handleError(`Specify at least 'userId' or 'userName' when using application permissions.`); - } - if (this.verbose) { await logger.logToStderr(`Retrieving licenses from user: ${args.options.userId || args.options.userName || 'current user'}.`); } let requestUrl: string = `${this.resource}/v1.0/`; if (args.options.userId || args.options.userName) { - requestUrl += `users/${args.options.userId || args.options.userName}`; + requestUrl += `users/${formatting.encodeQueryParameter(args.options.userId || args.options.userName as string)}`; } else { requestUrl += 'me'; diff --git a/src/m365/entra/commands/user/user-license-remove.spec.ts b/src/m365/entra/commands/user/user-license-remove.spec.ts index f2cd05ce5c8..e5b457e3b88 100644 --- a/src/m365/entra/commands/user/user-license-remove.spec.ts +++ b/src/m365/entra/commands/user/user-license-remove.spec.ts @@ -9,6 +9,7 @@ import request from '../../../../request.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; +import { formatting } from '../../../../utils/formatting.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './user-license-remove.js'; @@ -140,7 +141,7 @@ describe(commands.USER_LICENSE_REMOVE, () => { it('removes a single user license by userId without confirmation prompt', async () => { const postSpy = sinon.stub(request, 'post').callsFake(async opts => { - if ((opts.url === `https://graph.microsoft.com/v1.0/users/${validUserId}/assignLicense`)) { + if ((opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(validUserId)}/assignLicense`)) { return; } @@ -153,7 +154,7 @@ describe(commands.USER_LICENSE_REMOVE, () => { it('removes the specified user licenses by userName when prompt confirmed', async () => { const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${validUserName}/assignLicense`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(validUserName)}/assignLicense`) { return; } @@ -173,7 +174,7 @@ describe(commands.USER_LICENSE_REMOVE, () => { it('removes the specified user licenses by userId without confirmation prompt', async () => { const postSpy = sinon.stub(request, 'post').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users/${validUserId}/assignLicense`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(validUserId)}/assignLicense`) { return; } @@ -196,7 +197,7 @@ describe(commands.USER_LICENSE_REMOVE, () => { }; sinon.stub(request, 'post').callsFake(async opts => { - if ((opts.url === `https://graph.microsoft.com/v1.0/users/${validUserId}/assignLicense`)) { + if ((opts.url === `https://graph.microsoft.com/v1.0/users/${formatting.encodeQueryParameter(validUserId)}/assignLicense`)) { throw error; } diff --git a/src/m365/entra/commands/user/user-license-remove.ts b/src/m365/entra/commands/user/user-license-remove.ts index 9afcbfcb811..4511ed7c1dc 100644 --- a/src/m365/entra/commands/user/user-license-remove.ts +++ b/src/m365/entra/commands/user/user-license-remove.ts @@ -3,6 +3,7 @@ import GlobalOptions from '../../../../GlobalOptions.js'; import commands from '../../commands.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; +import { formatting } from '../../../../utils/formatting.js'; import { cli } from '../../../../cli/cli.js'; import GraphCommand from '../../../base/GraphCommand.js'; @@ -111,7 +112,7 @@ class EntraUserLicenseRemoveCommand extends GraphCommand { const requestBody = { "addLicenses": [], "removeLicenses": removeLicenses }; const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/users/${args.options.userId || args.options.userName}/assignLicense`, + url: `${this.resource}/v1.0/users/${formatting.encodeQueryParameter(args.options.userId || args.options.userName as string)}/assignLicense`, headers: { accept: 'application/json;odata.metadata=none' },