diff --git a/docs/docs/cmd/spe/container/container-permission-add.mdx b/docs/docs/cmd/spe/container/container-permission-add.mdx new file mode 100644 index 00000000000..b1f8fd70fad --- /dev/null +++ b/docs/docs/cmd/spe/container/container-permission-add.mdx @@ -0,0 +1,128 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spe container permission add + +Adds permission to SharePoint Embedded Container for a specified user + +## Usage + +```sh +m365 spe container permission add [options] +``` + +## Options + +```md definition-list +`-i, --containerId [containerId]` +: ID of a SharePoint Embedded container. Specify either `containerId` or `containerName` but not both. + +`-n, --containerName [containerName]` +: Display name of the Container. Specify either `containerId` or `containerName` but not both. + +`--containerTypeId [containerTypeId]` +: The ID of the container type. Specify either `containerTypeId` or `containerTypeName` when using `containerName` but not both. + +`--containerTypeName [containerTypeName]` +: The name of the container type. Specify either `containerTypeId` or `containerTypeName` when using `containerName` but not both. + +`-r, --roles `: +: Comma separated list of permissions. Possible values are `reader`, `writer`, `manager`, `owner`. + +`--userId [userId]` +: The id of user to assign role to. Use either `userId` or `userName` but not both. + +`--userName [userName]` +: The upn/email of user to assign role to. Use either `userId` or `userName` but not both. +``` + +## Permissions + + + + + | Resource | Permissions | + |-----------------|-------------------------------| + | Microsoft Graph | FileStorageContainer.Selected | + + + + + | Resource | Permissions | + |-----------------|-------------------------------| + | Microsoft Graph | FileStorageContainer.Selected | + + + + +## Examples + +Adds a reader role for the specified user to the container + +```sh +m365 spe container permission add --containerId "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --roles "reader" --userName "jacob@fabrikam.com" +``` + +Adds a multiple roles for the specified user to the container + +```sh +m365 spe container permission add --containerId "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --roles "reader,writer" --userName "jacob@fabrikam.com" +``` + +## Response + + + + + ```json + { + "id": "cJpbmNpcGFsT3duZAJfaLowIy5mfG1lbWJliZXJzaGlwfHJvcnlicjExMUBvdXRsb29rLmNvbQ", + "roles": ["reader"], + "grantedToV2": { + "user": { + "id": "89ea5c94-7736-4e25-95ad-3fa95f62b66e", + "userPrincipalName": "john.doe@contoso.com", + "displayName": "John Doe", + "email": "john.doe@contoso.com" + } + } + } + ``` + + + + + ```text + id userPrincipalName roles + -------------------------------------------------------------------------- --------------------- ------ + cJpbmNpcGFsT3duZAJfaLowIy5mfG1lbWJliZXJzaGlwfHJvcnlicjExMUBvdXRsb29rLmNvbQ john.doe@contoso..com reader + ``` + + + + + ```csv + id,roles,userPrincipalName + cJpbmNpcGFsT3duZAJfaLowIy5mfG1lbWJliZXJzaGlwfHJvcnlicjExMUBvdXRsb29rLmNvbQ,reader,john.doe@contoso.com + ``` + + + + + ```md + # spe container permission add --containerId "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --userId "89ea5c94-7736-4e25-95ad-3fa95f62b66e" --roles "reader" + + Date: 8/5/2025 + + ## X2k6MCMuZnxtZW1iZXJzaGlwfGRlYnJhYkBuYWNoYW4zNjUub25taWNyb3NvZnQuY29t + + Property | Value + ---------|------- + id | cJpbmNpcGFsT3duZAJfaLowIy5mfG1lbWJliZXJzaGlwfHJvcnlicjExMUBvdXRsb29rLmNvbQ + roles | reader + userPrincipalName | john.doe@contoso.com + ``` + + + \ No newline at end of file diff --git a/docs/docs/cmd/spe/containertype/containertype-add.mdx b/docs/docs/cmd/spe/containertype/containertype-add.mdx index 401678460d9..5dcbd9a9e68 100644 --- a/docs/docs/cmd/spe/containertype/containertype-add.mdx +++ b/docs/docs/cmd/spe/containertype/containertype-add.mdx @@ -104,7 +104,7 @@ The registration of a container type in a newly created tenant can fail if the t Adds a new trial container type. ```sh -m365 spe containertype add --name 'trial container' --applicationId '1b3b8660-9a44-4a7c-9c02-657f3ff5d5ac' --billingType trial +m365 spe containertype add --name 'trial container' --appId '1b3b8660-9a44-4a7c-9c02-657f3ff5d5ac' --billingType trial ``` Adds a new container type using a standard billing type for the current application. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index e6e537e6841..24dd93b4248 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -2216,6 +2216,11 @@ const sidebars: SidebarsConfig = { label: 'container remove', id: 'cmd/spe/container/container-remove' }, + { + type: 'doc', + label: 'container permission add', + id: 'cmd/spe/container/container-permission-add' + }, { type: 'doc', label: 'container permission list', diff --git a/src/m365/spe/commands.ts b/src/m365/spe/commands.ts index 142615a6fc2..81672756127 100644 --- a/src/m365/spe/commands.ts +++ b/src/m365/spe/commands.ts @@ -6,6 +6,7 @@ export default { CONTAINER_GET: `${prefix} container get`, CONTAINER_LIST: `${prefix} container list`, CONTAINER_REMOVE: `${prefix} container remove`, + CONTAINER_PERMISSION_ADD: `${prefix} container permission add`, CONTAINER_PERMISSION_LIST: `${prefix} container permission list`, CONTAINER_RECYCLEBINITEM_LIST: `${prefix} container recyclebinitem list`, CONTAINER_RECYCLEBINITEM_REMOVE: `${prefix} container recyclebinitem remove`, diff --git a/src/m365/spe/commands/container/container-permission-add.spec.ts b/src/m365/spe/commands/container/container-permission-add.spec.ts new file mode 100644 index 00000000000..10863c5d5e2 --- /dev/null +++ b/src/m365/spe/commands/container/container-permission-add.spec.ts @@ -0,0 +1,242 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandInfo } from "../../../../cli/CommandInfo.js"; +import { CommandError } from '../../../../Command.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command, { options } from './container-permission-add.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { session } from '../../../../utils/session.js'; +import { spe } from '../../../../utils/spe.js'; +import { cli } from '../../../../cli/cli.js'; + +describe(commands.CONTAINER_PERMISSION_ADD, () => { + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + + const containerTypeId = 'c6f08d91-77fa-485f-9369-f246ec0fc19c'; + const containerTypeName = 'Container type name'; + const containerId = 'b!McTeU0-dW0GxKwECWdW04TIvEK-Js9xJib_RFqF-CqZxNe3OHVAIT4SqBxGm4fND'; + const containerName = 'Container name'; + const userId = '12345678-90ab-cdef-1234-567890abcdef'; + const userName = 'john.doe@contoso.com'; + + const containerPermissionResponse = { + "id": "X2k6MCMuZnxtZW1iZXJzaGlwfGRlYnJhYkBuYWNoYW4zNjUub25taWNyb3NvZnQuY29t", + "roles": [ + "owner" + ], + "grantedToV2": { + "user": { + "displayName": "Debra Berger", + "email": "debra@contoso.onmicrosoft.com", + "userPrincipalName": "debra@contoso.onmicrosoft.com" + } + } + }; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').resolves(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + + sinon.stub(spe, 'getContainerTypeIdByName').withArgs(containerTypeName).resolves(containerTypeId); + sinon.stub(spe, 'getContainerIdByName').withArgs(containerTypeId, containerName).resolves(containerId); + sinon.stub(entraUser, 'getUpnByUserId').withArgs(userId).resolves(userName); + + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; + }); + + 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 + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CONTAINER_PERMISSION_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if both containerId and containerName options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, containerName: containerName, roles: 'reader' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if neither containerId nor containerName options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ roles: 'reader' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if containerId and containerTypeId options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, containerTypeId: containerTypeId, roles: 'reader', userId: userId }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if containerId and containerTypeName options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, containerTypeName: containerTypeName, roles: 'reader', userId: userId }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if containerName and both containerTypeId and containerTypeName options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ containerName: containerName, containerTypeId: containerTypeId, containerTypeName: containerTypeName, roles: 'reader', userId: userId }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if roles are not passed', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, userId: userId }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if both userId and userName are passed', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, roles: 'reader', userId: userId, userName: userName }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if neither userId nor userName are passed', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, roles: 'reader' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if userId is not a valid GUID', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, roles: 'reader', userId: 'foo' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if userName is not a valid UPN', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, roles: 'reader', userName: 'foo' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if correct role is not passed', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, roles: 'foo', userId: userId }); + assert.strictEqual(actual.success, false); + }); + + it('correctly adds permissions to a user for a container by id', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions`) { + return containerPermissionResponse; + } + + throw 'Invalid POST request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ containerId: containerId, roles: 'reader', userId: userId, verbose: true }) }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + roles: ['reader'], + grantedToV2: { + user: { + userPrincipalName: userName + } + } + }); + }); + + it('correctly adds permissions to a user for a container by name and container type by id', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions`) { + return containerPermissionResponse; + } + + throw 'Invalid POST request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ containerName: containerName, containerTypeId: containerTypeId, roles: 'reader', userId: userId, verbose: true }) }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + roles: ['reader'], + grantedToV2: { + user: { + userPrincipalName: userName + } + } + }); + }); + + it('correctly adds permissions to a user by UPN for a container by name and container type by name', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions`) { + return containerPermissionResponse; + } + + throw 'Invalid POST request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ containerName: containerName, containerTypeName: containerTypeName, roles: 'reader', userName: userName, verbose: true }) }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + roles: ['reader'], + grantedToV2: { + user: { + userPrincipalName: userName + } + } + }); + }); + + it('correctly adds multiple permissions to a user for a container by id', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions`) { + return containerPermissionResponse; + } + + throw 'Invalid POST request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ containerId: containerId, roles: 'reader,writer', userId: userId, verbose: true }) }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + roles: ['reader', 'writer'], + grantedToV2: { + user: { + userPrincipalName: userName + } + } + }); + }); + + it('correctly handles unexpected error', async () => { + const errorMessage = 'Access denied'; + sinon.stub(request, 'post').rejects({ + error: { + code: 'accessDenied', + message: errorMessage + } + }); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ containerId: containerId, roles: 'reader', userId: userId }) }), + new CommandError(errorMessage)); + }); +}); \ No newline at end of file diff --git a/src/m365/spe/commands/container/container-permission-add.ts b/src/m365/spe/commands/container/container-permission-add.ts new file mode 100644 index 00000000000..d5c022fe595 --- /dev/null +++ b/src/m365/spe/commands/container/container-permission-add.ts @@ -0,0 +1,125 @@ +import { z } from 'zod'; +import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import commands from '../../commands.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import { validation } from '../../../../utils/validation.js'; +import { spe } from '../../../../utils/spe.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { formatting } from '../../../../utils/formatting.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + containerId: z.string().alias('i').optional(), + containerName: z.string().alias('n').optional(), + containerTypeId: z.uuid().optional(), + containerTypeName: z.string().optional(), + roles: z.string().alias('r').transform((value) => value.split(',')).pipe(z.enum(['reader', 'writer', 'manager', 'owner']).array()), + userId: z.string().refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID.` + }).optional(), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid UPN.` + }).optional() +}); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpeContainerPermissionAddCommand extends GraphCommand { + public get name(): string { + return commands.CONTAINER_PERMISSION_ADD; + } + + public get description(): string { + return 'Adds permission to SharePoint Embedded Container for a specified user'; + } + + public get schema(): z.ZodType { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine((options: Options) => [options.containerId, options.containerName].filter(o => o !== undefined).length === 1, { + error: 'Use one of the following options: containerId or containerName.' + }) + .refine((options: Options) => [options.userId, options.userName].filter(o => o !== undefined).length === 1, { + error: 'Use one of the following options: userId or userName.' + }) + .refine((options: Options) => !options.containerName || [options.containerTypeId, options.containerTypeName].filter(o => o !== undefined).length === 1, { + error: 'Use one of the following options when specifying the container name: containerTypeId or containerTypeName.' + }) + .refine((options: Options) => options.containerName || [options.containerTypeId, options.containerTypeName].filter(o => o !== undefined).length === 0, { + error: 'Options containerTypeId and containerTypeName are only required when adding permissions to a container by name.' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + const containerId = await this.getContainerId(args.options, logger); + + if (this.verbose) { + await logger.logToStderr(`Adding permissions to container with ID '${containerId}'...`); + } + + let userName = args.options.userName; + if (args.options.userId) { + userName = await entraUser.getUpnByUserId(args.options.userId); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + roles: args.options.roles, + grantedToV2: { + user: { + userPrincipalName: userName + } + } + } + }; + + const container = await request.post(requestOptions); + await logger.log(container); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getContainerId(options: Options, logger: Logger): Promise { + if (options.containerId) { + return options.containerId; + } + + const containerTypeId = await this.getContainerTypeId(options, logger); + + if (this.verbose) { + await logger.logToStderr(`Getting container ID for container with name '${options.containerName}'...`); + } + + return spe.getContainerIdByName(containerTypeId, options.containerName!); + } + + private async getContainerTypeId(options: Options, logger: Logger): Promise { + if (options.containerTypeId) { + return options.containerTypeId; + } + + if (this.verbose) { + await logger.logToStderr(`Getting container type with name '${options.containerTypeName}'...`); + } + + return spe.getContainerTypeIdByName(options.containerTypeName!); + } +} + +export default new SpeContainerPermissionAddCommand();