diff --git a/docs/docs/cmd/aad/administrativeunit/administrativeunit-member-add.mdx b/docs/docs/cmd/aad/administrativeunit/administrativeunit-member-add.mdx new file mode 100644 index 00000000000..5c06d471d86 --- /dev/null +++ b/docs/docs/cmd/aad/administrativeunit/administrativeunit-member-add.mdx @@ -0,0 +1,93 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# aad administrativeunit member add + +Add a member (user, group, or device) to an administrative unit + +## Usage + +```sh +m365 aad administrativeunit member add [options] +``` + +## Options + +```md definition-list +`-i, --administrativeUnitId [administrativeUnitId]` +: The id of the administrative unit. Specify either `administrativeUnitId` or `administrativeUnitName` but not both. + +`-n, --administrativeUnitName [administrativeUnitName]` +: The name of the administrative unit. Specify either `administrativeUnitId` or `administrativeUnitName` but not both. + +`--userId [userId]` +: The id of the user to be added. Specify `userId`, `userName`, `groupId`, `groupName`, `deviceId` or `deviceName`. + +`--userName [userName]` +: The user principal name (UPN) of the user to be added. Specify `userId`, `userName`, `groupId`, `groupName`, `deviceId` or `deviceName`. + +`--groupId [groupId]` +: The id of the group to be added. Specify `userId`, `userName`, `groupId`, `groupName`, `deviceId` or `deviceName`. + +`--groupName [groupName]` +: The name of the group to be added. Specify `userId`, `userName`, `groupId`, `groupName`, `deviceId` or `deviceName`. + +`--deviceId [deviceId]` +: The id of the device to be added. Specify `userId`, `userName`, `groupId`, `groupName`, `deviceId` or `deviceName`. + +`--deviceName [deviceName]` +: The name of the device to be added. Specify `userId`, `userName`, `groupId`, `groupName`, `deviceId` or `deviceName`. +``` + + + +## Remarks + +:::info + +To use this command you must be either **Global Administrator** or **Privileged Role Administrator**. + +::: + +## Examples + +Add a single user specified by id to an administrative unit specified by id + +```sh +m365 aad administrativeunit member add --administrativeUnitId 03c4c9dc-6f0c-4c4f-a4e6-0c9ed80f54c7 --userId 1caf7dcd-7e83-4c3a-94f7-932a1299c844 +``` + +Add a single user specified by user principal name to an administrative unit specified by name + +```sh +m365 aad administrativeunit member add --administrativeUnitName 'Marketing Division' --userName john.doe@contoso.com +``` + +Add a single group specified by id to an administrative unit specified by id + +```sh +m365 aad administrativeunit member add --administrativeUnitId 03c4c9dc-6f0c-4c4f-a4e6-0c9ed80f54c7 --groupId b2307a39-e878-458b-bc90-03bc578531d6 +``` + +Add a single group specified by name to an administrative unit specified by name + +```sh +m365 aad administrativeunit member add --administrativeUnitName 'Marketing Division' --groupName 'Marketing Group' +``` + +Add a single device specified by id to an administrative unit specified by id + +```sh +m365 aad administrativeunit member add --administrativeUnitId 03c4c9dc-6f0c-4c4f-a4e6-0c9ed80f54c7 --deviceId 810c84a8-4a9e-49e6-bf7d-12d183f40d01 +``` + +Add a single device specified by name to an administrative unit specified by name + +```sh +m365 aad administrativeunit member add --administrativeUnitName 'Marketing Division' --deviceName 'JohnDoe-PC' +``` + +## More information + +- Administrative units: https://learn.microsoft.com/entra/identity/role-based-access-control/administrative-units \ No newline at end of file diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index a1853499a36..f5a5e2365d6 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -47,6 +47,11 @@ const sidebars: SidebarsConfig = { label: 'administrativeunit remove', id: 'cmd/aad/administrativeunit/administrativeunit-remove' }, + { + type: 'doc', + label: 'administrativeunit member add', + id: 'cmd/aad/administrativeunit/administrativeunit-member-add' + }, { type: 'doc', label: 'administrativeunit member list', diff --git a/src/m365/aad/commands.ts b/src/m365/aad/commands.ts index 6183fa4399d..ed8c1025e30 100644 --- a/src/m365/aad/commands.ts +++ b/src/m365/aad/commands.ts @@ -5,6 +5,7 @@ export default { ADMINISTRATIVEUNIT_GET: `${prefix} administrativeunit get`, ADMINISTRATIVEUNIT_LIST: `${prefix} administrativeunit list`, ADMINISTRATIVEUNIT_REMOVE: `${prefix} administrativeunit remove`, + ADMINISTRATIVEUNIT_MEMBER_ADD: `${prefix} administrativeunit member add`, ADMINISTRATIVEUNIT_MEMBER_LIST: `${prefix} administrativeunit member list`, APP_ADD: `${prefix} app add`, APP_GET: `${prefix} app get`, diff --git a/src/m365/aad/commands/administrativeunit/administrativeunit-member-add.spec.ts b/src/m365/aad/commands/administrativeunit/administrativeunit-member-add.spec.ts new file mode 100644 index 00000000000..c13cf749a72 --- /dev/null +++ b/src/m365/aad/commands/administrativeunit/administrativeunit-member-add.spec.ts @@ -0,0 +1,466 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import commands from '../../commands.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import { pid } from '../../../../utils/pid.js'; +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 './administrativeunit-member-add.js'; +import { aadAdministrativeUnit } from '../../../../utils/aadAdministrativeUnit.js'; +import { aadGroup } from '../../../../utils/aadGroup.js'; +import { aadUser } from '../../../../utils/aadUser.js'; +import { aadDevice } from '../../../../utils/aadDevice.js'; +import { settingsNames } from '../../../../settingsNames.js'; + +describe(commands.ADMINISTRATIVEUNIT_MEMBER_ADD, () => { + const administrativeUnitId = 'fc33aa61-cf0e-46b6-9506-f633347202ab'; + const administrativeUnitName = 'European Division'; + const userId = '23b415fb-baea-4995-a26e-c74073beadff'; + const userName = 'adele.vence@contoso.com'; + const groupId = '593af7e2-d27e-42b8-ad17-abe5e57dab61'; + const groupName = 'Marketing'; + const deviceId = '60c99a96-70af-4d68-a8dc-5c51b345c6ce'; + const deviceName = 'AdeleVence-PC'; + + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.service.connected = true; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.post, + aadAdministrativeUnit.getAdministrativeUnitByDisplayName, + aadUser.getUserIdByUpn, + aadGroup.getGroupIdByDisplayName, + aadDevice.getDeviceByDisplayName, + cli.getSettingWithDefaultValue, + cli.handleMultipleResultsFound, + cli.promptForSelection + ]); + }); + + after(() => { + sinon.restore(); + auth.service.connected = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ADMINISTRATIVEUNIT_MEMBER_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('passes validation when administrativeUnitId is a valid GUID', async () => { + const actual = await command.validate({ options: { administrativeUnitId: administrativeUnitId, userId: '00000000-0000-0000-0000-000000000000' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if administrativeUnitId is not a valid GUID', async () => { + const actual = await command.validate({ options: { administrativeUnitId: 'invalid', userId: '00000000-0000-0000-0000-000000000000' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation when userId is a valid GUID', async () => { + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userId: userId } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if userId is not a valid GUID', async () => { + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation when groupId is a valid GUID', async () => { + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', groupId: groupId } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if groupId is not a valid GUID', async () => { + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', groupId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation when deviceId is a valid GUID', async () => { + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', deviceId: deviceId } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if deviceId is not a valid GUID', async () => { + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', deviceId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both administrativeUnitId and administrativeUnitName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: administrativeUnitId, administrativeUnitName: administrativeUnitName, userId: '00000000-0000-0000-0000-000000000000' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if both administrativeUnitId and administrativeUnitName options are not passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { userId: userId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both userId and userName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userId: userId, userName: userName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both userId and groupId options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userId: userId, groupId: groupId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both userId and groupName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userId: userId, groupName: groupName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both userId and deviceId options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userId: userId, deviceId: deviceId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both userId and deviceName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userId: userId, deviceName: deviceName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both userName and groupId options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userName: userName, groupId: groupId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both userName and groupName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userName: userName, groupName: groupName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both userName and deviceId options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userName: userName, deviceId: deviceId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both userName and deviceName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', userName: userName, deviceName: deviceName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both groupId and groupName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', groupId: groupId, groupName: groupName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both groupId and deviceId options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', groupId: groupId, deviceId: deviceId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both groupId and deviceName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', groupId: groupId, deviceName: deviceName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both groupName and deviceId options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', groupName: groupName, deviceId: deviceId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both groupName and deviceName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', groupName: groupName, deviceName: deviceName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when both deviceId and deviceName options are passed', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ options: { administrativeUnitId: '00000000-0000-0000-0000-000000000000', deviceId: deviceId, deviceName: deviceName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if all the required options are specified', async () => { + const actual = await command.validate({ options: { administrativeUnitId: administrativeUnitId, userId: userId } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('adds a user specified by its id to an administrative unit specified by its id', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/directory/administrativeUnits/${administrativeUnitId}/members/$ref`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { administrativeUnitId: administrativeUnitId, userId: userId } }); + assert(postStub.called); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { "@odata.id": `https://graph.microsoft.com/v1.0/users/${userId}` }); + }); + + it('adds a user specified by its name to an administrative unit specified by its name (verbose)', async () => { + sinon.stub(aadUser, 'getUserIdByUpn').withArgs(userName).resolves(userId); + sinon.stub(aadAdministrativeUnit, 'getAdministrativeUnitByDisplayName').withArgs(administrativeUnitName).resolves({ id: administrativeUnitId, displayName: administrativeUnitName }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/directory/administrativeUnits/${administrativeUnitId}/members/$ref`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { administrativeUnitName: administrativeUnitName, userName: userName, verbose: true } }); + assert(postStub.called); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { "@odata.id": `https://graph.microsoft.com/v1.0/users/${userId}` }); + }); + + it('adds a group specified by its id to an administrative unit specified by its id', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/directory/administrativeUnits/${administrativeUnitId}/members/$ref`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { administrativeUnitId: administrativeUnitId, groupId: groupId } }); + assert(postStub.called); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { "@odata.id": `https://graph.microsoft.com/v1.0/groups/${groupId}` }); + }); + + it('adds a group specified by its name to an administrative unit specified by its name (verbose)', async () => { + sinon.stub(aadGroup, 'getGroupIdByDisplayName').withArgs(groupName).resolves(groupId); + sinon.stub(aadAdministrativeUnit, 'getAdministrativeUnitByDisplayName').withArgs(administrativeUnitName).resolves({ id: administrativeUnitId, displayName: administrativeUnitName }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/directory/administrativeUnits/${administrativeUnitId}/members/$ref`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { administrativeUnitName: administrativeUnitName, groupName: groupName, verbose: true } }); + assert(postStub.called); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { "@odata.id": `https://graph.microsoft.com/v1.0/groups/${groupId}` }); + }); + + it('adds a device specified by its id to an administrative unit specified by its id', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/directory/administrativeUnits/${administrativeUnitId}/members/$ref`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { administrativeUnitId: administrativeUnitId, deviceId: deviceId } }); + assert(postStub.called); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { "@odata.id": `https://graph.microsoft.com/v1.0/devices/${deviceId}` }); + }); + + it('adds a device specified by its name to an administrative unit specified by its name (verbose)', async () => { + sinon.stub(aadDevice, 'getDeviceByDisplayName').withArgs(deviceName).resolves({ id: deviceId, displayName: deviceName }); + sinon.stub(aadAdministrativeUnit, 'getAdministrativeUnitByDisplayName').withArgs(administrativeUnitName).resolves({ id: administrativeUnitId, displayName: administrativeUnitName }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/directory/administrativeUnits/${administrativeUnitId}/members/$ref`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { administrativeUnitName: administrativeUnitName, deviceName: deviceName, verbose: true } }); + assert(postStub.called); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { "@odata.id": `https://graph.microsoft.com/v1.0/devices/${deviceId}` }); + }); + + it('throws an error when administrative unit by id cannot be found', async () => { + const error = { + error: { + code: 'Request_ResourceNotFound', + message: `Resource '${administrativeUnitId}' does not exist or one of its queried reference-property objects are not present.`, + innerError: { + date: '2023-10-27T12:24:36', + 'request-id': 'b7dee9ee-d85b-4e7a-8686-74852cbfd85b', + 'client-request-id': 'b7dee9ee-d85b-4e7a-8686-74852cbfd85b' + } + } + }; + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/directory/administrativeUnits/${administrativeUnitId}/members/$ref`) { + throw error; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { administrativeUnitId: administrativeUnitId, userId: userId } }), new CommandError(error.error.message)); + }); +}); \ No newline at end of file diff --git a/src/m365/aad/commands/administrativeunit/administrativeunit-member-add.ts b/src/m365/aad/commands/administrativeunit/administrativeunit-member-add.ts new file mode 100644 index 00000000000..59bb25e8518 --- /dev/null +++ b/src/m365/aad/commands/administrativeunit/administrativeunit-member-add.ts @@ -0,0 +1,185 @@ +import GlobalOptions from "../../../../GlobalOptions.js"; +import { Logger } from "../../../../cli/Logger.js"; +import { aadAdministrativeUnit } from "../../../../utils/aadAdministrativeUnit.js"; +import { aadGroup } from "../../../../utils/aadGroup.js"; +import { aadUser } from "../../../../utils/aadUser.js"; +import { validation } from "../../../../utils/validation.js"; +import GraphCommand from "../../../base/GraphCommand.js"; +import commands from "../../commands.js"; +import request, { CliRequestOptions } from "../../../../request.js"; +import { aadDevice } from "../../../../utils/aadDevice.js"; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + administrativeUnitId?: string; + administrativeUnitName?: string; + userId?: string; + userName?: string; + groupId?: string; + groupName?: string; + deviceId?: string; + deviceName?: string; +} + +class AadAdministrativeUnitMemberAddCommand extends GraphCommand { + public get name(): string { + return commands.ADMINISTRATIVEUNIT_MEMBER_ADD; + } + + public get description(): string { + return 'Adds a member (user, group, device) to an administrative unit'; + } + + 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', + deviceId: typeof args.options.deviceId !== 'undefined', + deviceName: typeof args.options.deviceName !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-i, --administrativeUnitId [administrativeUnitId]' + }, + { + option: '-n, --administrativeUnitName [administrativeUnitName]' + }, + { + option: "--userId [userId]" + }, + { + option: "--userName [userName]" + }, + { + option: "--groupId [groupId]" + }, + { + option: "--groupName [groupName]" + }, + { + option: "--deviceId [deviceId]" + }, + { + option: "--deviceName [deviceName]" + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.administrativeUnitId && !validation.isValidGuid(args.options.administrativeUnitId as string)) { + return `${args.options.administrativeUnitId} is not a valid GUID`; + } + + if (args.options.userId && !validation.isValidGuid(args.options.userId as string)) { + return `${args.options.userId} is not a valid GUID`; + } + + if (args.options.groupId && !validation.isValidGuid(args.options.groupId as string)) { + return `${args.options.groupId} is not a valid GUID`; + } + + if (args.options.deviceId && !validation.isValidGuid(args.options.deviceId as string)) { + return `${args.options.deviceId} is not a valid GUID`; + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['administrativeUnitId', 'administrativeUnitName'] }); + this.optionSets.push({ options: ['userId', 'userName', 'groupId', 'groupName', 'deviceId', 'deviceName'] }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + let administrativeUnitId = args.options.administrativeUnitId; + let memberType; + let memberId; + + try { + if (args.options.administrativeUnitName) { + administrativeUnitId = (await aadAdministrativeUnit.getAdministrativeUnitByDisplayName(args.options.administrativeUnitName)).id!; + + if (this.verbose) { + await logger.logToStderr(`Administrative unit with name ${args.options.administrativeUnitName} retrieved, returned id: ${administrativeUnitId}`); + } + } + + if (args.options.userId || args.options.userName) { + memberType = 'users'; + memberId = args.options.userId; + + if (args.options.userName) { + memberId = await aadUser.getUserIdByUpn(args.options.userName); + + if (this.verbose) { + await logger.logToStderr(`User with name ${args.options.userName} retrieved, returned id: ${memberId}`); + } + } + } + else if (args.options.groupId || args.options.groupName) { + memberType = 'groups'; + memberId = args.options.groupId; + + if (args.options.groupName) { + memberId = await aadGroup.getGroupIdByDisplayName(args.options.groupName); + + if (this.verbose) { + await logger.logToStderr(`Group with name ${args.options.groupName} retrieved, returned id: ${memberId}`); + } + } + } + else if (args.options.deviceId || args.options.deviceName) { + memberType = 'devices'; + memberId = args.options.deviceId; + + if (args.options.deviceName) { + memberId = (await aadDevice.getDeviceByDisplayName(args.options.deviceName)).id; + + if (this.verbose) { + await logger.logToStderr(`Device with name ${args.options.deviceName} retrieved, returned id: ${memberId}`); + } + } + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/directory/administrativeUnits/${administrativeUnitId}/members/$ref`, + headers: { + 'accept': 'application/json;odata.metadata=none' + }, + data: { + "@odata.id": `https://graph.microsoft.com/v1.0/${memberType}/${memberId}` + } + }; + + await request.post(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new AadAdministrativeUnitMemberAddCommand(); \ No newline at end of file diff --git a/src/utils/aadDevice.spec.ts b/src/utils/aadDevice.spec.ts new file mode 100644 index 00000000000..09d22f8fe4e --- /dev/null +++ b/src/utils/aadDevice.spec.ts @@ -0,0 +1,102 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import { aadDevice } from './aadDevice.js'; +import { cli } from "../cli/cli.js"; +import request from "../request.js"; +import { sinonUtil } from "./sinonUtil.js"; +import { formatting } from './formatting.js'; +import { settingsNames } from '../settingsNames.js'; + + +describe('utils/aadDevice', () => { + const deviceId = 'fc33aa61-cf0e-46b6-9506-f633347202ab'; + const secondDeviceId = 'fc33aa61-cf0e-1234-9506-f633347202ab'; + const displayName = 'AdeleVence-PC'; + const secondDisplayName = 'JohnDoe-PC'; + const invalidDisplayName = 'European'; + + afterEach(() => { + sinonUtil.restore([ + request.get, + cli.getSettingWithDefaultValue, + cli.handleMultipleResultsFound + ]); + }); + + it('correctly get single device by name using getDeviceByDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/devices?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + { + id: deviceId, + displayName: displayName + } + ] + }; + } + + return 'Invalid Request'; + }); + + const actual = await aadDevice.getDeviceByDisplayName(displayName); + assert.deepStrictEqual(actual, { id: deviceId, displayName: displayName }); + }); + + it('handles selecting single device when multiple devices with the specified name found using getDeviceByDisplayName and cli is set to prompt', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/devices?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + { id: deviceId, displayName: displayName }, + { id: secondDeviceId, displayName: secondDisplayName } + ] + }; + } + + return 'Invalid Request'; + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves({ id: deviceId, displayName: displayName }); + + const actual = await aadDevice.getDeviceByDisplayName(displayName); + assert.deepStrictEqual(actual, { id: deviceId, displayName: displayName }); + }); + + it('throws error message when no device was found using getDeviceByDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/devices?$filter=displayName eq '${formatting.encodeQueryParameter(invalidDisplayName)}'`) { + return { value: [] }; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(aadDevice.getDeviceByDisplayName(invalidDisplayName)), Error(`The specified device '${invalidDisplayName}' does not exist.`); + }); + + it('throws error message when multiple devices were found using getDeviceByDisplayName', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/devices?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + { id: deviceId }, + { id: deviceId } + ] + }; + } + + return 'Invalid Request'; + }); + + await assert.rejects(aadDevice.getDeviceByDisplayName(displayName), Error(`Multiple devices with name '${displayName}' found. Found: ${deviceId}.`)); + }); +}); \ No newline at end of file diff --git a/src/utils/aadDevice.ts b/src/utils/aadDevice.ts new file mode 100644 index 00000000000..808ed956a3f --- /dev/null +++ b/src/utils/aadDevice.ts @@ -0,0 +1,30 @@ +import { Device } from "@microsoft/microsoft-graph-types"; +import { odata } from "./odata.js"; +import { formatting } from "./formatting.js"; +import { cli } from "../cli/cli.js"; + +const graphResource = 'https://graph.microsoft.com'; + +export const aadDevice = { + /** + * Get a device by its display name. + * @param displayName Device display name. + * @returns The device. + * @throws Error when device was not found. + */ + async getDeviceByDisplayName(displayName: string): Promise { + const devices = await odata.getAllItems(`${graphResource}/v1.0/devices?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`); + + if (devices.length === 0) { + throw `The specified device '${displayName}' does not exist.`; + } + + if (devices.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('id', devices); + const selectedDevice = await cli.handleMultipleResultsFound(`Multiple devices with name '${displayName}' found.`, resultAsKeyValuePair); + return selectedDevice; + } + + return devices[0]; + } +}; \ No newline at end of file