Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New command: entra roledefinition set. Closes #6467 #6522

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

`--description [description]`
: Updated description for the role definition.

`-e, --enabled [enabled]`
: Indicates if the role is enabled for the assignment.

`--allowedResourceActions [allowedResourceActions]`
: Updated comma-separated list of resource actions allowed for the role.

`--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 add --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 add --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 @@ -643,6 +643,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 @@ -91,6 +91,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
153 changes: 153 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,153 @@
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';
});

await command.action(logger, { options: { id: roleId, allowedResourceActions: "microsoft.directory/groups.unified/create,microsoft.directory/groups.unified/delete" } });
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';
});

await command.action(logger, {
options: {
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
}
});
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'
}
}
}
});

await assert.rejects(command.action(logger, {
options: {
displayName: 'Custom Role',
allowedResourceActions: "microsoft.directory/groups.unified/create"
}
}), 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: z.string().optional(),
description: z.string().optional(),
enabled: z.boolean().optional(),
version: 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();
Loading