diff --git a/docs/docs/cmd/entra/roledefinition/roledefinition-add.mdx b/docs/docs/cmd/entra/roledefinition/roledefinition-add.mdx new file mode 100644 index 0000000000..5a61d82087 --- /dev/null +++ b/docs/docs/cmd/entra/roledefinition/roledefinition-add.mdx @@ -0,0 +1,127 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# entra roledefinition add + +Creates a custom Microsoft Entra ID role definition + +## Usage + +```sh +m365 entra roledefinition add [options] +``` + +## Options + +```md definition-list +`-n, --displayName ` +: The display name for the role definition. + +`--allowedResourceActions ` +: Comma-separated list of resource actions allowed for the role. + +`--description [description]` +: The description for the role definition. + +`--enabled [enabled]` +: Indicates if the role is enabled for the assignment. If not specified, the role is enabled by default. + +`--version [version]` +: The version of the role definition. +``` + + + +## Examples + +Create a custom Microsoft Entra ID role + +```sh +m365 entra roledefinition add --displayName 'Application Remover' --description 'Allows to remove any Entra ID application' --allowedResourceActions 'microsoft.directory/applications/delete' +``` + +Create a custom Microsoft Entra ID role, but disable it for the assignment + +```sh +m365 entra roledefinition add --displayName 'Application Remover' --version '1.0' --enabled false --allowedResourceActions 'microsoft.directory/applications/delete,microsoft.directory/applications/owners/update' +``` + +## Response + + + + + ```json + { + "id": "3844129d-f748-4c03-8165-4412ee9b4ceb", + "description": null, + "displayName": "Custom Role", + "isBuiltIn": false, + "isEnabled": true, + "resourceScopes": [ + "/" + ], + "templateId": "3844129d-f748-4c03-8165-4412ee9b4ceb", + "version": "1", + "rolePermissions": [ + { + "allowedResourceActions": [ + "microsoft.directory/groups.unified/create", + "microsoft.directory/groups.unified/delete" + ], + "condition": null + } + ] + } + ``` + + + + + ```text + description : null + displayName : Custom Role + id : 3844129d-f748-4c03-8165-4412ee9b4ceb + isBuiltIn : false + isEnabled : true + resourceScopes : ["/"] + rolePermissions: [{"allowedResourceActions":["microsoft.directory/groups.unified/create","microsoft.directory/groups.unified/delete"],"condition":null}] + templateId : 3844129d-f748-4c03-8165-4412ee9b4ceb + version : 1 + ``` + + + + + ```csv + id,description,displayName,isBuiltIn,isEnabled,templateId,version + 3844129d-f748-4c03-8165-4412ee9b4ceb,,Custom Role,0,1,3844129d-f748-4c03-8165-4412ee9b4ceb,1 + ``` + + + + + ```md + # entra roledefinition add --displayName "Custom Role" --allowedResourceActions "microsoft.directory/groups.unified/create,microsoft.directory/groups.unified/delete" --version 1 + + Date: 12/15/2024 + + ## Custom Role (3844129d-f748-4c03-8165-4412ee9b4ceb) + + Property | Value + ---------|------- + id | 3844129d-f748-4c03-8165-4412ee9b4ceb + displayName | Custom Role + isBuiltIn | false + isEnabled | true + templateId | 3844129d-f748-4c03-8165-4412ee9b4ceb + version | 1 + ``` + + + + +## More information + +- https://learn.microsoft.com/graph/api/rbacapplication-post-roledefinitions \ No newline at end of file diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 78006f1998..7faf08cbee 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -629,6 +629,11 @@ const sidebars: SidebarsConfig = { }, { roledefinition: [ + { + type: 'doc', + label: 'roledefinition add', + id: 'cmd/entra/roledefinition/roledefinition-add' + }, { type: 'doc', label: 'roledefinition get', diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts index dc270f50c7..2f7ca15cdf 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_ADD: `${prefix} roledefinition add`, ROLEDEFINITION_LIST: `${prefix} roledefinition list`, ROLEDEFINITION_GET: `${prefix} roledefinition get`, ROLEDEFINITION_REMOVE: `${prefix} roledefinition remove`, diff --git a/src/m365/entra/commands/roledefinition/roledefinition-add.spec.ts b/src/m365/entra/commands/roledefinition/roledefinition-add.spec.ts new file mode 100644 index 0000000000..af4d326a53 --- /dev/null +++ b/src/m365/entra/commands/roledefinition/roledefinition-add.spec.ts @@ -0,0 +1,178 @@ +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-add.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'; + +describe(commands.ROLEDEFINITION_ADD, () => { + const roleDefinitionResponse = { + "id": "e1ede50a-487c-49b3-a43e-cda270d3341f", + "description": null, + "displayName": "Custom Role", + "isBuiltIn": false, + "isEnabled": true, + "resourceScopes": [ + "/" + ], + "templateId": "e1ede50a-487c-49b3-a43e-cda270d3341f", + "version": null, + "rolePermissions": [ + { + "allowedResourceActions": [ + "microsoft.directory/groups.unified/create", + "microsoft.directory/groups.unified/delete" + ], + "condition": null + } + ] + }; + + const roleDefinitionWithDeatilsResponse = { + "id": "abcde50a-487c-49b3-a43e-cda270d3341f", + "description": "Allows creating and deleting unified groups", + "displayName": "Custom Role", + "isBuiltIn": false, + "isEnabled": false, + "resourceScopes": [ + "/" + ], + "templateId": "abcnpm instade50a-487c-49b3-a43e-cda270d3341f", + "version": "1", + "rolePermissions": [ + { + "allowedResourceActions": [ + "microsoft.directory/groups.unified/create", + "microsoft.directory/groups.unified/delete" + ], + "condition": null + } + ] + }; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + 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); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.post, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ROLEDEFINITION_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if displayName is not provided', () => { + const actual = commandOptionsSchema.safeParse({ allowedResourceActions: "microsoft.directory/groups.unified/create"}); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if allowedResourceActions is not provided', () => { + const actual = commandOptionsSchema.safeParse({ displayName: "Custom Role" }); + assert.notStrictEqual(actual.success, true); + }); + + it('creates a custom role definition with a specific display name and resource actions', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions') { + return roleDefinitionResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Custom Role', allowedResourceActions: "microsoft.directory/groups.unified/create,microsoft.directory/groups.unified/delete" } }); + assert(loggerLogSpy.calledOnceWithExactly(roleDefinitionResponse)); + }); + + it('creates a custom role definition with a specific display name, description, version and resource actions', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions') { + return roleDefinitionWithDeatilsResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + displayName: 'Custom Role', + description: 'Allows creating and deleting unified groups', + allowedResourceActions: "microsoft.directory/groups.unified/create,microsoft.directory/groups.unified/delete", + enabled: false, + version: "1", + verbose: true + } + }); + assert(loggerLogSpy.calledOnceWithExactly(roleDefinitionWithDeatilsResponse)); + }); + + it('correctly handles API OData error', async () => { + sinon.stub(request, 'post').rejects({ + error: { + 'odata.error': { + code: '-1, InvalidOperationException', + message: { + value: 'Invalid request' + } + } + } + }); + + await assert.rejects(command.action(logger, { + options: { + displayName: 'Custom Role', + allowedResourceActions: "microsoft.directory/groups.unified/create" + } + }), new CommandError('Invalid request')); + }); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/roledefinition/roledefinition-add.ts b/src/m365/entra/commands/roledefinition/roledefinition-add.ts new file mode 100644 index 0000000000..b385b41d2c --- /dev/null +++ b/src/m365/entra/commands/roledefinition/roledefinition-add.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import { zod } from '../../../../utils/zod.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { UnifiedRoleDefinition } from '@microsoft/microsoft-graph-types'; + +const options = globalOptionsZod + .extend({ + displayName: zod.alias('n', z.string()), + allowedResourceActions: z.string().transform((value) => value.split(',').map(String)), + description: z.string().optional(), + enabled: z.boolean().optional(), + version: z.string().optional() + }) + .strict(); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class EntraRoleDefinitionAddCommand extends GraphCommand { + public get name(): string { + return commands.ROLEDEFINITION_ADD; + } + + public get description(): string { + return 'Creates a custom Microsoft Entra ID role definition'; + } + + public get schema(): z.ZodTypeAny | undefined { + return options; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (args.options.verbose) { + await logger.logToStderr(`Creating custom role definition with name ${args.options.displayName}...`); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/roleManagement/directory/roleDefinitions`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + data: { + displayName: args.options.displayName, + rolePermissions: [ + { + allowedResourceActions: args.options.allowedResourceActions + } + ], + description: args.options.description, + isEnabled: args.options.enabled !== undefined ? args.options.enabled : true, + version: args.options.version + }, + responseType: 'json' + }; + + try { + const result = await request.post(requestOptions); + await logger.log(result); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new EntraRoleDefinitionAddCommand(); \ No newline at end of file