From 1fb49ea7da232fdfe4ab4d550fe2fca7badd6dda Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Fri, 5 Jan 2024 08:21:01 +0100 Subject: [PATCH] Adds the entra administrativeunit roleassignment add command. Closes #5671 --- .eslintrc.cjs | 1 + .../administrativeunit-roleassignment-add.mdx | 116 ++++++ docs/src/config/sidebars.ts | 5 + src/m365/entra/aadCommands.ts | 1 + src/m365/entra/commands.ts | 1 + ...inistrativeunit-roleassignment-add.spec.ts | 369 ++++++++++++++++++ .../administrativeunit-roleassignment-add.ts | 142 +++++++ src/utils/roleAssignment.spec.ts | 89 +++++ src/utils/roleAssignment.ts | 45 +++ src/utils/roleDefinition.spec.ts | 205 ++++++++++ src/utils/roleDefinition.ts | 28 ++ 11 files changed, 1002 insertions(+) create mode 100644 docs/docs/cmd/entra/administrativeunit/administrativeunit-roleassignment-add.mdx create mode 100644 src/m365/entra/commands/administrativeunit/administrativeunit-roleassignment-add.spec.ts create mode 100644 src/m365/entra/commands/administrativeunit/administrativeunit-roleassignment-add.ts create mode 100644 src/utils/roleAssignment.spec.ts create mode 100644 src/utils/roleAssignment.ts create mode 100644 src/utils/roleDefinition.spec.ts create mode 100644 src/utils/roleDefinition.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 4ed406d88a5..5416cd98fef 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -13,6 +13,7 @@ const dictionary = [ 'approve', 'assessment', 'assets', + 'assignment', 'audit', 'bin', 'builder', diff --git a/docs/docs/cmd/entra/administrativeunit/administrativeunit-roleassignment-add.mdx b/docs/docs/cmd/entra/administrativeunit/administrativeunit-roleassignment-add.mdx new file mode 100644 index 00000000000..1a3e146d83f --- /dev/null +++ b/docs/docs/cmd/entra/administrativeunit/administrativeunit-roleassignment-add.mdx @@ -0,0 +1,116 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# entra administrativeunit roleassignment add + +Assigns a Microsoft Entra role with administrative unit scope to a user + +## Usage + +```sh +m365 entra administrativeunit roleassignment add [options] +``` + +## Options + +```md definition-list +`-i, --administrativeUnitId [administrativeUnitId]` +: The id of the administrative unit. Specify either `administrativeUnitId` or `administrativeUnitName`. + +`-n, --administrativeUnitName [administrativeUnitName]` +: The name of the administrative unit. Specify either `administrativeUnitId` or `administrativeUnitName`. + +`--roleDefinitionId [roleDefinitionId]` +: The id of the role definition that the member is in. Specify either `roleDefinitionId` or `roleDefinitionName`. + +`--roleDefinitionName [roleDefinitionName]` +: The name of the role definition that the member is in. Specify either `roleDefinitionId` or `roleDefinitionName`. + +`--userId [userId]` +: The id of the user that is a member of the scoped role. Specify either `userId` or `userName`. + +`--userName [userName]` +: The name of the user that is a member of the scoped role. Specify either `userId` or `userName`. +``` + + + +## Remarks + +:::info + +To use this command you must be either **Global Administrator** or **Privileged Role Administrator**. + +::: + +## Examples + +Assign a role definition specified by id to a user specified by id for an administrative unit specified by id + +```sh +m365 entra administrativeunit roleassignment add --administrativeUnitId 81bb36e4-f4c6-4984-8e56-d4f8feae9e09 --roleDefinitionId 4d6ac14f-3453-41d0-bef9-a3e0c569773a --userId 5f91f951-7305-4a27-9b63-7b00906de09f +``` + +Assign a role definition specified by name to a user specified by name for an administrative unit specified by name + +```sh +m365 entra administrativeunit roleassignment add --administrativeUnitName 'Marketing Division' --roleDefinitionName 'License Administrator' --userName 'john.doe@contoso.com' +``` + +## Response + + + + + ```json + { + "id": "5wuT_mJe20eRr5jDpJo4sVH5kV8FcydKm2N7AJBt4J_kNruBxvSESY5W1Pj-rp4J-2", + "principalId": "5f91f951-7305-4a27-9b63-7b00906de09f", + "directoryScopeId": "/administrativeUnits/81bb36e4-f4c6-4984-8e56-d4f8feae9e09", + "roleDefinitionId": "4d6ac14f-3453-41d0-bef9-a3e0c569773a" + } + ``` + + + + + ```text + directoryScopeId: /administrativeUnits/81bb36e4-f4c6-4984-8e56-d4f8feae9e09 + id : 4yeYchSc90m7G5YI8Va7uFH5kV8FcydKm2N7AJBt4J_kNruBxvSESY5W1Pj-rp4J-2 + principalId : 5f91f951-7305-4a27-9b63-7b00906de09f + roleDefinitionId: 4d6ac14f-3453-41d0-bef9-a3e0c569773a + ``` + + + + + ```csv + id,principalId,directoryScopeId,roleDefinitionId + UB-K8uf2cUWBi2oS8q9rbFH5kV8FcydKm2N7AJBt4J_kNruBxvSESY5W1Pj-rp4J-2,5f91f951-7305-4a27-9b63-7b00906de09f,/administrativeUnits/81bb36e4-f4c6-4984-8e56-d4f8feae9e09,4d6ac14f-3453-41d0-bef9-a3e0c569773a + ``` + + + + + ```md + # entra administrativeunit roleassignment add --administrativeUnitId "81bb36e4-f4c6-4984-8e56-d4f8feae9e09" --roleDefinitionId "4d6ac14f-3453-41d0-bef9-a3e0c569773a" --userId "5f91f951-7305-4a27-9b63-7b00906de09f" + + Date: 11/16/2023 + + ## T8FqTVM00EG--aPgxWl3OlH5kV8FcydKm2N7AJBt4J_kNruBxvSESY5W1Pj-rp4J-2 + + Property | Value + ---------|------- + id | T8FqTVM00EG--aPgxWl3OlH5kV8FcydKm2N7AJBt4J\_kNruBxvSESY5W1Pj-rp4J-2 + principalId | 5f91f951-7305-4a27-9b63-7b00906de09f + directoryScopeId | /administrativeUnits/81bb36e4-f4c6-4984-8e56-d4f8feae9e09 + roleDefinitionId | 4d6ac14f-3453-41d0-bef9-a3e0c569773a + ``` + + + + +## More information + +- [Roles with administrative unit scope](https://learn.microsoft.com/entra/identity/role-based-access-control/admin-units-assign-roles#roles-that-can-be-assigned-with-administrative-unit-scope) diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index f31bd11876a..b30f19b6bf9 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -198,6 +198,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'administrativeunit member list', id: 'cmd/entra/administrativeunit/administrativeunit-member-list' + }, + { + type: 'doc', + label: 'administrativeunit roleassignment add', + id: 'cmd/entra/administrativeunit/administrativeunit-roleassignment-add' } ] }, diff --git a/src/m365/entra/aadCommands.ts b/src/m365/entra/aadCommands.ts index 492da3b946a..8667fe0e4d4 100644 --- a/src/m365/entra/aadCommands.ts +++ b/src/m365/entra/aadCommands.ts @@ -8,6 +8,7 @@ export default { ADMINISTRATIVEUNIT_MEMBER_ADD: `${prefix} administrativeunit member add`, ADMINISTRATIVEUNIT_MEMBER_GET: `${prefix} administrativeunit member get`, ADMINISTRATIVEUNIT_MEMBER_LIST: `${prefix} administrativeunit member list`, + ADMINISTRATIVEUNIT_ROLEASSIGNMENT_ADD: `${prefix} administrativeunit roleassignment add`, APP_ADD: `${prefix} app add`, APP_GET: `${prefix} app get`, APP_LIST: `${prefix} app list`, diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts index 486a7d044df..3c79e1916a6 100644 --- a/src/m365/entra/commands.ts +++ b/src/m365/entra/commands.ts @@ -8,6 +8,7 @@ export default { ADMINISTRATIVEUNIT_MEMBER_ADD: `${prefix} administrativeunit member add`, ADMINISTRATIVEUNIT_MEMBER_GET: `${prefix} administrativeunit member get`, ADMINISTRATIVEUNIT_MEMBER_LIST: `${prefix} administrativeunit member list`, + ADMINISTRATIVEUNIT_ROLEASSIGNMENT_ADD: `${prefix} administrativeunit roleassignment add`, APP_ADD: `${prefix} app add`, APP_GET: `${prefix} app get`, APP_LIST: `${prefix} app list`, diff --git a/src/m365/entra/commands/administrativeunit/administrativeunit-roleassignment-add.spec.ts b/src/m365/entra/commands/administrativeunit/administrativeunit-roleassignment-add.spec.ts new file mode 100644 index 00000000000..6d44815b9c9 --- /dev/null +++ b/src/m365/entra/commands/administrativeunit/administrativeunit-roleassignment-add.spec.ts @@ -0,0 +1,369 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import commands from '../../commands.js'; +import command from './administrativeunit-roleassignment-add.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 { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import { aadAdministrativeUnit } from '../../../../utils/aadAdministrativeUnit.js'; +import { aadUser } from '../../../../utils/aadUser.js'; +import { roleAssignment } from '../../../../utils/roleAssignment.js'; +import { roleDefinition } from '../../../../utils/roleDefinition.js'; +import { settingsNames } from '../../../../settingsNames.js'; +import request from '../../../../request.js'; + +describe(commands.ADMINISTRATIVEUNIT_ROLEASSIGNMENT_ADD, () => { + const roleDefinitionId = 'fe930be7-5e62-47db-91af-98c3a49a38b1'; + const roleDefinitionName = 'User Administrator'; + const userId = '2056d2f6-3257-4253-8cfc-b73393e414e5'; + const userName = 'AdeleVance@contoso.com'; + const administrativeUnitId = 'fc33aa61-cf0e-46b6-9506-f633347202ab'; + const administrativeUnitName = 'Marketing Department'; + const unifiedRoleAssignment = { + "id": "BH21sHQtUEyvox7IA_Eu_mm3jqnUe4lEhvatluHIWb7-1", + "roleDefinitionId": "fe930be7-5e62-47db-91af-98c3a49a38b1", + "principalId": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "directoryScopeId": "/administrativeUnits/fc33aa61-cf0e-46b6-9506-f633347202ab" + }; + + 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([ + aadAdministrativeUnit.getAdministrativeUnitByDisplayName, + aadUser.getUserIdByUpn, + roleAssignment.createRoleAssignmentWithAdministrativeUnitScope, + roleDefinition.getRoleDefinitionByDisplayName, + cli.getSettingWithDefaultValue, + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.service.connected = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ADMINISTRATIVEUNIT_ROLEASSIGNMENT_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('passes validation if administrative unit id, role definition id and user id are passed', async () => { + const actual = await command.validate({ + options: { + administrativeUnitId: administrativeUnitId, + roleDefinitionId: roleDefinitionId, + userId: userId + } + }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if administrative unit name, role definition name and user name are passed', async () => { + const actual = await command.validate({ + options: { + administrativeUnitName: administrativeUnitName, + roleDefinitionName: roleDefinitionName, + userName: userName + } + }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if both user id and user name are not passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ + options: { + administrativeUnitId: administrativeUnitId, + roleDefinitionId: roleDefinitionId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if both user id and user name are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ + options: { + administrativeUnitId: administrativeUnitId, + roleDefinitionId: roleDefinitionId, + userId: userId, + userName: userName + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if both role definition id and role definition name are not passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ + options: { + administrativeUnitId: administrativeUnitId, + userId: userId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if both role definition id and role definition name are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ + options: { + administrativeUnitId: administrativeUnitId, + roleDefinitionId: roleDefinitionId, + roleDefinitionName: roleDefinitionName, + userId: userId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if both administrative unit id and administrative unit name are not passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ + options: { + roleDefinitionId: roleDefinitionId, + userId: userId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if both administrative unit id and administrative unit name are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ + options: { + administrativeUnitId: administrativeUnitId, + administrativeUnitName: administrativeUnitName, + roleDefinitionId: roleDefinitionId, + userId: userId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if administrative unit id is not a valid GUID', async () => { + const actual = await command.validate({ + options: { + administrativeUnitId: '123', + roleDefinitionId: roleDefinitionId, + userId: userId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if role definition id is not a valid GUID', async () => { + const actual = await command.validate({ + options: { + administrativeUnitId: administrativeUnitId, + roleDefinitionId: '123', + userId: userId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if user id is not a valid GUID', async () => { + const actual = await command.validate({ + options: { + administrativeUnitId: administrativeUnitId, + roleDefinitionId: roleDefinitionId, + userId: '123' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('correctly assigns a role specified by id to and administrative unit specified by id and to a user specified by id', async () => { + sinon.stub(roleAssignment, 'createRoleAssignmentWithAdministrativeUnitScope').withArgs(roleDefinitionId, userId, administrativeUnitId).resolves(unifiedRoleAssignment); + + await command.action(logger, { + options: { + administrativeUnitId: administrativeUnitId, + roleDefinitionId: roleDefinitionId, + userId: userId + } + }); + + assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignment)); + }); + + it('correctly assigns a role specified by name to and administrative unit specified by name and to a user specified by name (verbose)', async () => { + sinon.stub(aadAdministrativeUnit, 'getAdministrativeUnitByDisplayName').withArgs(administrativeUnitName).resolves({ id: administrativeUnitId, displayName: administrativeUnitName }); + sinon.stub(aadUser, 'getUserIdByUpn').withArgs(userName).resolves(userId); + sinon.stub(roleDefinition, 'getRoleDefinitionByDisplayName').withArgs(roleDefinitionName).resolves({ id: roleDefinitionId, displayName: roleDefinitionName }); + sinon.stub(roleAssignment, 'createRoleAssignmentWithAdministrativeUnitScope').withArgs(roleDefinitionId, userId, administrativeUnitId).resolves(unifiedRoleAssignment); + + await command.action(logger, { + options: { + administrativeUnitName: administrativeUnitName, + roleDefinitionName: roleDefinitionName, + userName: userName, + verbose: true + } + }); + assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignment)); + }); + + it('correctly handles error', async () => { + sinon.stub(roleAssignment, 'createRoleAssignmentWithAdministrativeUnitScope').throws(Error('Invalid request')); + + await assert.rejects(command.action(logger, { + options: { + administrativeUnitId: administrativeUnitId, + roleDefinitionId: roleDefinitionId, + userId: userId + } + }), new CommandError('Invalid request')); + }); + + it('fails if an administrative unit specified by name was not found', async () => { + sinon.stub(aadAdministrativeUnit, 'getAdministrativeUnitByDisplayName').withArgs(administrativeUnitName).throws(Error("The specified administrative unit 'Marketing Department' does not exist.")); + sinon.stub(aadUser, 'getUserIdByUpn').withArgs(userName).resolves(userId); + sinon.stub(roleDefinition, 'getRoleDefinitionByDisplayName').withArgs(roleDefinitionName).resolves({ id: roleDefinitionId, displayName: roleDefinitionName }); + sinon.stub(roleAssignment, 'createRoleAssignmentWithAdministrativeUnitScope').withArgs(roleDefinitionId, userId, administrativeUnitId).resolves(unifiedRoleAssignment); + + await assert.rejects(command.action(logger, { + options: { + administrativeUnitName: administrativeUnitName, + roleDefinitionName: roleDefinitionName, + userName: userName + } + }), new CommandError("The specified administrative unit 'Marketing Department' does not exist.")); + }); + + it('fails if a role definition specified by name was not found', async () => { + sinon.stub(aadAdministrativeUnit, 'getAdministrativeUnitByDisplayName').withArgs(administrativeUnitName).resolves({ id: administrativeUnitId, displayName: administrativeUnitName }); + sinon.stub(aadUser, 'getUserIdByUpn').withArgs(userName).resolves(userId); + sinon.stub(roleDefinition, 'getRoleDefinitionByDisplayName').withArgs(roleDefinitionName).throws(Error("The specified role definition 'User Administrator' does not exist.")); + sinon.stub(roleAssignment, 'createRoleAssignmentWithAdministrativeUnitScope').withArgs(roleDefinitionId, userId, administrativeUnitId).resolves(unifiedRoleAssignment); + + await assert.rejects(command.action(logger, { + options: { + administrativeUnitName: administrativeUnitName, + roleDefinitionName: roleDefinitionName, + userName: userName + } + }), new CommandError("The specified role definition 'User Administrator' does not exist.")); + }); + + it('fails if a user specified by UPN was not found', async () => { + sinon.stub(aadAdministrativeUnit, 'getAdministrativeUnitByDisplayName').withArgs(administrativeUnitName).resolves({ id: administrativeUnitId, displayName: administrativeUnitName }); + sinon.stub(aadUser, 'getUserIdByUpn').withArgs(userName).throws(Error("The specified user with user name AdeleVance@contoso.com does not exist.")); + sinon.stub(roleDefinition, 'getRoleDefinitionByDisplayName').withArgs(roleDefinitionName).resolves({ id: roleDefinitionId, displayName: roleDefinitionName }); + sinon.stub(roleAssignment, 'createRoleAssignmentWithAdministrativeUnitScope').withArgs(roleDefinitionId, userId, administrativeUnitId).resolves(unifiedRoleAssignment); + + await assert.rejects(command.action(logger, { + options: { + administrativeUnitName: administrativeUnitName, + roleDefinitionName: roleDefinitionName, + userName: userName + } + }), new CommandError("The specified user with user name AdeleVance@contoso.com does not exist.")); + }); + + it('correctly handles API OData error when creating role assignment with an administrative unit scope failed', async () => { + sinon.stub(aadAdministrativeUnit, 'getAdministrativeUnitByDisplayName').withArgs(administrativeUnitName).resolves({ id: administrativeUnitId, displayName: administrativeUnitName }); + sinon.stub(aadUser, 'getUserIdByUpn').withArgs(userName).resolves(userId); + sinon.stub(roleDefinition, 'getRoleDefinitionByDisplayName').withArgs(roleDefinitionName).resolves({ id: roleDefinitionId, displayName: roleDefinitionName }); + sinon.stub(request, 'post').rejects({ + error: { + 'odata.error': { + code: '-1, InvalidOperationException', + message: { + value: 'Invalid request' + } + } + } + }); + + await assert.rejects(command.action(logger, { + options: { + administrativeUnitName: administrativeUnitName, + roleDefinitionName: roleDefinitionName, + userName: userName + } + }), new CommandError("Invalid request")); + }); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/administrativeunit/administrativeunit-roleassignment-add.ts b/src/m365/entra/commands/administrativeunit/administrativeunit-roleassignment-add.ts new file mode 100644 index 00000000000..d95af45a78e --- /dev/null +++ b/src/m365/entra/commands/administrativeunit/administrativeunit-roleassignment-add.ts @@ -0,0 +1,142 @@ +import GlobalOptions from '../../../../GlobalOptions.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { aadAdministrativeUnit } from '../../../../utils/aadAdministrativeUnit.js'; +import { aadUser } from '../../../../utils/aadUser.js'; +import { roleAssignment } from '../../../../utils/roleAssignment.js'; +import { roleDefinition } from '../../../../utils/roleDefinition.js'; +import { validation } from '../../../../utils/validation.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + administrativeUnitId?: string; + administrativeUnitName?: string; + roleDefinitionId?: string; + roleDefinitionName?: string; + userId?: string; + userName?: string; +} + +class EntraAdministrativeUnitRoleAssignmentAddCommand extends GraphCommand { + public get name(): string { + return commands.ADMINISTRATIVEUNIT_ROLEASSIGNMENT_ADD; + } + + public get description(): string { + return 'Assigns a Microsoft Entra role with administrative unit scope to a user'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + administrativeUnitId: typeof args.options.administrativeUnitId !== 'undefined', + administrativeUnitName: typeof args.options.administrativeUnitName !== 'undefined', + roleDefinitionId: typeof args.options.roleDefinitionId !== 'undefined', + roleDefinitionName: typeof args.options.roleDefinitionName !== 'undefined', + userId: typeof args.options.userId !== 'undefined', + userName: typeof args.options.userName !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-i, --administrativeUnitId [administrativeUnitId]' + }, + { + option: '-n, --administrativeUnitName [administrativeUnitName]' + }, + { + option: '--roleDefinitionId [roleDefinitionId]' + }, + { + option: '--roleDefinitionName [roleDefinitionName]' + }, + { + option: '--userId [userId]' + }, + { + option: '--userName [userName]' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.administrativeUnitId && !validation.isValidGuid(args.options.administrativeUnitId)) { + return `${args.options.administrativeUnitId} is not a valid GUID`; + } + + if (args.options.roleDefinitionId && !validation.isValidGuid(args.options.roleDefinitionId)) { + return `${args.options.roleDefinitionId} is not a valid GUID`; + } + + if (args.options.userId && !validation.isValidGuid(args.options.userId)) { + return `${args.options.userId} is not a valid GUID`; + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['administrativeUnitId', 'administrativeUnitName'] }); + this.optionSets.push({ options: ['roleDefinitionId', 'roleDefinitionName'] }); + this.optionSets.push({ options: ['userId', 'userName'] }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + let { administrativeUnitId, roleDefinitionId, userId } = args.options; + + if (args.options.administrativeUnitName) { + if (this.verbose) { + await logger.logToStderr(`Retrieving administrative unit by its name '${args.options.administrativeUnitName}'`); + } + + administrativeUnitId = (await aadAdministrativeUnit.getAdministrativeUnitByDisplayName(args.options.administrativeUnitName)).id; + } + + if (args.options.roleDefinitionName) { + if (this.verbose) { + await logger.logToStderr(`Retrieving role definition by its name '${args.options.roleDefinitionName}'`); + } + + roleDefinitionId = (await roleDefinition.getRoleDefinitionByDisplayName(args.options.roleDefinitionName)).id; + } + + if (args.options.userName) { + if (this.verbose) { + await logger.logToStderr(`Retrieving user by UPN '${args.options.userName}'`); + } + + userId = await aadUser.getUserIdByUpn(args.options.userName); + } + + const unifiedRoleAssignment = await roleAssignment.createRoleAssignmentWithAdministrativeUnitScope(roleDefinitionId!, userId!, administrativeUnitId!); + + await logger.log(unifiedRoleAssignment); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new EntraAdministrativeUnitRoleAssignmentAddCommand(); \ No newline at end of file diff --git a/src/utils/roleAssignment.spec.ts b/src/utils/roleAssignment.spec.ts new file mode 100644 index 00000000000..96b56ec86e4 --- /dev/null +++ b/src/utils/roleAssignment.spec.ts @@ -0,0 +1,89 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import request from '../request.js'; +import { sinonUtil } from './sinonUtil.js'; +import { roleAssignment } from './roleAssignment.js'; + +describe('utils/roleAssignment', () => { + const roleDefinitionId = 'fe930be7-5e62-47db-91af-98c3a49a38b1'; + const userId = '2056d2f6-3257-4253-8cfc-b73393e414e5'; + const administrativeUnitId = 'fc33aa61-cf0e-46b6-9506-f633347202ab'; + const unifieRoleAssignmentWithAdministrativeUnitScopeResponse = { + "id": "BH21sHQtUEyvox7IA_Eu_mm3jqnUe4lEhvatluHIWb7-1", + "roleDefinitionId": "fe930be7-5e62-47db-91af-98c3a49a38b1", + "principalId": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "directoryScopeId": "/administrativeUnits/fc33aa61-cf0e-46b6-9506-f633347202ab" + }; + const unifieRoleAssignmentWithTenantScopeResponse = { + "id": "BH21sHQtUEyvox7IA_Eu_mm3jqnUe4lEhvatluHIWb7-1", + "roleDefinitionId": "fe930be7-5e62-47db-91af-98c3a49a38b1", + "principalId": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "directoryScopeId": "/" + }; + + afterEach(() => { + sinonUtil.restore([ + request.post + ]); + }); + + it('correctly assigns a directory (Entra ID) role specified by id with administrative unit scope to a user specified by id', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments' && + JSON.stringify(opts.data) === JSON.stringify({ + "roleDefinitionId": roleDefinitionId, + "principalId": userId, + "directoryScopeId": `/administrativeUnits/${administrativeUnitId}` + })) { + return unifieRoleAssignmentWithAdministrativeUnitScopeResponse; + } + + throw 'Invalid request'; + }); + + const unifiedRoleAssignment = await roleAssignment.createRoleAssignmentWithAdministrativeUnitScope(roleDefinitionId, userId, administrativeUnitId); + assert.deepStrictEqual(unifiedRoleAssignment, { + "id": "BH21sHQtUEyvox7IA_Eu_mm3jqnUe4lEhvatluHIWb7-1", + "roleDefinitionId": "fe930be7-5e62-47db-91af-98c3a49a38b1", + "principalId": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "directoryScopeId": "/administrativeUnits/fc33aa61-cf0e-46b6-9506-f633347202ab" + }); + }); + + it('correctly assigns a directory (Entra ID) role specified by id with tenant scope to a user specified by id', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments' && + JSON.stringify(opts.data) === JSON.stringify({ + "roleDefinitionId": roleDefinitionId, + "principalId": userId, + "directoryScopeId": '/' + })) { + return unifieRoleAssignmentWithTenantScopeResponse; + } + + throw 'Invalid request'; + }); + + const unifiedRoleAssignment = await roleAssignment.createRoleAssignmentWithTenantScope(roleDefinitionId, userId); + assert.deepStrictEqual(unifiedRoleAssignment, { + "id": "BH21sHQtUEyvox7IA_Eu_mm3jqnUe4lEhvatluHIWb7-1", + "roleDefinitionId": "fe930be7-5e62-47db-91af-98c3a49a38b1", + "principalId": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "directoryScopeId": "/" + }); + }); + + it('correctly handles random API error when createRoleAssignmentWithAdministrativeUnitScope is called', async () => { + const errorMessage = 'Something went wrong'; + sinon.stub(request, 'post').rejects(new Error(errorMessage)); + + await assert.rejects(roleAssignment.createRoleAssignmentWithAdministrativeUnitScope(roleDefinitionId, userId, administrativeUnitId), new Error(errorMessage)); + }); + + it('correctly handles random API error when createRoleAssignmentWithTenantScope is called', async () => { + const errorMessage = 'Something went wrong'; + sinon.stub(request, 'post').rejects(new Error(errorMessage)); + + await assert.rejects(roleAssignment.createRoleAssignmentWithTenantScope(roleDefinitionId, userId), new Error(errorMessage)); + }); +}); \ No newline at end of file diff --git a/src/utils/roleAssignment.ts b/src/utils/roleAssignment.ts new file mode 100644 index 00000000000..9bb94345683 --- /dev/null +++ b/src/utils/roleAssignment.ts @@ -0,0 +1,45 @@ +import { UnifiedRoleAssignment } from '@microsoft/microsoft-graph-types'; +import request, { CliRequestOptions } from '../request.js'; + +const getRequestOptions = (roleDefinitionId: string, principalId: string, directoryScopeId: string): CliRequestOptions => ({ + url: `https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + roleDefinitionId: roleDefinitionId, + principalId: principalId, + directoryScopeId: directoryScopeId + } +}); + +/** + * Utils for RBAC. + * Supported RBAC providers: + * - Directory (Entra ID) + */ +export const roleAssignment = { + /** + * Assigns a specific role to a principal with scope to an administrative unit + * @param roleDefinitionId Role which lists the actions that can be performed + * @param principalId Object that represents a user, group, service principal, or managed identity that is requesting access to resources + * @param administrativeUnitId Administrative unit which represents a current scope for a role assignment + * @returns Returns unified role assignment object that represents a role definition assigned to a principal with scope to an administrative unit + */ + async createRoleAssignmentWithAdministrativeUnitScope(roleDefinitionId: string, principalId: string, administrativeUnitId: string): Promise { + const requestOptions = getRequestOptions(roleDefinitionId, principalId, `/administrativeUnits/${administrativeUnitId}`); + return await request.post(requestOptions); + }, + + /** + * Assigns a specific role to a principal with scope to the whole tenant + * @param roleDefinitionId Role which lists the actions that can be performed + * @param principalId Object that represents a user, group, service principal, or managed identity that is requesting access to resources + * @returns Returns unified role assignment object that represents a role definition assigned to a principal with scope to the whole tenant + */ + async createRoleAssignmentWithTenantScope(roleDefinitionId: string, principalId: string): Promise { + const requestOptions = getRequestOptions(roleDefinitionId, principalId, '/'); + return await request.post(requestOptions); + } +}; \ No newline at end of file diff --git a/src/utils/roleDefinition.spec.ts b/src/utils/roleDefinition.spec.ts new file mode 100644 index 00000000000..49bb4ddaaa3 --- /dev/null +++ b/src/utils/roleDefinition.spec.ts @@ -0,0 +1,205 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import { cli } from '../cli/cli.js'; +import request from '../request.js'; +import { sinonUtil } from './sinonUtil.js'; +import { roleDefinition } from './roleDefinition.js'; +import { formatting } from './formatting.js'; +import { settingsNames } from '../settingsNames.js'; + +describe('utils/roleDefinition', () => { + const displayName = 'Helpdesk Administrator'; + const invalidDisplayName = 'Helpdeks Administratr'; + const roleDefinitionResponse = { + "id": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "description": "Can reset passwords for non-administrators and Helpdesk Administrators.", + "displayName": "Helpdesk Administrator", + "isBuiltIn": true, + "isEnabled": true, + "templateId": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "version": "1", + "rolePermissions": [ + { + "allowedResourceActions": [ + "microsoft.directory/users/invalidateAllRefreshTokens", + "microsoft.directory/users/bitLockerRecoveryKeys/read", + "microsoft.directory/users/password/update", + "microsoft.azure.serviceHealth/allEntities/allTasks", + "microsoft.azure.supportTickets/allEntities/allTasks", + "microsoft.office365.webPortal/allEntities/standard/read", + "microsoft.office365.serviceHealth/allEntities/allTasks", + "microsoft.office365.supportTickets/allEntities/allTasks" + ], + "condition": null + } + ], + "inheritsPermissionsFrom": [ + { + "id": "88d8e3e3-8f55-4a1e-953a-9b9898b8876b" + } + ] + }; + const customRoleDefinitionResponse = { + "id": "129827e3-9c14-49f7-bb1b-9608f156bbb8", + "description": "Can update passwords for non-administrators and Helpdesk Administrators.", + "displayName": "Helpdesk Administrator", + "isBuiltIn": false, + "isEnabled": true, + "templateId": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "version": "1", + "rolePermissions": [ + { + "allowedResourceActions": [ + "microsoft.directory/users/invalidateAllRefreshTokens", + "microsoft.directory/users/bitLockerRecoveryKeys/read", + "microsoft.directory/users/password/update" + ], + "condition": null + } + ], + "inheritsPermissionsFrom": [ + { + "id": "88d8e3e3-8f55-4a1e-953a-9b9898b8876b" + } + ] + }; + + afterEach(() => { + sinonUtil.restore([ + request.get, + cli.getSettingWithDefaultValue, + cli.handleMultipleResultsFound + ]); + }); + + it('correctly get single role definition by name using getDirectoryRoleDefinitionByDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + roleDefinitionResponse + ] + }; + } + + return 'Invalid Request'; + }); + + const actual = await roleDefinition.getRoleDefinitionByDisplayName(displayName); + assert.deepStrictEqual(actual, { + "id": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "description": "Can reset passwords for non-administrators and Helpdesk Administrators.", + "displayName": "Helpdesk Administrator", + "isBuiltIn": true, + "isEnabled": true, + "templateId": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "version": "1", + "rolePermissions": [ + { + "allowedResourceActions": [ + "microsoft.directory/users/invalidateAllRefreshTokens", + "microsoft.directory/users/bitLockerRecoveryKeys/read", + "microsoft.directory/users/password/update", + "microsoft.azure.serviceHealth/allEntities/allTasks", + "microsoft.azure.supportTickets/allEntities/allTasks", + "microsoft.office365.webPortal/allEntities/standard/read", + "microsoft.office365.serviceHealth/allEntities/allTasks", + "microsoft.office365.supportTickets/allEntities/allTasks" + ], + "condition": null + } + ], + "inheritsPermissionsFrom": [ + { + "id": "88d8e3e3-8f55-4a1e-953a-9b9898b8876b" + } + ] + }); + }); + + it('handles selecting single role definition when multiple role definitions with the specified name found using getDirectoryRoleDefinitionByDisplayName and cli is set to prompt', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + roleDefinitionResponse, + customRoleDefinitionResponse + ] + }; + } + + return 'Invalid Request'; + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves(roleDefinitionResponse); + + const actual = await roleDefinition.getRoleDefinitionByDisplayName(displayName); + assert.deepStrictEqual(actual, { + "id": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "description": "Can reset passwords for non-administrators and Helpdesk Administrators.", + "displayName": "Helpdesk Administrator", + "isBuiltIn": true, + "isEnabled": true, + "templateId": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "version": "1", + "rolePermissions": [ + { + "allowedResourceActions": [ + "microsoft.directory/users/invalidateAllRefreshTokens", + "microsoft.directory/users/bitLockerRecoveryKeys/read", + "microsoft.directory/users/password/update", + "microsoft.azure.serviceHealth/allEntities/allTasks", + "microsoft.azure.supportTickets/allEntities/allTasks", + "microsoft.office365.webPortal/allEntities/standard/read", + "microsoft.office365.serviceHealth/allEntities/allTasks", + "microsoft.office365.supportTickets/allEntities/allTasks" + ], + "condition": null + } + ], + "inheritsPermissionsFrom": [ + { + "id": "88d8e3e3-8f55-4a1e-953a-9b9898b8876b" + } + ] + }); + }); + + it('throws error message when no role definition was found using getDirectoryRoleDefinitionByDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$filter=displayName eq '${formatting.encodeQueryParameter(invalidDisplayName)}'`) { + return { value: [] }; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(roleDefinition.getRoleDefinitionByDisplayName(invalidDisplayName)), Error(`The specified role definition '${invalidDisplayName}' does not exist.`); + }); + + it('throws error message when multiple role definition were found using getDirectoryRoleDefinitionByDisplayName', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + roleDefinitionResponse, + customRoleDefinitionResponse + ] + }; + } + + return 'Invalid Request'; + }); + + await assert.rejects(roleDefinition.getRoleDefinitionByDisplayName(displayName), + Error(`Multiple role definitions with name '${displayName}' found. Found: ${roleDefinitionResponse.id}, ${customRoleDefinitionResponse.id}.`)); + }); +}); \ No newline at end of file diff --git a/src/utils/roleDefinition.ts b/src/utils/roleDefinition.ts new file mode 100644 index 00000000000..2ab75088397 --- /dev/null +++ b/src/utils/roleDefinition.ts @@ -0,0 +1,28 @@ +import { RoleDefinition } from '@microsoft/microsoft-graph-types'; +import { cli } from '../cli/cli.js'; +import { formatting } from './formatting.js'; +import { odata } from './odata.js'; + +export const roleDefinition = { + /** + * Get a directory (Microsoft Entra) role + * @param displayName Role definition display name. + * @returns The role definition. + * @throws Error when role definition was not found. + */ + async getRoleDefinitionByDisplayName(displayName: string): Promise { + const roleDefinitions = await odata.getAllItems(`https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`); + + if (roleDefinitions.length === 0) { + throw `The specified role definition '${displayName}' does not exist.`; + } + + if (roleDefinitions.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('id', roleDefinitions); + const selectedRoleDefinition = await cli.handleMultipleResultsFound(`Multiple role definitions with name '${displayName}' found.`, resultAsKeyValuePair); + return selectedRoleDefinition; + } + + return roleDefinitions[0]; + } +}; \ No newline at end of file