From b9ffea9f14be16fd82a8df92b6b1e5b410c7d0a4 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 28 May 2026 15:06:04 +0200 Subject: [PATCH] Migrate entra pim and policy commands to Zod Migrates 6 commands from the old manual options/validators/optionSets pattern to the new Zod schema-based validation: - pim-role-assignment-add - pim-role-assignment-list - pim-role-assignment-remove - pim-role-assignment-eligibility-list - pim-role-request-list - policy-list For pim-role-assignment-add, the --no-expiration option is now handled via yargs boolean negation: expiration defaults to true, and --no-expiration sets it to false. Closes #7299 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pim/pim-role-assignment-add.spec.ts | 186 +++++++------ .../commands/pim/pim-role-assignment-add.ts | 224 +++++----------- ...m-role-assignment-eligibility-list.spec.ts | 42 +-- .../pim-role-assignment-eligibility-list.ts | 104 +++----- .../pim/pim-role-assignment-list.spec.ts | 48 ++-- .../commands/pim/pim-role-assignment-list.ts | 106 +++----- .../pim/pim-role-assignment-remove.spec.ts | 125 ++++----- .../pim/pim-role-assignment-remove.ts | 170 ++++-------- .../pim/pim-role-request-list.spec.ts | 66 ++--- .../commands/pim/pim-role-request-list.ts | 130 +++------ .../entra/commands/policy/policy-list.spec.ts | 246 +++++++----------- src/m365/entra/commands/policy/policy-list.ts | 86 ++---- 12 files changed, 546 insertions(+), 987 deletions(-) diff --git a/src/m365/entra/commands/pim/pim-role-assignment-add.spec.ts b/src/m365/entra/commands/pim/pim-role-assignment-add.spec.ts index b5e3bc4dd86..c431d0bfdb3 100644 --- a/src/m365/entra/commands/pim/pim-role-assignment-add.spec.ts +++ b/src/m365/entra/commands/pim/pim-role-assignment-add.spec.ts @@ -10,7 +10,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; -import command from './pim-role-assignment-add.js'; +import command, { options } from './pim-role-assignment-add.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { accessToken } from '../../../../utils/accessToken.js'; @@ -180,6 +180,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -188,6 +189,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -234,86 +236,88 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { }); it('passes validation when roleDefinitionId is a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId }); + assert.strictEqual(actual.success, true); }); it('passes validation when userId is a valid GUID', async () => { - const actual = await command.validate({ options: { userId: userId, roleDefinitionName: 'Global Administrator' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: userId, roleDefinitionName: 'Global Administrator' }); + assert.strictEqual(actual.success, true); }); it('passes validation when groupId is a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: groupId, roleDefinitionName: 'Global Administrator' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: groupId, roleDefinitionName: 'Global Administrator' }); + assert.strictEqual(actual.success, true); }); it('passes validation when startDateTime is a valid ISO 8601 date', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, startDateTime: '2024-02-20T08:00:00Z' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, startDateTime: '2024-02-20T08:00:00Z' }); + assert.strictEqual(actual.success, true); }); it('passes validation when endDateTime is a valid ISO 8601 date', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, endDateTime: '2024-02-20T08:00:00Z' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, endDateTime: '2024-02-20T08:00:00Z' }); + assert.strictEqual(actual.success, true); }); it('passes validation when duration is a valid ISO 8601 duration', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, duration: 'P3Y6M4DT12H30M5S' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, duration: 'P3Y6M4DT12H30M5S' }); + assert.strictEqual(actual.success, true); }); it('passes validation when administrativeUnitId is a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, administrativeUnitId: '81bb36e4-f4c6-4984-8e56-d4f8feae9e09' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, administrativeUnitId: '81bb36e4-f4c6-4984-8e56-d4f8feae9e09' }); + assert.strictEqual(actual.success, true); }); it('passes validation when applicationId is a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, applicationId: '94446d35-4df6-45da-a17f-c601310a8342' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, applicationId: '94446d35-4df6-45da-a17f-c601310a8342' }); + assert.strictEqual(actual.success, true); }); it('fails validation when roleDefinitionId is not a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when userId is not a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'foo', roleDefinitionName: 'Global Administrator' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: 'foo', roleDefinitionName: 'Global Administrator' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when groupId is not a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: 'foo', roleDefinitionName: 'Global Administrator' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: 'foo', roleDefinitionName: 'Global Administrator' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when startDateTime is not a valid ISO 8601 date', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, startDateTime: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, startDateTime: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when endDateTime is not a valid ISO 8601 date', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, endDateTime: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, endDateTime: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when duration is not a valid ISO 8601 duration', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, duration: 'PY6M4DT12H30M5S' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, duration: 'PY6M4DT12H30M5S' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when administrativeUnitId is not a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, administrativeUnitId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, administrativeUnitId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when applicationId is not a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, applicationId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, applicationId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('correctly requests activation of role specified by id for a user specified by id with default expiration and with tenant-wide scope', async () => { + auth.connection.accessTokens[auth.defaultResource] = { expiresOn: '', accessToken: '' }; + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleRequests' && JSON.stringify(opts.data) === JSON.stringify({ @@ -337,18 +341,17 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: - { - roleDefinitionId: roleDefinitionId, - userId: userId, - justification: 'Need SharePoint Administrator role' - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionId: roleDefinitionId, + userId: userId, + justification: 'Need SharePoint Administrator role' + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseNoExpiration)); }); it('correctly requests activation of role specified by id for a user specified by id with no expiration and with tenant-wide scope', async () => { + auth.connection.accessTokens[auth.defaultResource] = { expiresOn: '', accessToken: '' }; + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleRequests' && JSON.stringify(opts.data) === JSON.stringify({ @@ -371,19 +374,18 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: - { - roleDefinitionId: roleDefinitionId, - userId: userId, - justification: 'Need SharePoint Administrator role', - noExpiration: true - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionId: roleDefinitionId, + userId: userId, + justification: 'Need SharePoint Administrator role', + expiration: false + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseNoExpiration)); }); it('correctly requests activation of role specified by name for a user specified by name with limited duration and with administrative unit scope', async () => { + auth.connection.accessTokens[auth.defaultResource] = { expiresOn: '', accessToken: '' }; + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); sinon.stub(roleDefinition, 'getRoleDefinitionByDisplayName').withArgs(roleDefinitionName).resolves({ id: roleDefinitionId, displayName: roleDefinitionName }); sinon.stub(entraUser, 'getUserIdByUpn').withArgs(userName).resolves(userId); @@ -411,22 +413,21 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: - { - roleDefinitionName: roleDefinitionName, - userName: userName, - administrativeUnitId: '81bb36e4-f4c6-4984-8e56-d4f8feae9e09', - startDateTime: '2024-02-12T08:00:00Z', - duration: 'PT4H', - justification: 'Need SharePoint Administrator role for admin unit for half day', - verbose: true - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionName: roleDefinitionName, + userName: userName, + administrativeUnitId: '81bb36e4-f4c6-4984-8e56-d4f8feae9e09', + startDateTime: '2024-02-12T08:00:00Z', + duration: 'PT4H', + justification: 'Need SharePoint Administrator role for admin unit for half day', + verbose: true + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseAfterDuration)); }); it('correctly requests activation of role specified by id for a group specified by id with expiration after specified date with application scope', async () => { + auth.connection.accessTokens[auth.defaultResource] = { expiresOn: '', accessToken: '' }; + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleRequests' && JSON.stringify(opts.data) === JSON.stringify({ @@ -450,20 +451,19 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: - { - roleDefinitionId: roleDefinitionId, - groupId: groupId, - applicationId: '94446d35-4df6-45da-a17f-c601310a8342', - endDateTime: '2024-02-12T12:00:00Z', - justification: 'Need Application Administrator role for group for two days' - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionId: roleDefinitionId, + groupId: groupId, + applicationId: '94446d35-4df6-45da-a17f-c601310a8342', + endDateTime: '2024-02-12T12:00:00Z', + justification: 'Need Application Administrator role for group for two days' + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseAfterDateTime)); }); it('correctly requests activation of role specified by id for a group specified by name with ticket details', async () => { + auth.connection.accessTokens[auth.defaultResource] = { expiresOn: '', accessToken: '' }; + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); sinon.stub(entraGroup, 'getGroupIdByDisplayName').withArgs(groupName).resolves(groupId); sinon.stub(request, 'post').callsFake(async (opts) => { @@ -490,18 +490,15 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: - { - roleDefinitionId: roleDefinitionId, - groupName: groupName, - justification: 'Need User Administrator role for group, ticket details included', - ticketSystem: 'JIRA', - ticketNumber: 'MSFT-2024', - noExpiration: true, - verbose: true - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionId: roleDefinitionId, + groupName: groupName, + justification: 'Need User Administrator role for group, ticket details included', + ticketSystem: 'JIRA', + ticketNumber: 'MSFT-2024', + expiration: false, + verbose: true + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseWithTicketInfo)); }); @@ -535,15 +532,12 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { throw opts.data; }); - await command.action(logger, { - options: - { - roleDefinitionId: roleDefinitionId, - justification: 'Need SharePoint Administrator role', - noExpiration: true, - verbose: true - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionId: roleDefinitionId, + justification: 'Need SharePoint Administrator role', + expiration: false, + verbose: true + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseNoExpiration)); }); @@ -554,7 +548,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { }; sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); - await assert.rejects(command.action(logger, { options: { roleDefinitionId: roleDefinitionId, verbose: true } }), new CommandError('When running with application permissions either userId, userName, groupId or groupName is required')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, verbose: true }).data! }), new CommandError('When running with application permissions either userId, userName, groupId or groupName is required')); }); it('throws an error during self activation when role assignment does not exist', async () => { @@ -597,7 +591,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { roleDefinitionId: roleDefinitionId, justification: 'Need SharePoint Administrator role', noExpiration: true } }), new CommandError(error.error.message)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, justification: 'Need SharePoint Administrator role', expiration: false }).data! }), new CommandError(error.error.message)); }); it('throws an error during admin assignment when role assignment already exists', async () => { @@ -634,6 +628,6 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { roleDefinitionId: roleDefinitionId, userId: userId, justification: 'Need SharePoint Administrator role', noExpiration: true } }), new CommandError(error.error.message)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, userId: userId, justification: 'Need SharePoint Administrator role', expiration: false }).data! }), new CommandError(error.error.message)); }); }); \ No newline at end of file diff --git a/src/m365/entra/commands/pim/pim-role-assignment-add.ts b/src/m365/entra/commands/pim/pim-role-assignment-add.ts index 14179d05b34..808529f552d 100644 --- a/src/m365/entra/commands/pim/pim-role-assignment-add.ts +++ b/src/m365/entra/commands/pim/pim-role-assignment-add.ts @@ -1,5 +1,6 @@ import { UnifiedRoleAssignmentScheduleRequest } from '@microsoft/microsoft-graph-types'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; @@ -11,28 +12,37 @@ import { entraGroup } from '../../../../utils/entraGroup.js'; import { accessToken } from '../../../../utils/accessToken.js'; import auth from '../../../../Auth.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + roleDefinitionName: z.string().optional().alias('n'), + roleDefinitionId: z.uuid().optional().alias('i'), + userId: z.uuid().optional(), + userName: z.string().optional(), + groupId: z.uuid().optional(), + groupName: z.string().optional(), + administrativeUnitId: z.uuid().optional(), + applicationId: z.uuid().optional(), + justification: z.string().optional().alias('j'), + startDateTime: z.string().refine(date => validation.isValidISODateTime(date), { + error: e => `'${e.input}' is not a valid ISO 8601 date time string.` + }).optional().alias('s'), + endDateTime: z.string().refine(date => validation.isValidISODateTime(date), { + error: e => `'${e.input}' is not a valid ISO 8601 date time string.` + }).optional().alias('e'), + duration: z.string().refine(dur => validation.isValidISODuration(dur), { + error: e => `'${e.input}' is not a valid ISO 8601 duration.` + }).optional().alias('d'), + ticketNumber: z.string().optional(), + ticketSystem: z.string().optional(), + expiration: z.boolean().default(true) +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - roleDefinitionName?: string; - roleDefinitionId?: string; - userId?: string; - userName?: string; - groupId?: string; - groupName?: string; - administrativeUnitId?: string; - applicationId?: string; - justification?: string; - startDateTime?: string; - endDateTime?: string; - duration?: string; - ticketNumber?: string; - ticketSystem?: string; - noExpiration?: boolean; -} - class EntraPimRoleAssignmentAddCommand extends GraphCommand { public get name(): string { return commands.PIM_ROLE_ASSIGNMENT_ADD; @@ -42,147 +52,49 @@ class EntraPimRoleAssignmentAddCommand extends GraphCommand { return 'Request activation of an Entra role assignment for a user or group'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - roleDefinitionName: typeof args.options.roleDefinitionName !== 'undefined', - roleDefinitionId: typeof args.options.roleDefinitionId !== 'undefined', - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - groupId: typeof args.options.groupId !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - administrativeUnitId: typeof args.options.administrativeUnitId !== 'undefined', - applicationId: typeof args.options.applicationId !== 'undefined', - justification: typeof args.options.justification !== 'undefined', - startDateTime: typeof args.options.startDateTime !== 'undefined', - endDateTime: typeof args.options.endDateTime !== 'undefined', - duration: typeof args.options.duration !== 'undefined', - ticketNumber: typeof args.options.ticketNumber !== 'undefined', - ticketSystem: typeof args.options.ticketSystem !== 'undefined', - noExpiration: !!args.options.noExpiration - }); - }); + public get schema(): z.ZodType | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '-n, --roleDefinitionName [roleDefinitionName]' - }, - { - option: '-i, --roleDefinitionId [roleDefinitionId]' - }, - { - option: "--userId [userId]" - }, - { - option: "--userName [userName]" - }, - { - option: "--groupId [groupId]" - }, - { - option: "--groupName [groupName]" - }, - { - option: "--administrativeUnitId [administrativeUnitId]" - }, - { - option: "--applicationId [applicationId]" - }, - { - option: "-j, --justification [justification]" - }, - { - option: "-s, --startDateTime [startDateTime]" - }, - { - option: "-e, --endDateTime [endDateTime]" - }, - { - option: "-d, --duration [duration]" - }, - { - option: "--ticketNumber [ticketNumber]" - }, - { - option: "--ticketSystem [ticketSystem]" - }, - { - option: "--no-expiration" - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.roleDefinitionId && !validation.isValidGuid(args.options.roleDefinitionId)) { - return `${args.options.roleDefinitionId} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(options => [options.roleDefinitionId, options.roleDefinitionName].filter(o => o !== undefined).length === 1, { + message: 'Specify either roleDefinitionId or roleDefinitionName', + params: { + customCode: 'optionSet', + options: ['roleDefinitionId', 'roleDefinitionName'] } - - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `${args.options.userId} is not a valid GUID`; + }) + .refine(options => { + const specified = [!options.expiration ? true : undefined, options.endDateTime, options.duration].filter(o => o !== undefined).length; + return specified <= 1; + }, { + message: 'Specify only one of the following options: no-expiration, endDateTime, duration', + params: { + customCode: 'optionSet', + options: ['no-expiration', 'endDateTime', 'duration'] } - - if (args.options.groupId && !validation.isValidGuid(args.options.groupId)) { - return `${args.options.groupId} is not a valid GUID`; + }) + .refine(options => { + const specified = [options.userId, options.userName, options.groupId, options.groupName].filter(o => o !== undefined).length; + return specified <= 1; + }, { + message: 'Specify only one of the following options: userId, userName, groupId, groupName', + params: { + customCode: 'optionSet', + options: ['userId', 'userName', 'groupId', 'groupName'] } - - if (args.options.startDateTime && !validation.isValidISODateTime(args.options.startDateTime)) { - return `${args.options.startDateTime} is not a valid ISO 8601 date time string`; - } - - if (args.options.endDateTime && !validation.isValidISODateTime(args.options.endDateTime)) { - return `${args.options.endDateTime} is not a valid ISO 8601 date time string`; + }) + .refine(options => { + const specified = [options.administrativeUnitId, options.applicationId].filter(o => o !== undefined).length; + return specified <= 1; + }, { + message: 'Specify only one of the following options: administrativeUnitId, applicationId', + params: { + customCode: 'optionSet', + options: ['administrativeUnitId', 'applicationId'] } - - if (args.options.duration && !validation.isValidISODuration(args.options.duration)) { - return `${args.options.duration} is not a valid ISO 8601 duration`; - } - - if (args.options.administrativeUnitId && !validation.isValidGuid(args.options.administrativeUnitId)) { - return `${args.options.administrativeUnitId} is not a valid GUID`; - } - - if (args.options.applicationId && !validation.isValidGuid(args.options.applicationId)) { - return `${args.options.applicationId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['roleDefinitionName', 'roleDefinitionId'] }); - this.optionSets.push({ - options: ['noExpiration', 'endDateTime', 'duration'], - runsWhen: (args) => { - return !!args.options.noExpiration || args.options.endDateTime !== undefined || args.options.duration !== undefined; - } - }); - this.optionSets.push({ - options: ['userId', 'userName', 'groupId', 'groupName'], - runsWhen: (args) => { - return args.options.userId !== undefined || args.options.userName !== undefined || args.options.groupId !== undefined || args.options.groupName !== undefined; - } - }); - this.optionSets.push({ - options: ['administrativeUnitId', 'applicationId'], - runsWhen: (args) => { - return args.options.administrativeUnitId !== undefined || args.options.applicationId !== undefined; - } - }); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -280,7 +192,7 @@ class EntraPimRoleAssignmentAddCommand extends GraphCommand { return 'afterDateTime'; } - if (options.noExpiration) { + if (!options.expiration) { return 'noExpiration'; } @@ -288,7 +200,7 @@ class EntraPimRoleAssignmentAddCommand extends GraphCommand { } private getDuration(options: Options): string | undefined { - if (!options.duration && !options.endDateTime && !options.noExpiration) { + if (!options.duration && !options.endDateTime && options.expiration) { return 'PT8H'; } diff --git a/src/m365/entra/commands/pim/pim-role-assignment-eligibility-list.spec.ts b/src/m365/entra/commands/pim/pim-role-assignment-eligibility-list.spec.ts index bf498dfb73a..7591ae4e29c 100644 --- a/src/m365/entra/commands/pim/pim-role-assignment-eligibility-list.spec.ts +++ b/src/m365/entra/commands/pim/pim-role-assignment-eligibility-list.spec.ts @@ -10,7 +10,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; -import command from './pim-role-assignment-eligibility-list.js'; +import command, { options } from './pim-role-assignment-eligibility-list.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { CommandError } from '../../../../Command.js'; @@ -201,6 +201,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -209,6 +210,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -249,33 +251,33 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { }); it('passes validation when userId is a valid GUID', async () => { - const actual = await command.validate({ options: { userId: userId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: userId }); + assert.strictEqual(actual.success, true); }); it('passes validation when userName is a valid user principal name', async () => { - const actual = await command.validate({ options: { userName: userName } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userName: userName }); + assert.strictEqual(actual.success, true); }); it('passes validation when groupId is a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: groupId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: groupId }); + assert.strictEqual(actual.success, true); }); it('fails validation when userId is not a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when userName is not a valid user principal name', async () => { - const actual = await command.validate({ options: { userName: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userName: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when groupId is not a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('should get a list of eligible roles for any user', async () => { @@ -289,7 +291,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignmentEligibilityScheduleInstanceTransformedResponse)); }); @@ -307,7 +309,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userId: userId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ userId: userId, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentEligibilityScheduleInstanceTransformedResponse[0]])); }); @@ -326,7 +328,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ userName: userName, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentEligibilityScheduleInstanceTransformedResponse[0]])); }); @@ -344,7 +346,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: groupId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ groupId: groupId, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentEligibilityScheduleInstanceTransformedResponse[1]])); }); @@ -363,7 +365,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupName: groupName, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ groupName: groupName, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentEligibilityScheduleInstanceTransformedResponse[1]])); }); @@ -379,7 +381,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { withPrincipalDetails: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ withPrincipalDetails: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignmentEligibilityScheduleInstanceWithPrincipalTransformedResponse)); }); @@ -393,7 +395,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_ELIGIBILITY_LIST, () => { }); await assert.rejects( - command.action(logger, { options: {} } as any), + command.action(logger, { options: commandOptionsSchema.safeParse({}).data! }), new CommandError('An error has occurred') ); }); diff --git a/src/m365/entra/commands/pim/pim-role-assignment-eligibility-list.ts b/src/m365/entra/commands/pim/pim-role-assignment-eligibility-list.ts index dcda71c0161..f1dea7bd918 100644 --- a/src/m365/entra/commands/pim/pim-role-assignment-eligibility-list.ts +++ b/src/m365/entra/commands/pim/pim-role-assignment-eligibility-list.ts @@ -1,5 +1,6 @@ import { UnifiedRoleEligibilityScheduleInstance } from '@microsoft/microsoft-graph-types'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; @@ -8,18 +9,23 @@ import { entraUser } from '../../../../utils/entraUser.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { odata } from '../../../../utils/odata.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.uuid().optional(), + userName: z.string().refine(upn => validation.isValidUserPrincipalName(upn), { + error: e => `'${e.input}' is not a valid user principal name for option 'userName'.` + }).optional(), + groupId: z.uuid().optional(), + groupName: z.string().optional(), + withPrincipalDetails: z.boolean().default(false) +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userId?: string; - userName?: string; - groupId?: string; - groupName?: string; - withPrincipalDetails?: boolean; -} - interface UnifiedRoleEligibilityScheduleInstanceEx extends UnifiedRoleEligibilityScheduleInstance { roleDefinitionName?: string } @@ -33,76 +39,26 @@ class EntraPimRoleAssignmentEligibilityListCommand extends GraphCommand { return 'Retrieves a list of eligible roles a user or group can be assigned to'; } - public defaultProperties(): string[] | undefined { - return ['roleDefinitionId', 'roleDefinitionName', 'principalId']; - } - - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - groupId: typeof args.options.groupId !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - withPrincipalDetails: !!args.options.withPrincipalDetails - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--userId [userId]' - }, - { - option: '--userName [userName]' - }, - { - option: '--groupId [groupId]' - }, - { - option: '--groupName [groupName]' - }, - { - option: '--withPrincipalDetails' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `'${args.options.userId} is not a valid GUID for option 'userId'.`; - } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `'${args.options.userName} is not a valid user principal name for option 'userName'.`; - } - - if (args.options.groupId && !validation.isValidGuid(args.options.groupId)) { - return `'${args.options.groupId}' is not a valid GUID for option 'groupId'.`; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(options => { + const specified = [options.userId, options.userName, options.groupId, options.groupName].filter(o => o !== undefined).length; + return specified <= 1; + }, { + message: 'Specify only one of the following options: userId, userName, groupId, groupName', + params: { + customCode: 'optionSet', + options: ['userId', 'userName', 'groupId', 'groupName'] } - - return true; - } - ); + }); } - #initOptionSets(): void { - this.optionSets.push({ - options: ['userId', 'userName', 'groupId', 'groupName'], - runsWhen: (args) => args.options.userId || args.options.userName || args.options.groupName || args.options.groupId - }); + public defaultProperties(): string[] | undefined { + return ['roleDefinitionId', 'roleDefinitionName', 'principalId']; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/pim/pim-role-assignment-list.spec.ts b/src/m365/entra/commands/pim/pim-role-assignment-list.spec.ts index 91aaffbaa22..752fb5af234 100644 --- a/src/m365/entra/commands/pim/pim-role-assignment-list.spec.ts +++ b/src/m365/entra/commands/pim/pim-role-assignment-list.spec.ts @@ -10,7 +10,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; -import command from './pim-role-assignment-list.js'; +import command, { options } from './pim-role-assignment-list.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { CommandError } from '../../../../Command.js'; @@ -119,6 +119,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -127,6 +128,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -170,33 +172,33 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { }); it('passes validation when userId is a valid GUID', async () => { - const actual = await command.validate({ options: { userId: userId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: userId }); + assert.strictEqual(actual.success, true); }); it('passes validation when groupId is a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: groupId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: groupId }); + assert.strictEqual(actual.success, true); }); it('passes validation when startDateTime is a valid ISO 8601 date', async () => { - const actual = await command.validate({ options: { startDateTime: '2024-02-20T08:00:00Z' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ startDateTime: '2024-02-20T08:00:00Z' }); + assert.strictEqual(actual.success, true); }); it('fails validation when userId is not a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when groupId is not a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when startDateTime is not a valid ISO 8601 date', async () => { - const actual = await command.validate({ options: { startDateTime: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ startDateTime: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('should get a list of role assignments', async () => { @@ -210,7 +212,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: {} }); + await command.action(logger, { options: commandOptionsSchema.safeParse({}).data! }); assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignmentScheduleInstanceResponse)); }); @@ -228,7 +230,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userId: userId } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ userId: userId }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleInstanceResponse[0]])); }); @@ -247,7 +249,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ userName: userName, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleInstanceResponse[0]])); }); @@ -265,7 +267,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: groupId } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ groupId: groupId }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleInstanceResponse[1]])); }); @@ -284,7 +286,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupName: groupName, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ groupName: groupName, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleInstanceResponse[1]])); }); @@ -302,7 +304,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { startDateTime: startDateTime } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ startDateTime: startDateTime }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleInstanceResponse[1]])); }); @@ -320,7 +322,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userId: userId, startDateTime: startDateTime } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ userId: userId, startDateTime: startDateTime }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleInstanceResponse[1]])); }); @@ -336,7 +338,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { withPrincipalDetails: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ withPrincipalDetails: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignmentScheduleInstanceWithPrincipalResponse)); }); @@ -353,7 +355,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupName: groupName, startDateTime: startDateTime, withPrincipalDetails: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ groupName: groupName, startDateTime: startDateTime, withPrincipalDetails: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignmentScheduleInstanceWithPrincipalResponse)); }); @@ -367,7 +369,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_LIST, () => { }); await assert.rejects( - command.action(logger, { options: {} } as any), + command.action(logger, { options: commandOptionsSchema.safeParse({}).data! }), new CommandError('An error has occurred') ); }); diff --git a/src/m365/entra/commands/pim/pim-role-assignment-list.ts b/src/m365/entra/commands/pim/pim-role-assignment-list.ts index 5f9a36f714b..70e81bd91f0 100644 --- a/src/m365/entra/commands/pim/pim-role-assignment-list.ts +++ b/src/m365/entra/commands/pim/pim-role-assignment-list.ts @@ -1,5 +1,6 @@ import { UnifiedRoleAssignmentScheduleInstance } from '@microsoft/microsoft-graph-types'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; @@ -8,19 +9,24 @@ import { entraUser } from '../../../../utils/entraUser.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { odata } from '../../../../utils/odata.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.uuid().optional(), + userName: z.string().optional(), + groupId: z.uuid().optional(), + groupName: z.string().optional(), + startDateTime: z.string().refine(date => validation.isValidISODateTime(date), { + error: e => `'${e.input}' is not a valid ISO 8601 date time string.` + }).optional().alias('s'), + withPrincipalDetails: z.boolean().default(false) +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userId?: string; - userName?: string; - groupId?: string; - groupName?: string; - startDateTime?: string; - withPrincipalDetails?: boolean; -} - class EntraPimRoleAssignmentListCommand extends GraphCommand { public get name(): string { return commands.PIM_ROLE_ASSIGNMENT_LIST; @@ -30,76 +36,22 @@ class EntraPimRoleAssignmentListCommand extends GraphCommand { return 'Retrieves a list of Entra role assignments for a user or group'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - groupId: typeof args.options.groupId !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - startDateTime: typeof args.options.startDateTime !== 'undefined', - withPrincipalDetails: !!args.options.withPrincipalDetails - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: "--userId [userId]" - }, - { - option: "--userName [userName]" - }, - { - option: "--groupId [groupId]" - }, - { - option: "--groupName [groupName]" - }, - { - option: "-s, --startDateTime [startDateTime]" - }, - { - option: "--withPrincipalDetails" - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `${args.options.userId} is not a valid GUID`; - } - - if (args.options.groupId && !validation.isValidGuid(args.options.groupId)) { - return `${args.options.groupId} is not a valid GUID`; - } - - if (args.options.startDateTime && !validation.isValidISODateTime(args.options.startDateTime)) { - return `${args.options.startDateTime} is not a valid ISO 8601 date time string`; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(options => { + const specified = [options.userId, options.userName, options.groupId, options.groupName].filter(o => o !== undefined).length; + return specified <= 1; + }, { + message: 'Specify only one of the following options: userId, userName, groupId, groupName', + params: { + customCode: 'optionSet', + options: ['userId', 'userName', 'groupId', 'groupName'] } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ - options: ['userId', 'userName', 'groupId', 'groupName'], - runsWhen: (args) => args.options.userId || args.options.userName || args.options.groupId || args.options.groupName - }); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/pim/pim-role-assignment-remove.spec.ts b/src/m365/entra/commands/pim/pim-role-assignment-remove.spec.ts index f07ab48cc03..06b33a29286 100644 --- a/src/m365/entra/commands/pim/pim-role-assignment-remove.spec.ts +++ b/src/m365/entra/commands/pim/pim-role-assignment-remove.spec.ts @@ -10,7 +10,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; -import command from './pim-role-assignment-remove.js'; +import command, { options } from './pim-role-assignment-remove.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { accessToken } from '../../../../utils/accessToken.js'; @@ -156,6 +156,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -164,6 +165,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -210,53 +212,53 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { }); it('passes validation when roleDefinitionId is a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId }); + assert.strictEqual(actual.success, true); }); it('passes validation when userId is a valid GUID', async () => { - const actual = await command.validate({ options: { userId: userId, roleDefinitionName: 'Global Administrator' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: userId, roleDefinitionName: 'Global Administrator' }); + assert.strictEqual(actual.success, true); }); it('passes validation when groupId is a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: groupId, roleDefinitionName: 'Global Administrator' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: groupId, roleDefinitionName: 'Global Administrator' }); + assert.strictEqual(actual.success, true); }); it('passes validation when administrativeUnitId is a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, administrativeUnitId: '81bb36e4-f4c6-4984-8e56-d4f8feae9e09' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, administrativeUnitId: '81bb36e4-f4c6-4984-8e56-d4f8feae9e09' }); + assert.strictEqual(actual.success, true); }); it('passes validation when applicationId is a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, applicationId: '94446d35-4df6-45da-a17f-c601310a8342' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, applicationId: '94446d35-4df6-45da-a17f-c601310a8342' }); + assert.strictEqual(actual.success, true); }); it('fails validation when roleDefinitionId is not a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when userId is not a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'foo', roleDefinitionName: 'Global Administrator' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: 'foo', roleDefinitionName: 'Global Administrator' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when groupId is not a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: 'foo', roleDefinitionName: 'Global Administrator' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: 'foo', roleDefinitionName: 'Global Administrator' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when administrativeUnitId is not a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, administrativeUnitId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, administrativeUnitId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when applicationId is not a valid GUID', async () => { - const actual = await command.validate({ options: { roleDefinitionId: roleDefinitionId, applicationId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, applicationId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('correctly requests deactivation of role specified by id for a user specified by id tenant-wide scope', async () => { @@ -277,14 +279,11 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: - { - roleDefinitionId: roleDefinitionId, - userId: userId, - justification: 'Remove user from SharePoint Administrator role' - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionId: roleDefinitionId, + userId: userId, + justification: 'Remove user from SharePoint Administrator role' + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseTenantScope)); }); @@ -309,16 +308,13 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: - { - roleDefinitionName: roleDefinitionName, - userName: userName, - administrativeUnitId: '81bb36e4-f4c6-4984-8e56-d4f8feae9e09', - justification: 'Remove user from SharePoint Administrator role for admin unit', - verbose: true - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionName: roleDefinitionName, + userName: userName, + administrativeUnitId: '81bb36e4-f4c6-4984-8e56-d4f8feae9e09', + justification: 'Remove user from SharePoint Administrator role for admin unit', + verbose: true + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseAdminUnitScope)); }); @@ -340,15 +336,12 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: - { - roleDefinitionId: roleDefinitionId, - groupId: groupId, - applicationId: '94446d35-4df6-45da-a17f-c601310a8342', - justification: 'Remove Application Administrator role for group' - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionId: roleDefinitionId, + groupId: groupId, + applicationId: '94446d35-4df6-45da-a17f-c601310a8342', + justification: 'Remove Application Administrator role for group' + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseApplicationScope)); }); @@ -374,17 +367,14 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: - { - roleDefinitionId: roleDefinitionId, - groupName: groupName, - justification: 'Remove User Administrator role for group, ticket details included', - ticketSystem: 'JIRA', - ticketNumber: 'MSFT-2024', - verbose: true - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionId: roleDefinitionId, + groupName: groupName, + justification: 'Remove User Administrator role for group, ticket details included', + ticketSystem: 'JIRA', + ticketNumber: 'MSFT-2024', + verbose: true + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseWithTicketInfo)); }); @@ -413,14 +403,11 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { throw opts.data; }); - await command.action(logger, { - options: - { - roleDefinitionId: roleDefinitionId, - justification: 'Remove SharePoint Administrator role', - verbose: true - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + roleDefinitionId: roleDefinitionId, + justification: 'Remove SharePoint Administrator role', + verbose: true + }).data! }); assert(loggerLogSpy.calledOnceWithExactly(roleAssignmentResponseTenantScope)); }); @@ -431,7 +418,7 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { }; sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); - await assert.rejects(command.action(logger, { options: { roleDefinitionId: roleDefinitionId, verbose: true } }), new CommandError('When running with application permissions either userId, userName, groupId or groupName is required')); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, verbose: true }).data! }), new CommandError('When running with application permissions either userId, userName, groupId or groupName is required')); }); it('throws an error during self deactivation when role assignment does not exist', async () => { @@ -469,6 +456,6 @@ describe(commands.PIM_ROLE_ASSIGNMENT_REMOVE, () => { throw 'Invalid request'; }); - await assert.rejects(command.action(logger, { options: { roleDefinitionId: roleDefinitionId, justification: 'Remove SharePoint Administrator role' } }), new CommandError(error.error.message)); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.safeParse({ roleDefinitionId: roleDefinitionId, justification: 'Remove SharePoint Administrator role' }).data! }), new CommandError(error.error.message)); }); }); \ No newline at end of file diff --git a/src/m365/entra/commands/pim/pim-role-assignment-remove.ts b/src/m365/entra/commands/pim/pim-role-assignment-remove.ts index b95ddd60bda..5c2aeddada1 100644 --- a/src/m365/entra/commands/pim/pim-role-assignment-remove.ts +++ b/src/m365/entra/commands/pim/pim-role-assignment-remove.ts @@ -1,34 +1,37 @@ import { UnifiedRoleAssignmentScheduleRequest } from '@microsoft/microsoft-graph-types'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; import { roleDefinition } from '../../../../utils/roleDefinition.js'; -import { validation } from '../../../../utils/validation.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { accessToken } from '../../../../utils/accessToken.js'; import auth from '../../../../Auth.js'; +export const options = z.strictObject({ + ...globalOptionsZod.shape, + roleDefinitionName: z.string().optional().alias('n'), + roleDefinitionId: z.uuid().optional().alias('i'), + userId: z.uuid().optional(), + userName: z.string().optional(), + groupId: z.uuid().optional(), + groupName: z.string().optional(), + administrativeUnitId: z.uuid().optional(), + applicationId: z.uuid().optional(), + justification: z.string().optional().alias('j'), + ticketNumber: z.string().optional(), + ticketSystem: z.string().optional() +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - roleDefinitionName?: string; - roleDefinitionId?: string; - userId?: string; - userName?: string; - groupId?: string; - groupName?: string; - administrativeUnitId?: string; - applicationId?: string; - justification?: string, - ticketNumber?: string; - ticketSystem?: string; -} - class EntraPimRoleAssignmentRemoveCommand extends GraphCommand { public get name(): string { return commands.PIM_ROLE_ASSIGNMENT_REMOVE; @@ -38,118 +41,39 @@ class EntraPimRoleAssignmentRemoveCommand extends GraphCommand { return 'Request deactivation of an Entra role assignment for a user or group'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - this.#initTypes(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - roleDefinitionName: typeof args.options.roleDefinitionName !== 'undefined', - roleDefinitionId: typeof args.options.roleDefinitionId !== 'undefined', - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - groupId: typeof args.options.groupId !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - administrativeUnitId: typeof args.options.administrativeUnitId !== 'undefined', - applicationId: typeof args.options.applicationId !== 'undefined', - justification: typeof args.options.justification !== 'undefined', - ticketNumber: typeof args.options.ticketNumber !== 'undefined', - ticketSystem: typeof args.options.ticketSystem !== 'undefined' - }); - }); + public get schema(): z.ZodType | undefined { + return options; } - #initOptions(): void { - this.options.unshift( - { - option: '-n, --roleDefinitionName [roleDefinitionName]' - }, - { - option: '-i, --roleDefinitionId [roleDefinitionId]' - }, - { - option: "--userId [userId]" - }, - { - option: "--userName [userName]" - }, - { - option: "--groupId [groupId]" - }, - { - option: "--groupName [groupName]" - }, - { - option: "--administrativeUnitId [administrativeUnitId]" - }, - { - option: "--applicationId [applicationId]" - }, - { - option: "-j, --justification [justification]" - }, - { - option: "--ticketNumber [ticketNumber]" - }, - { - option: "--ticketSystem [ticketSystem]" - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.roleDefinitionId && !validation.isValidGuid(args.options.roleDefinitionId)) { - return `${args.options.roleDefinitionId} is not a valid GUID`; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(options => [options.roleDefinitionId, options.roleDefinitionName].filter(o => o !== undefined).length === 1, { + message: 'Specify either roleDefinitionId or roleDefinitionName', + params: { + customCode: 'optionSet', + options: ['roleDefinitionId', 'roleDefinitionName'] } - - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `${args.options.userId} is not a valid GUID`; + }) + .refine(options => { + const specified = [options.userId, options.userName, options.groupId, options.groupName].filter(o => o !== undefined).length; + return specified <= 1; + }, { + message: 'Specify only one of the following options: userId, userName, groupId, groupName', + params: { + customCode: 'optionSet', + options: ['userId', 'userName', 'groupId', 'groupName'] } - - if (args.options.groupId && !validation.isValidGuid(args.options.groupId)) { - return `${args.options.groupId} is not a valid GUID`; + }) + .refine(options => { + const specified = [options.administrativeUnitId, options.applicationId].filter(o => o !== undefined).length; + return specified <= 1; + }, { + message: 'Specify only one of the following options: administrativeUnitId, applicationId', + params: { + customCode: 'optionSet', + options: ['administrativeUnitId', 'applicationId'] } - - if (args.options.administrativeUnitId && !validation.isValidGuid(args.options.administrativeUnitId)) { - return `${args.options.administrativeUnitId} is not a valid GUID`; - } - - if (args.options.applicationId && !validation.isValidGuid(args.options.applicationId)) { - return `${args.options.applicationId} is not a valid GUID`; - } - - return true; - } - ); - } - - #initOptionSets(): void { - this.optionSets.push({ options: ['roleDefinitionName', 'roleDefinitionId'] }); - this.optionSets.push({ - options: ['userId', 'userName', 'groupId', 'groupName'], - runsWhen: (args) => { - return args.options.userId !== undefined || args.options.userName !== undefined || args.options.groupId !== undefined || args.options.groupName !== undefined; - } - }); - this.optionSets.push({ - options: ['administrativeUnitId', 'applicationId'], - runsWhen: (args) => { - return args.options.administrativeUnitId !== undefined || args.options.applicationId !== undefined; - } - }); - } - - #initTypes(): void { - this.types.string.push('userId', 'userName', 'groupId', 'groupName', 'administrativeUnitId', 'applicationId', 'roleDefinitionName', 'roleDefinitionId', 'justification', 'ticketNumber', 'ticketSystem'); + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/pim/pim-role-request-list.spec.ts b/src/m365/entra/commands/pim/pim-role-request-list.spec.ts index 66f0ec5aeda..cd92950525d 100644 --- a/src/m365/entra/commands/pim/pim-role-request-list.spec.ts +++ b/src/m365/entra/commands/pim/pim-role-request-list.spec.ts @@ -10,7 +10,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; -import command from './pim-role-request-list.js'; +import command, { options } from './pim-role-request-list.js'; import { entraUser } from '../../../../utils/entraUser.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { CommandError } from '../../../../Command.js'; @@ -287,6 +287,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -295,6 +296,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -335,53 +337,53 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { }); it('passes validation when userId is a valid GUID', async () => { - const actual = await command.validate({ options: { userId: userId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: userId }); + assert.strictEqual(actual.success, true); }); it('passes validation when userName is a valid user principal name', async () => { - const actual = await command.validate({ options: { userName: userName } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userName: userName }); + assert.strictEqual(actual.success, true); }); it('passes validation when groupId is a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: groupId } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: groupId }); + assert.strictEqual(actual.success, true); }); it('passes validation when createdDateTime is a valid ISO 8601 date', async () => { - const actual = await command.validate({ options: { createdDateTime: '2024-02-20T08:00:00Z' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ createdDateTime: '2024-02-20T08:00:00Z' }); + assert.strictEqual(actual.success, true); }); it('passes validation when status is set to one of allowed values', async () => { - const actual = await command.validate({ options: { status: 'Granted' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ status: 'Granted' }); + assert.strictEqual(actual.success, true); }); it('fails validation when userId is not a valid GUID', async () => { - const actual = await command.validate({ options: { userId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when userName is not a valid user principal name', async () => { - const actual = await command.validate({ options: { userName: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ userName: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when groupId is not a valid GUID', async () => { - const actual = await command.validate({ options: { groupId: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ groupId: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when createdDateTime is not a valid ISO 8601 date', async () => { - const actual = await command.validate({ options: { createdDateTime: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ createdDateTime: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('fails validation when status has invalid value', async () => { - const actual = await command.validate({ options: { status: 'foo' } }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ status: 'foo' }); + assert.notStrictEqual(actual.success, true); }); it('should get a list of PIM requests', async () => { @@ -395,7 +397,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignmentScheduleRequestTransformedResponse)); }); @@ -413,7 +415,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userId: userId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ userId: userId, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleRequestTransformedResponse[0]])); }); @@ -432,7 +434,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userName: userName, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ userName: userName, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleRequestTransformedResponse[0]])); }); @@ -450,7 +452,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupId: groupId, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ groupId: groupId, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleRequestTransformedResponse[1]])); }); @@ -469,7 +471,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupName: groupName, verbose: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ groupName: groupName, verbose: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleRequestTransformedResponse[1]])); }); @@ -487,7 +489,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { createdDateTime: createdDateTime } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ createdDateTime: createdDateTime }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleRequestTransformedResponse[1]])); }); @@ -505,7 +507,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { status: status } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ status: status }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleRequestTransformedResponse[1]])); }); @@ -523,7 +525,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { userId: userId, createdDateTime: createdDateTime, status: status } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ userId: userId, createdDateTime: createdDateTime, status: status }).data! }); assert(loggerLogSpy.calledOnceWithExactly([unifiedRoleAssignmentScheduleRequestTransformedResponse[1]])); }); @@ -539,7 +541,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { withPrincipalDetails: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ withPrincipalDetails: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignmentScheduleRequestWithPrincipalTransformedResponse)); }); @@ -556,7 +558,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { options: { groupName: groupName, createdDateTime: createdDateTime, withPrincipalDetails: true } }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ groupName: groupName, createdDateTime: createdDateTime, withPrincipalDetails: true }).data! }); assert(loggerLogSpy.calledOnceWithExactly(unifiedRoleAssignmentScheduleRequestWithPrincipalTransformedResponse)); }); @@ -570,7 +572,7 @@ describe(commands.PIM_ROLE_REQUEST_LIST, () => { }); await assert.rejects( - command.action(logger, { options: {} } as any), + command.action(logger, { options: commandOptionsSchema.safeParse({}).data! }), new CommandError('An error has occurred') ); }); diff --git a/src/m365/entra/commands/pim/pim-role-request-list.ts b/src/m365/entra/commands/pim/pim-role-request-list.ts index 5584aea963d..3ee65c47df2 100644 --- a/src/m365/entra/commands/pim/pim-role-request-list.ts +++ b/src/m365/entra/commands/pim/pim-role-request-list.ts @@ -1,5 +1,6 @@ import { UnifiedRoleAssignmentScheduleRequest } from '@microsoft/microsoft-graph-types'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; @@ -8,26 +9,34 @@ import { entraUser } from '../../../../utils/entraUser.js'; import { entraGroup } from '../../../../utils/entraGroup.js'; import { odata } from '../../../../utils/odata.js'; +const allowedStatuses = ['Canceled', 'Denied', 'Failed', 'Granted', 'PendingAdminDecision', 'PendingApproval', 'PendingProvisioning', 'PendingScheduleCreation', 'Provisioned', 'Revoked', 'ScheduleCreated'] as const; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.uuid().optional(), + userName: z.string().refine(upn => validation.isValidUserPrincipalName(upn), { + error: e => `'${e.input}' is not a valid user principal name for option 'userName'.` + }).optional(), + groupId: z.uuid().optional(), + groupName: z.string().optional(), + createdDateTime: z.string().refine(date => validation.isValidISODateTime(date), { + error: e => `'${e.input}' is not a valid ISO 8601 date time string for option 'createdDateTime'.` + }).optional().alias('c'), + status: z.enum(allowedStatuses).optional().alias('s'), + withPrincipalDetails: z.boolean().default(false) +}); + +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - userId?: string; - userName?: string; - groupId?: string; - groupName?: string; - createdDateTime?: string; - status?: string; - withPrincipalDetails?: boolean; -} - interface UnifiedRoleAssignmentScheduleRequestEx extends UnifiedRoleAssignmentScheduleRequest { roleDefinitionName?: string } class EntraPimRoleRequestListCommand extends GraphCommand { - private readonly allowedStatuses = ['Canceled', 'Denied', 'Failed', 'Granted', 'PendingAdminDecision', 'PendingApproval', 'PendingProvisioning', 'PendingScheduleCreation', 'Provisioned', 'Revoked', 'ScheduleCreated']; public get name(): string { return commands.PIM_ROLE_REQUEST_LIST; } @@ -36,93 +45,26 @@ class EntraPimRoleRequestListCommand extends GraphCommand { return 'Retrieves a list of PIM requests for roles'; } - public defaultProperties(): string[] | undefined { - return ['id', 'roleDefinitionName', 'principalId']; - } - - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - this.#initOptionSets(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - userId: typeof args.options.userId !== 'undefined', - userName: typeof args.options.userName !== 'undefined', - groupId: typeof args.options.groupId !== 'undefined', - groupName: typeof args.options.groupName !== 'undefined', - createdDateTime: typeof args.options.createdDateTime !== 'undefined', - status: typeof args.options.status !== 'undefined', - withPrincipalDetails: !!args.options.withPrincipalDetails - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '--userId [userId]' - }, - { - option: '--userName [userName]' - }, - { - option: '--groupId [groupId]' - }, - { - option: '--groupName [groupName]' - }, - { - option: '-c, --createdDateTime [createdDateTime]' - }, - { - option: '-s, --status [status]', - autocomplete: this.allowedStatuses - }, - { - option: '--withPrincipalDetails' - } - ); + public get schema(): z.ZodType | undefined { + return options; } - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.userId && !validation.isValidGuid(args.options.userId)) { - return `'${args.options.userId}' is not a valid GUID for option 'userId'`; + public getRefinedSchema(schema: typeof options): z.ZodType | undefined { + return schema + .refine(options => { + const specified = [options.userId, options.userName, options.groupId, options.groupName].filter(o => o !== undefined).length; + return specified <= 1; + }, { + message: 'Specify only one of the following options: userId, userName, groupId, groupName', + params: { + customCode: 'optionSet', + options: ['userId', 'userName', 'groupId', 'groupName'] } - - if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { - return `'${args.options.userName}' is not a valid user principal name for option 'userName'.`; - } - - if (args.options.groupId && !validation.isValidGuid(args.options.groupId)) { - return `'${args.options.groupId}' is not a valid GUID for option 'groupId'`; - } - - if (args.options.createdDateTime && !validation.isValidISODateTime(args.options.createdDateTime)) { - return `'${args.options.createdDateTime}' is not a valid ISO 8601 date time string for option 'createdDateTime'`; - } - - if (args.options.status && !this.allowedStatuses.some(status => status.toLowerCase() === args.options.status!.toLowerCase())) { - return `'${args.options.status}' for option 'status' must be one of the following values: ${this.allowedStatuses.join(', ')}.`; - } - - return true; - } - ); + }); } - #initOptionSets(): void { - this.optionSets.push({ - options: ['userId', 'userName', 'groupId', 'groupName'], - runsWhen: (args) => args.options.userId || args.options.userName || args.options.groupId || args.options.groupName - }); + public defaultProperties(): string[] | undefined { + return ['id', 'roleDefinitionName', 'principalId']; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/entra/commands/policy/policy-list.spec.ts b/src/m365/entra/commands/policy/policy-list.spec.ts index 9855bc44f32..ee694e41093 100644 --- a/src/m365/entra/commands/policy/policy-list.spec.ts +++ b/src/m365/entra/commands/policy/policy-list.spec.ts @@ -11,13 +11,14 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import command from './policy-list.js'; +import command, { options } from './policy-list.js'; describe(commands.POLICY_LIST, () => { let log: string[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -26,6 +27,7 @@ describe(commands.POLICY_LIST, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; }); beforeEach(() => { @@ -96,11 +98,9 @@ describe(commands.POLICY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: { - type: "authorization" - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + type: "authorization" + }).data! }); assert(loggerLogSpy.calledWith({ "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#policies/authorizationPolicy/$entity", "@odata.id": "https://graph.microsoft.com/v2/b30f2eac-f6b4-4f87-9dcb-cdf7ae1f8923/authorizationPolicy/authorizationPolicy", @@ -145,11 +145,9 @@ describe(commands.POLICY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: { - type: "tokenLifetime" - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + type: "tokenLifetime" + }).data! }); assert(loggerLogSpy.calledWith([ { id: 'a457c42c-0f2e-4a25-be2a-545e840add1f', @@ -844,10 +842,8 @@ describe(commands.POLICY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: { - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + }).data! }); assert(loggerLogSpy.calledWith([ { "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#policies/adminConsentRequestPolicy/$entity", @@ -1458,11 +1454,9 @@ describe(commands.POLICY_LIST, () => { throw 'Invalid request'; }); - await command.action(logger, { - options: { - type: "roleManagement" - } - }); + await command.action(logger, { options: commandOptionsSchema.safeParse({ + type: "roleManagement" + }).data! }); assert(loggerLogSpy.calledWith([ { "id": "DirectoryRole_a457c42c-0f2e-4a25-be2a-545e840add1f_7ace6474-d11c-4a14-bc8f-3c9fdfc34930", @@ -1496,202 +1490,146 @@ describe(commands.POLICY_LIST, () => { it('correctly handles API OData error for specified policies', async () => { sinon.stub(request, 'get').rejects(new Error('An error has occurred.')); - await assert.rejects(command.action(logger, { options: { type: "foo" } } as any), new CommandError("An error has occurred.")); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.safeParse({ type: "activityBasedTimeout" }).data! }), new CommandError("An error has occurred.")); }); it('correctly handles API OData error for all policies', async () => { sinon.stub(request, 'get').rejects(new Error("An error has occurred.")); - await assert.rejects(command.action(logger, { options: {} } as any), new CommandError("An error has occurred.")); + await assert.rejects(command.action(logger, { options: commandOptionsSchema.safeParse({}).data! }), new CommandError("An error has occurred.")); }); it('accepts type to be activityBasedTimeout', async () => { - const actual = await command.validate({ - options: - { - type: "activityBasedTimeout" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "activityBasedTimeout" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be authorization', async () => { - const actual = await command.validate({ - options: - { - type: "authorization" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "authorization" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be claimsMapping', async () => { - const actual = await command.validate({ - options: - { - type: "claimsMapping" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "claimsMapping" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be homeRealmDiscovery', async () => { - const actual = await command.validate({ - options: - { - type: "homeRealmDiscovery" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "homeRealmDiscovery" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be identitySecurityDefaultsEnforcement', async () => { - const actual = await command.validate({ - options: - { - type: "identitySecurityDefaultsEnforcement" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "identitySecurityDefaultsEnforcement" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be tokenLifetime', async () => { - const actual = await command.validate({ - options: - { - type: "tokenLifetime" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "tokenLifetime" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be tokenIssuance', async () => { - const actual = await command.validate({ - options: - { - type: "tokenIssuance" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "tokenIssuance" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be adminConsentRequest', async () => { - const actual = await command.validate({ - options: - { - type: "adminConsentRequest" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "adminConsentRequest" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be appManagement', async () => { - const actual = await command.validate({ - options: - { - type: "appManagement" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "appManagement" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be authenticationFlows', async () => { - const actual = await command.validate({ - options: - { - type: "authenticationFlows" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "authenticationFlows" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be authenticationMethods', async () => { - const actual = await command.validate({ - options: - { - type: "authenticationMethods" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "authenticationMethods" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be authenticationStrength', async () => { - const actual = await command.validate({ - options: - { - type: "authenticationStrength" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "authenticationStrength" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be conditionalAccess', async () => { - const actual = await command.validate({ - options: - { - type: "conditionalAccess" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "conditionalAccess" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be crossTenantAccess', async () => { - const actual = await command.validate({ - options: - { - type: "crossTenantAccess" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "crossTenantAccess" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be defaultAppManagement', async () => { - const actual = await command.validate({ - options: - { - type: "defaultAppManagement" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "defaultAppManagement" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be deviceRegistration', async () => { - const actual = await command.validate({ - options: - { - type: "deviceRegistration" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "deviceRegistration" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be permissionGrant', async () => { - const actual = await command.validate({ - options: - { - type: "permissionGrant" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "permissionGrant" + }); + assert.strictEqual(actual.success, true); }); it('accepts type to be roleManagement', async () => { - const actual = await command.validate({ - options: - { - type: "roleManagement" - } - }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: "roleManagement" + }); + assert.strictEqual(actual.success, true); }); it('rejects invalid type', async () => { const type = 'foo'; - const actual = await command.validate({ - options: { - type: type - } - }, commandInfo); - assert.notStrictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ + type: type + }); + assert.notStrictEqual(actual.success, true); }); }); diff --git a/src/m365/entra/commands/policy/policy-list.ts b/src/m365/entra/commands/policy/policy-list.ts index e4d0fa0c4d6..ac069b56770 100644 --- a/src/m365/entra/commands/policy/policy-list.ts +++ b/src/m365/entra/commands/policy/policy-list.ts @@ -1,17 +1,10 @@ +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; -interface CommandArgs { - options: Options; -} - -interface Options extends GlobalOptions { - type?: string; -} - const policyEndPoints: any = { activitybasedtimeout: "activityBasedTimeoutPolicies", adminconsentrequest: "adminConsentRequestPolicy", @@ -34,29 +27,20 @@ const policyEndPoints: any = { tokenlifetime: "tokenLifetimePolicies" }; -class EntraPolicyListCommand extends GraphCommand { - private static readonly supportedPolicyTypes: string[] = [ - 'activityBasedTimeout', - 'adminConsentRequest', - 'appManagement', - 'authenticationFlows', - 'authenticationMethods', - 'authenticationStrength', - 'authorization', - 'claimsMapping', - 'conditionalAccess', - 'crossTenantAccess', - 'defaultAppManagement', - 'deviceRegistration', - 'featureRolloutPolicy', - 'homeRealmDiscovery', - 'identitySecurityDefaultsEnforcement', - 'permissionGrant', - 'roleManagement', - 'tokenIssuance', - 'tokenLifetime' - ]; +const supportedPolicyTypes = ['activityBasedTimeout', 'adminConsentRequest', 'appManagement', 'authenticationFlows', 'authenticationMethods', 'authenticationStrength', 'authorization', 'claimsMapping', 'conditionalAccess', 'crossTenantAccess', 'defaultAppManagement', 'deviceRegistration', 'featureRolloutPolicy', 'homeRealmDiscovery', 'identitySecurityDefaultsEnforcement', 'permissionGrant', 'roleManagement', 'tokenIssuance', 'tokenLifetime'] as const; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + type: z.enum(supportedPolicyTypes).optional().alias('t') +}); + +declare type Options = z.infer; +interface CommandArgs { + options: Options; +} + +class EntraPolicyListCommand extends GraphCommand { public get name(): string { return commands.POLICY_LIST; } @@ -65,44 +49,8 @@ class EntraPolicyListCommand extends GraphCommand { return 'Returns policies from Entra ID'; } - constructor() { - super(); - - this.#initTelemetry(); - this.#initOptions(); - this.#initValidators(); - } - - #initTelemetry(): void { - this.telemetry.push((args: CommandArgs) => { - Object.assign(this.telemetryProperties, { - policyType: args.options.type || 'all' - }); - }); - } - - #initOptions(): void { - this.options.unshift( - { - option: '-t, --type [type]', - autocomplete: EntraPolicyListCommand.supportedPolicyTypes - } - ); - } - - #initValidators(): void { - this.validators.push( - async (args: CommandArgs) => { - if (args.options.type) { - const policyType: string = args.options.type.toLowerCase(); - if (!EntraPolicyListCommand.supportedPolicyTypes.find(p => p.toLowerCase() === policyType)) { - return `${args.options.type} is not a valid type. Allowed values are ${EntraPolicyListCommand.supportedPolicyTypes.join(', ')}`; - } - } - - return true; - } - ); + public get schema(): z.ZodType | undefined { + return options; } public defaultProperties(): string[] | undefined {