Skip to content

Commit

Permalink
Adds command 'entra roledefinition set'. Closes #6467
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinM85 authored and martinlingstuyl committed Jan 15, 2025
1 parent 81cfb8c commit 60298b1
Show file tree
Hide file tree
Showing 5 changed files with 326 additions and 0 deletions.
60 changes: 60 additions & 0 deletions docs/docs/cmd/entra/roledefinition/roledefinition-set.mdx
Original file line number Diff line number Diff line change
@@ -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.
```

<Global />

## 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
5 changes: 5 additions & 0 deletions docs/src/config/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
]
},
Expand Down
1 change: 1 addition & 0 deletions src/m365/entra/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
156 changes: 156 additions & 0 deletions src/m365/entra/commands/roledefinition/roledefinition-set.spec.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
104 changes: 104 additions & 0 deletions src/m365/entra/commands/roledefinition/roledefinition-set.ts
Original file line number Diff line number Diff line change
@@ -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<typeof options>;

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<any> | 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<void> {
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();

0 comments on commit 60298b1

Please sign in to comment.