From 094e5313423ae590c8e8d3411ad0493b0a1095c4 Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Sat, 9 May 2026 19:26:20 +0200 Subject: [PATCH] Adds 'spe container permission remove' command. Closes #6161 --- .../container/container-permission-remove.mdx | 72 +++++++ docs/src/config/sidebars.ts | 5 + src/m365/spe/commands.ts | 1 + .../container-permission-remove.spec.ts | 181 ++++++++++++++++++ .../container/container-permission-remove.ts | 110 +++++++++++ 5 files changed, 369 insertions(+) create mode 100644 docs/docs/cmd/spe/container/container-permission-remove.mdx create mode 100644 src/m365/spe/commands/container/container-permission-remove.spec.ts create mode 100644 src/m365/spe/commands/container/container-permission-remove.ts diff --git a/docs/docs/cmd/spe/container/container-permission-remove.mdx b/docs/docs/cmd/spe/container/container-permission-remove.mdx new file mode 100644 index 00000000000..8a4ef52f982 --- /dev/null +++ b/docs/docs/cmd/spe/container/container-permission-remove.mdx @@ -0,0 +1,72 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spe container permission remove + +Removes permissions from a SharePoint Embedded Container + +## Usage + +```sh +m365 spe container permission remove [options] +``` + +## Options + +```md definition-list +`-i, --id ` +: ID of the permission to remove. + +`--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. + +`-f, --force [force]` +: Don't prompt for confirmation. +``` + +## Permissions + + + + + | Resource | Permissions | + |-----------------|-------------------------------| + | Microsoft Graph | FileStorageContainer.Selected | + + + + + | Resource | Permissions | + |-----------------|-------------------------------| + | Microsoft Graph | FileStorageContainer.Selected | + + + + +## Examples + +Removes the specified permission from a container + +```sh +m365 spe container permission remove --containerId "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --Id "cmVhZGVyX2k6MCMuZnxtZW1iZXJzaGlwfHJvcnlicjExMUBvdXRsb29rLmNvbQ" +``` + +Removes the specified permission from a container and doesn't prompt for confirmation + +```sh +m365 spe container permission remove --containerId "b!ISJs1WRro0y0EWgkUYcktDa0mE8zSlFEqFzqRn70Zwp1CEtDEBZgQICPkRbil_5Z" --Id "cmVhZGVyX2k6MCMuZnxtZW1iZXJzaGlwfHJvcnlicjExMUBvdXRsb29rLmNvbQ" --force +``` + +## 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..e4255bfa7f2 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 remove', + id: 'cmd/spe/container/container-permission-remove' + }, { type: 'doc', label: 'container recyclebinitem list', diff --git a/src/m365/spe/commands.ts b/src/m365/spe/commands.ts index 142615a6fc2..aa1a68c1caa 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_REMOVE: `${prefix} container permission remove`, 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-remove.spec.ts b/src/m365/spe/commands/container/container-permission-remove.spec.ts new file mode 100644 index 00000000000..6c4ead648ce --- /dev/null +++ b/src/m365/spe/commands/container/container-permission-remove.spec.ts @@ -0,0 +1,181 @@ +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-remove.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_REMOVE, () => { + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + let promptIssued: boolean; + + 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); + } + }; + sinon.stub(cli, 'promptForConfirmation').callsFake(() => { + promptIssued = true; + return Promise.resolve(false); + }); + }); + + afterEach(() => { + sinonUtil.restore([ + request.delete, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CONTAINER_PERMISSION_REMOVE); + }); + + 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 }); + 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 }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if neither containerId nor containerName options are passed', async () => { + const actual = commandOptionsSchema.safeParse({ id: permissionId }); + 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 }); + 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 }); + 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 }); + assert.strictEqual(actual.success, false); + }); + + it('correctly removes permissions for a container by id', async () => { + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions/${permissionId}`) { + return; + } + + throw 'Invalid DELETE request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerId: containerId, verbose: true, force: true }) }); + assert(deleteStub.calledOnce); + }); + + it('correctly removes permissions for a container by name and container type by id and prompts for confirmation', async () => { + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/storage/fileStorage/containers/${formatting.encodeQueryParameter(containerId)}/permissions/${permissionId}`) { + return; + } + + throw 'Invalid DELETE request: ' + opts.url; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerName: containerName, containerTypeId: containerTypeId, verbose: true }) }); + assert(deleteStub.calledOnce); + }); + + it('correctly removes permissions for a container by name and container type by name', async () => { + const deleteStub = sinon.stub(request, 'delete').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, verbose: true, force: true }) }); + assert(deleteStub.calledOnce); + }); + + it('prompts before removing permissions when confirm option not passed', async () => { + await command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerId: containerId }) }); + + assert(promptIssued); + }); + + it('aborts removing permissions when prompt not confirmed', async () => { + const deleteSpy = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerId: containerId }) }); + assert(deleteSpy.notCalled); + }); + + it('correctly handles unexpected error', async () => { + const errorMessage = 'Access denied'; + sinon.stub(request, 'delete').rejects({ + error: { + code: 'accessDenied', + message: errorMessage + } + }); + + await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ id: permissionId, containerId: containerId, force: true }) }), + new CommandError(errorMessage)); + }); +}); \ No newline at end of file diff --git a/src/m365/spe/commands/container/container-permission-remove.ts b/src/m365/spe/commands/container/container-permission-remove.ts new file mode 100644 index 00000000000..d908d725491 --- /dev/null +++ b/src/m365/spe/commands/container/container-permission-remove.ts @@ -0,0 +1,110 @@ +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'; +import { cli } from '../../../../cli/cli.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(), + force: z.boolean().alias('f').optional() +}); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpeContainerPermissionRemoveCommand extends GraphCommand { + public get name(): string { + return commands.CONTAINER_PERMISSION_REMOVE; + } + + public get description(): string { + return 'Removes 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 removing permissions from a container by name.' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (!args.options.force) { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove permission '${args.options.id}' from container '${args.options.containerId || args.options.containerName}'?` }); + + if (!result) { + return; + } + } + + try { + const containerId = await this.getContainerId(args.options, logger); + + if (this.verbose) { + await logger.logToStderr(`Removing permissions from 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' + }; + + await request.delete(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 SpeContainerPermissionRemoveCommand(); \ No newline at end of file