diff --git a/docs/docs/cmd/entra/roledefinition/roledefinition-set.mdx b/docs/docs/cmd/entra/roledefinition/roledefinition-set.mdx new file mode 100644 index 0000000000..a17bebc2e5 --- /dev/null +++ b/docs/docs/cmd/entra/roledefinition/roledefinition-set.mdx @@ -0,0 +1,60 @@ +import Global from '/docs/cmd/_global.mdx'; + +# entra roledefinition set + +Updates a custom Microsoft Entra ID role definition + +## Usage + +```sh +m365 entra roledefinition set [options] +``` + +## Options + +```md definition-list +`-i, --id [id]` +: The id of the role definition to be updated. Specify either `id` or `displayName`, but not both. + +`-n, --displayName [displayName]` +: The display name of the role definition to be updated. Specify either `id` or `displayName`, but not both. + +`--newDisplayName [newDisplayName]` +: Updated display name for the role definition. + +`-d, --description [description]` +: Updated description for the role definition. + +`-e, --enabled [enabled]` +: Indicates if the role is enabled for the assignment. + +`a-, --allowedResourceActions [allowedResourceActions]` +: Updated comma-separated list of resource actions allowed for the role. + +`-v, --version [version]` +: Updated version of the role definition. +``` + + + +## Examples + +Update a custom Microsoft Entra ID role specified by the id + +```sh +m365 entra roledefinition set --id fadbc488-151d-4431-9143-6abbffae759f --newDisplayName 'Application Remover' --description 'Allows to remove any Entra ID application' --allowedResourceActions 'microsoft.directory/applications/delete' +``` + +Update a custom Microsoft Entra ID role specified by the display name + +```sh +m365 entra roledefinition set --displayName 'Application Remover' --version '1.0' --enabled true --allowedResourceActions 'microsoft.directory/applications/delete,microsoft.directory/applications/owners/update' +``` + +## Response + +The command won't return a response on success + +## 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 0d17dbbf22..ead561006e 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -653,6 +653,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'roledefinition remove', id: 'cmd/entra/roledefinition/roledefinition-remove' + }, + { + type: 'doc', + label: 'roledefinition set', + id: 'cmd/entra/roledefinition/roledefinition-set' } ] }, diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts index f821e6f049..15d3156488 100644 --- a/src/m365/entra/commands.ts +++ b/src/m365/entra/commands.ts @@ -93,6 +93,7 @@ export default { ROLEDEFINITION_LIST: `${prefix} roledefinition list`, ROLEDEFINITION_GET: `${prefix} roledefinition get`, ROLEDEFINITION_REMOVE: `${prefix} roledefinition remove`, + ROLEDEFINITION_SET: `${prefix} roledefinition set`, SITECLASSIFICATION_DISABLE: `${prefix} siteclassification disable`, SITECLASSIFICATION_ENABLE: `${prefix} siteclassification enable`, SITECLASSIFICATION_GET: `${prefix} siteclassification get`, diff --git a/src/m365/entra/commands/roledefinition/roledefinition-set.spec.ts b/src/m365/entra/commands/roledefinition/roledefinition-set.spec.ts new file mode 100644 index 0000000000..76f26d45b3 --- /dev/null +++ b/src/m365/entra/commands/roledefinition/roledefinition-set.spec.ts @@ -0,0 +1,156 @@ +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-set.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_SET, () => { + const roleId = 'abcd1234-de71-4623-b4af-96380a352509'; + const roleDisplayName = 'Custom Role'; + + let log: string[]; + let logger: Logger; + 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); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.patch + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ROLEDEFINITION_SET); + }); + + 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('fails validation if neither newDisplayName, description, allowedResourceActions, enabled nor version is provided', () => { + const actual = commandOptionsSchema.safeParse({ id: roleId }); + assert.notStrictEqual(actual.success, true); + }); + + it('updates a custom role definition specified by id', async () => { + const patchRequestStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions/${roleId}`) { + return; + } + + throw 'Invalid request'; + }); + + const parsedSchema = commandOptionsSchema.safeParse({ id: roleId, allowedResourceActions: "microsoft.directory/groups.unified/create,microsoft.directory/groups.unified/delete" }); + await command.action(logger, { options: parsedSchema.data }); + assert(patchRequestStub.called); + }); + + it('updates a custom role definition specified by displayName', async () => { + sinon.stub(roleDefinition, 'getRoleDefinitionByDisplayName').resolves({ id: roleId }); + + const patchRequestStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions/${roleId}`) { + return; + } + + throw 'Invalid request'; + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + displayName: roleDisplayName, + newDisplayName: 'Custom Role Test', + description: 'Allows creating and deleting unified groups', + allowedResourceActions: "microsoft.directory/groups.unified/create,microsoft.directory/groups.unified/delete", + enabled: false, + version: "2", + verbose: true + }); + await command.action(logger, { + options: parsedSchema.data + }); + assert(patchRequestStub.called); + }); + + it('correctly handles API OData error', async () => { + sinon.stub(request, 'patch').rejects({ + error: { + 'odata.error': { + code: '-1, InvalidOperationException', + message: { + value: 'Invalid request' + } + } + } + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + displayName: 'Custom Role', + allowedResourceActions: "microsoft.directory/groups.unified/create" + }); + await assert.rejects(command.action(logger, { + options: parsedSchema.data + }), new CommandError('Invalid request')); + }); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/roledefinition/roledefinition-set.ts b/src/m365/entra/commands/roledefinition/roledefinition-set.ts new file mode 100644 index 0000000000..f0c8df0af7 --- /dev/null +++ b/src/m365/entra/commands/roledefinition/roledefinition-set.ts @@ -0,0 +1,104 @@ +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 { roleDefinition } from '../../../../utils/roleDefinition.js'; +import { validation } from '../../../../utils/validation.js'; +import { UnifiedRoleDefinition } from '@microsoft/microsoft-graph-types'; + +const options = globalOptionsZod + .extend({ + id: zod.alias('i', z.string().optional()), + displayName: zod.alias('n', z.string().optional()), + newDisplayName: z.string().optional(), + allowedResourceActions: zod.alias('a', z.string().optional()), + description: zod.alias('d', z.string().optional()), + enabled: zod.alias('e', z.boolean().optional()), + version: zod.alias('v', z.string().optional()) + }) + .strict(); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class EntraRoleDefinitionSetCommand extends GraphCommand { + public get name(): string { + return commands.ROLEDEFINITION_SET; + } + + public get description(): string { + return 'Updates a custom 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'] + })) + .refine(options => Object.values([options.newDisplayName, options.description, options.allowedResourceActions, options.enabled, options.version]).filter(v => typeof v !== 'undefined').length > 0, { + message: 'Provide value for at least one of the following parameters: newDisplayName, description, allowedResourceActions, enabled or version' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): 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(`Updating custom role definition with ID ${roleDefinitionId}...`); + } + + const data: UnifiedRoleDefinition = { + displayName: args.options.newDisplayName, + description: args.options.description, + isEnabled: args.options.enabled, + version: args.options.version + }; + + if (args.options.allowedResourceActions) { + data['rolePermissions'] = [ + { + allowedResourceActions: args.options.allowedResourceActions.split(',') + } + ]; + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/roleManagement/directory/roleDefinitions/${roleDefinitionId}`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + data: data, + responseType: 'json' + }; + + await request.patch(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new EntraRoleDefinitionSetCommand(); \ No newline at end of file