From d079d12f1c2e51b11d9f320b0a91b9035a8abe9e Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Fri, 8 May 2026 20:09:33 +0200 Subject: [PATCH] Adds 'spec container permission set' command. Closes #6160 --- .../container/container-permission-set.mdx | 72 +++++++ docs/src/config/sidebars.ts | 5 + src/m365/spe/commands.ts | 1 + .../container-permission-set.spec.ts | 190 ++++++++++++++++++ .../container/container-permission-set.ts | 104 ++++++++++ 5 files changed, 372 insertions(+) create mode 100644 docs/docs/cmd/spe/container/container-permission-set.mdx create mode 100644 src/m365/spe/commands/container/container-permission-set.spec.ts create mode 100644 src/m365/spe/commands/container/container-permission-set.ts diff --git a/docs/docs/cmd/spe/container/container-permission-set.mdx b/docs/docs/cmd/spe/container/container-permission-set.mdx new file mode 100644 index 00000000000..d6c88487202 --- /dev/null +++ b/docs/docs/cmd/spe/container/container-permission-set.mdx @@ -0,0 +1,72 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spe container permission set + +Updates SharePoint Embedded Container permission + +## Usage + +```sh +m365 spe container permission set [options] +``` + +## Options + +```md definition-list +`-i, --id ` +: ID of the permission which will be modified. + +`--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`. +``` + +## Permissions + + + + + | Resource | Permissions | + |-----------------|-------------------------------| + | Microsoft Graph | FileStorageContainer.Selected | + + + + + | Resource | Permissions | + |-----------------|-------------------------------| + | Microsoft Graph | FileStorageContainer.Selected | + + + + +## Examples + +Updates role for the specified container + +```sh +m365 spe container permission set --containerId "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --id "cmVhZGVyX2k6MCMuZnxtZW1iZXJzaGlwfHJvcnlicjExMUBvdXRsb29rLmNvbQ" --roles "reader" +``` + +Updates multiple roles for the specified container + +```sh +m365 spe container permission set --containerId "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --id "cmVhZGVyX2k6MCMuZnxtZW1iZXJzaGlwfHJvcnlicjExMUBvdXRsb29rLmNvbQ" --roles "reader,writer" +``` + +## Response + +The command won't return a response on success. \ No newline at end of file diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index e6e537e6841..09142db17dc 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -2221,6 +2221,11 @@ const sidebars: SidebarsConfig = { label: 'container permission list', id: 'cmd/spe/container/container-permission-list' }, + { + type: 'doc', + label: 'container permission set', + id: 'cmd/spe/container/container-permission-set' + }, { type: 'doc', label: 'container recyclebinitem list', diff --git a/src/m365/spe/commands.ts b/src/m365/spe/commands.ts index 142615a6fc2..d8fd568d195 100644 --- a/src/m365/spe/commands.ts +++ b/src/m365/spe/commands.ts @@ -7,6 +7,7 @@ export default { CONTAINER_LIST: `${prefix} container list`, CONTAINER_REMOVE: `${prefix} container remove`, CONTAINER_PERMISSION_LIST: `${prefix} container permission list`, + CONTAINER_PERMISSION_SET: `${prefix} container permission set`, CONTAINER_RECYCLEBINITEM_LIST: `${prefix} container recyclebinitem list`, CONTAINER_RECYCLEBINITEM_REMOVE: `${prefix} container recyclebinitem remove`, CONTAINER_RECYCLEBINITEM_RESTORE: `${prefix} container recyclebinitem restore`, diff --git a/src/m365/spe/commands/container/container-permission-set.spec.ts b/src/m365/spe/commands/container/container-permission-set.spec.ts new file mode 100644 index 00000000000..3eb5f1b7c8b --- /dev/null +++ b/src/m365/spe/commands/container/container-permission-set.spec.ts @@ -0,0 +1,190 @@ +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-set.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_SET, () => { + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + + const permissionId = 'cmVhZGVyX2k6MCMuZnxtZW1iZXJzaGlwfHJvcnlicjExMUBvdXRsb29rLmNvbQ'; + const containerTypeId = 'c6f08d91-77fa-485f-9369-f246ec0fc19c'; + const containerTypeName = 'Container type name'; + const containerId = 'b!McTeU0-dW0GxKwECWdW04TIvEK-Js9xJib_RFqF-CqZxNe3OHVAIT4SqBxGm4fND'; + const containerName = 'Container name'; + + 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); + + 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.patch + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CONTAINER_PERMISSION_SET); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if permission id is not passed', async () => { + const actual = commandOptionsSchema.safeParse({ containerId: containerId, roles: 'reader' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if both containerId and containerName options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ id: permissionId, 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({ id: permissionId, roles: 'reader' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if containerId and containerTypeId options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ id: permissionId, containerId: containerId, containerTypeId: containerTypeId, roles: 'reader' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if containerId and containerTypeName options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ id: permissionId, containerId: containerId, containerTypeName: containerTypeName, roles: 'reader' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if containerName and both containerTypeId and containerTypeName options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ id: permissionId, containerName: containerName, containerTypeId: containerTypeId, containerTypeName: containerTypeName, roles: 'reader' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if roles are not passed', async () => { + const actual = commandOptionsSchema.safeParse({ id: permissionId, containerId: containerId }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if correct role is not passed', async () => { + const actual = commandOptionsSchema.safeParse({ id: permissionId, containerId: containerId, roles: 'foo' }); + assert.strictEqual(actual.success, false); + }); + + it('correctly updates permissions for a container by id', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions/${permissionId}`) { + return; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerId: containerId, roles: 'reader', verbose: true }) }); + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + roles: ['reader'] + }); + }); + + it('correctly updates permissions for a container by name and container type by id', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions/${permissionId}`) { + return; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerName: containerName, containerTypeId: containerTypeId, roles: 'reader', verbose: true }) }); + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + roles: ['reader'] + }); + }); + + it('correctly updates permissions for a container by name and container type by name', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions/${permissionId}`) { + return; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerName: containerName, containerTypeName: containerTypeName, roles: 'reader', verbose: true }) }); + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + roles: ['reader'] + }); + }); + + it('correctly updates multiple permissions for a container by id', async () => { + const patchStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions/${permissionId}`) { + return; + } + + throw 'Invalid PATCH request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerId: containerId, roles: 'reader,writer', verbose: true }) }); + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + roles: ['reader', 'writer'] + }); + }); + + it('correctly handles unexpected error', async () => { + const errorMessage = 'Access denied'; + sinon.stub(request, 'patch').rejects({ + error: { + code: 'accessDenied', + message: errorMessage + } + }); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerId: containerId, roles: 'reader' }) }), + new CommandError(errorMessage)); + }); +}); \ No newline at end of file diff --git a/src/m365/spe/commands/container/container-permission-set.ts b/src/m365/spe/commands/container/container-permission-set.ts new file mode 100644 index 00000000000..63db1dea10d --- /dev/null +++ b/src/m365/spe/commands/container/container-permission-set.ts @@ -0,0 +1,104 @@ +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 { spe } from '../../../../utils/spe.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + containerId: z.string().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()) +}); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpeContainerPermissionSetCommand extends GraphCommand { + public get name(): string { + return commands.CONTAINER_PERMISSION_SET; + } + + public get description(): string { + return 'Updates SharePoint Embedded Container permission'; + } + + 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.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}'...`); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions/${args.options.id}`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + roles: args.options.roles + } + }; + + await request.patch(requestOptions); + } + 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 SpeContainerPermissionSetCommand(); \ No newline at end of file