From a53438f652b2a8eb78c3b5dcb04ad20c721bb0d1 Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Mon, 18 Nov 2024 14:26:40 +0100 Subject: [PATCH] New command: entra roledefinition remove --- .eslintrc.cjs | 1 + .../roledefinition/roledefinition-remove.mdx | 52 ++++++ docs/src/config/sidebars.ts | 9 + src/m365/entra/commands.ts | 1 + .../roledefinition-remove.spec.ts | 166 ++++++++++++++++++ .../roledefinition/roledefinition-remove.ts | 93 ++++++++++ src/utils/roleDefinition.spec.ts | 111 +++++++++++- src/utils/roleDefinition.ts | 40 ++++- 8 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 docs/docs/cmd/entra/roledefinition/roledefinition-remove.mdx create mode 100644 src/m365/entra/commands/roledefinition/roledefinition-remove.spec.ts create mode 100644 src/m365/entra/commands/roledefinition/roledefinition-remove.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6a4f107a168..931de61f390 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -32,6 +32,7 @@ const dictionary = [ 'customizer', 'dataverse', 'default', + 'definition', 'dev', 'details', 'eligibility', diff --git a/docs/docs/cmd/entra/roledefinition/roledefinition-remove.mdx b/docs/docs/cmd/entra/roledefinition/roledefinition-remove.mdx new file mode 100644 index 00000000000..2f1fe920377 --- /dev/null +++ b/docs/docs/cmd/entra/roledefinition/roledefinition-remove.mdx @@ -0,0 +1,52 @@ +import Global from '/docs/cmd/_global.mdx'; + +# entra roledefinition remove + +Removes a custom Microsoft Entra role definition + +## Usage + +```sh +m365 entra roledefinition remove [options] +``` + +## Options + +```md definition-list +`-i, --id [id]` +: The id of the role definition. Specify either `id` or `displayName`, but not both. + +`-n, --displayName [displayName]` +: The name of the role definition. Specify either `id` or `displayName`, but not both. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Remarks + +:::info + +When the role definition is removed, all the associated role assignments are deleted. + +::: + +## Examples + +Remove a role definition specified by id without prompting + +```sh +m365 entra roledefinition remove --id 0bed8b86-5026-4a93-ac7d-56750cc099f1 --force +``` + +Remove a role definition specified by name and prompt for confirmation + +```sh +m365 entra roledefinition remove --displayName 'Custom Role' +``` + +## Response + +The command won't return a response on success \ No newline at end of file diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index dd119e7f527..e8a2c530fac 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -627,6 +627,15 @@ const sidebars: SidebarsConfig = { } ] }, + { + roledefinition: [ + { + type: 'doc', + label: 'roledefinition remove', + id: 'cmd/entra/roledefinition/roledefinition-remove' + } + ] + }, { siteclassification: [ { diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts index 4b51b5d02e4..4182bbcfb52 100644 --- a/src/m365/entra/commands.ts +++ b/src/m365/entra/commands.ts @@ -88,6 +88,7 @@ export default { PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST: `${prefix} pim role assignment eligibility list`, PIM_ROLE_REQUEST_LIST: `${prefix} pim role request list`, POLICY_LIST: `${prefix} policy list`, + ROLEDEFINITION_REMOVE: `${prefix} roledefinition remove`, SITECLASSIFICATION_DISABLE: `${prefix} siteclassification disable`, SITECLASSIFICATION_ENABLE: `${prefix} siteclassification enable`, SITECLASSIFICATION_GET: `${prefix} siteclassification get`, diff --git a/src/m365/entra/commands/roledefinition/roledefinition-remove.spec.ts b/src/m365/entra/commands/roledefinition/roledefinition-remove.spec.ts new file mode 100644 index 00000000000..608a789c493 --- /dev/null +++ b/src/m365/entra/commands/roledefinition/roledefinition-remove.spec.ts @@ -0,0 +1,166 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import commands from '../../commands.js'; +import request from '../../../../request.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import command from './roledefinition-remove.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { CommandError } from '../../../../Command.js'; +import { z } from 'zod'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { cli } from '../../../../cli/cli.js'; +import { roleDefinition } from '../../../../utils/roleDefinition.js'; + +describe(commands.ROLEDEFINITION_REMOVE, () => { + const roleId = 'abcd1234-de71-4623-b4af-96380a352509'; + const roleDisplayName = 'Bitlocker Keys Reader'; + + let log: string[]; + let logger: Logger; + let promptIssued: boolean; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + sinon.stub(cli, 'promptForConfirmation').callsFake(() => { + promptIssued = true; + return Promise.resolve(false); + }); + + promptIssued = false; + }); + + afterEach(() => { + sinonUtil.restore([ + request.get, + request.delete, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ROLEDEFINITION_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if id is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + id: 'foo' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if both id and displayName are provided', () => { + const actual = commandOptionsSchema.safeParse({ + id: roleId, + displayName: roleDisplayName + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if neither id nor displayName is provided', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); + }); + + it('prompts before removing the role definition when confirm option not passed', async () => { + await command.action(logger, { options: { id: roleId } }); + + assert(promptIssued); + }); + + it('aborts removing the role definition when prompt not confirmed', async () => { + const deleteSpy = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { options: { id: roleId } }); + assert(deleteSpy.notCalled); + }); + + it('removes the role definition specified by id without prompting for confirmation', async () => { + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions/${roleId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: roleId, force: true, verbose: true } }); + assert(deleteRequestStub.called); + }); + + it('removes the role definition specified by displayName while prompting for confirmation', async () => { + sinon.stub(roleDefinition, 'getRoleDefinitionByDisplayName').resolves({ id: roleId }); + + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions/${roleId}`) { + return; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: { displayName: roleDisplayName } }); + assert(deleteRequestStub.called); + }); + + it('handles error when role definition specified by id was not found', async () => { + sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions/${roleId}`) { + throw { + error: + { + code: 'Request_ResourceNotFound', + message: `Resource '${roleId}' does not exist or one of its queried reference-property objects are not present.` + } + }; + } + throw `Invalid request`; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await assert.rejects( + command.action(logger, { options: { id: roleId } }), + new CommandError(`Resource '${roleId}' does not exist or one of its queried reference-property objects are not present.`) + ); + }); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/roledefinition/roledefinition-remove.ts b/src/m365/entra/commands/roledefinition/roledefinition-remove.ts new file mode 100644 index 00000000000..ed79eb4a228 --- /dev/null +++ b/src/m365/entra/commands/roledefinition/roledefinition-remove.ts @@ -0,0 +1,93 @@ +import { Logger } from '../../../../cli/Logger.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import { zod } from '../../../../utils/zod.js'; +import { roleDefinition } from '../../../../utils/roleDefinition.js'; +import { validation } from '../../../../utils/validation.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { cli } from '../../../../cli/cli.js'; + +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().optional()), + displayName: zod.alias('n', z.string().optional()), + force: zod.alias('f', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class EntraRoleDefinitionRemoveCommand extends GraphCommand { + public get name(): string { + return commands.ROLEDEFINITION_REMOVE; + } + + public get description(): string { + return 'Removes a specific Microsoft Entra ID role definition'; + } + + public get schema(): z.ZodTypeAny | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => !options.id !== !options.displayName, { + message: 'Specify either id or displayName, but not both' + }) + .refine(options => options.id || options.displayName, { + message: 'Specify either id or displayName' + }) + .refine(options => (!options.id && !options.displayName) || options.displayName || (options.id && validation.isValidGuid(options.id)), options => ({ + message: `The '${options.id}' must be a valid GUID`, + path: ['id'] + })); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const removeRoleDefinition = async (): Promise => { + try { + let roleDefinitionId = args.options.id; + + if (args.options.displayName) { + roleDefinitionId = (await roleDefinition.getRoleDefinitionByDisplayName(args.options.displayName, 'id')).id; + } + + if (args.options.verbose) { + await logger.logToStderr(`Removing role definition with ID ${roleDefinitionId}...`); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/roleManagement/directory/roleDefinitions/${roleDefinitionId}`, + headers: { + accept: 'application/json;odata.metadata=none' + } + }; + + await request.delete(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await removeRoleDefinition(); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove role definition '${args.options.id || args.options.displayName}'?` }); + + if (result) { + await removeRoleDefinition(); + } + } + } +} + +export default new EntraRoleDefinitionRemoveCommand(); \ No newline at end of file diff --git a/src/utils/roleDefinition.spec.ts b/src/utils/roleDefinition.spec.ts index 49bb4ddaaa3..29f29f1dade 100644 --- a/src/utils/roleDefinition.spec.ts +++ b/src/utils/roleDefinition.spec.ts @@ -8,6 +8,7 @@ import { formatting } from './formatting.js'; import { settingsNames } from '../settingsNames.js'; describe('utils/roleDefinition', () => { + const id = '729827e3-9c14-49f7-bb1b-9608f156bbb8'; const displayName = 'Helpdesk Administrator'; const invalidDisplayName = 'Helpdeks Administratr'; const roleDefinitionResponse = { @@ -39,6 +40,10 @@ describe('utils/roleDefinition', () => { } ] }; + const roleDefinitionLimitedResponse = { + "id": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "displayName": "Helpdesk Administrator" + }; const customRoleDefinitionResponse = { "id": "129827e3-9c14-49f7-bb1b-9608f156bbb8", "description": "Can update passwords for non-administrators and Helpdesk Administrators.", @@ -82,7 +87,7 @@ describe('utils/roleDefinition', () => { }; } - return 'Invalid Request'; + throw 'Invalid Request'; }); const actual = await roleDefinition.getRoleDefinitionByDisplayName(displayName); @@ -117,6 +122,26 @@ describe('utils/roleDefinition', () => { }); }); + it('correctly get single role definition by name using getDirectoryRoleDefinitionByDisplayName with specified properties', 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)}'&$select=id,displayName`) { + return { + value: [ + roleDefinitionLimitedResponse + ] + }; + } + + throw 'Invalid Request'; + }); + + const actual = await roleDefinition.getRoleDefinitionByDisplayName(displayName, 'id,displayName'); + assert.deepStrictEqual(actual, { + "id": "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "displayName": "Helpdesk Administrator" + }); + }); + 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)}'`) { @@ -128,7 +153,7 @@ describe('utils/roleDefinition', () => { }; } - return 'Invalid Request'; + throw 'Invalid Request'; }); sinon.stub(cli, 'handleMultipleResultsFound').resolves(roleDefinitionResponse); @@ -202,4 +227,86 @@ describe('utils/roleDefinition', () => { await assert.rejects(roleDefinition.getRoleDefinitionByDisplayName(displayName), Error(`Multiple role definitions with name '${displayName}' found. Found: ${roleDefinitionResponse.id}, ${customRoleDefinitionResponse.id}.`)); }); + + it('correctly get single role definition by name using getRoleDefinitionById', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions/${id}`) { + return roleDefinitionResponse; + } + + throw 'Invalid Request'; + }); + + const actual = await roleDefinition.getRoleDefinitionById(id); + 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('correctly get single role definition by name using getRoleDefinitionById with specified properties', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions/${id}?$select=id,displayName`) { + return roleDefinitionResponse; + } + + throw 'Invalid Request'; + }); + + const actual = await roleDefinition.getRoleDefinitionById(id, 'id,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" + } + ] + }); + }); }); \ No newline at end of file diff --git a/src/utils/roleDefinition.ts b/src/utils/roleDefinition.ts index 2ab75088397..be6ba66183a 100644 --- a/src/utils/roleDefinition.ts +++ b/src/utils/roleDefinition.ts @@ -1,17 +1,23 @@ -import { RoleDefinition } from '@microsoft/microsoft-graph-types'; +import { UnifiedRoleDefinition } from '@microsoft/microsoft-graph-types'; import { cli } from '../cli/cli.js'; import { formatting } from './formatting.js'; import { odata } from './odata.js'; +import request, { CliRequestOptions } from '../request.js'; export const roleDefinition = { /** - * Get a directory (Microsoft Entra) role + * Get an Entra ID (directory) role by it's name * @param displayName Role definition display name. + * @param properties Comma-separated list of properties to include in the response. * @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)}'`); + async getRoleDefinitionByDisplayName(displayName: string, properties: string = ''): Promise { + let url = `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`; + if (properties !== '') { + url += `&$select=${properties}`; + } + const roleDefinitions = await odata.getAllItems(url); if (roleDefinitions.length === 0) { throw `The specified role definition '${displayName}' does not exist.`; @@ -19,10 +25,34 @@ export const roleDefinition = { if (roleDefinitions.length > 1) { const resultAsKeyValuePair = formatting.convertArrayToHashTable('id', roleDefinitions); - const selectedRoleDefinition = await cli.handleMultipleResultsFound(`Multiple role definitions with name '${displayName}' found.`, resultAsKeyValuePair); + const selectedRoleDefinition = await cli.handleMultipleResultsFound(`Multiple role definitions with name '${displayName}' found.`, resultAsKeyValuePair); return selectedRoleDefinition; } return roleDefinitions[0]; + }, + + /** + * Get an Entra ID (directory) role by it's id + * @param id Role definition id. + * @param properties Comma-separated list of properties to include in the response. + * @returns The role definition. + * @throws Error when role definition was not found. + */ + async getRoleDefinitionById(id: string, properties: string = ''): Promise { + let url = `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions/${id}`; + if (properties !== '') { + url += `?$select=${properties}`; + } + + const requestOptions: CliRequestOptions = { + url: url, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + return await request.get(requestOptions); } }; \ No newline at end of file