diff --git a/messages/delete_sandbox.md b/messages/delete_sandbox.md index bf24b3f7a..34e16a7ca 100644 --- a/messages/delete_sandbox.md +++ b/messages/delete_sandbox.md @@ -54,3 +54,24 @@ Ensure the CLI has authenticated with the sandbox's production org. # error.missingUsername Unable to determine the username of the org to delete. Specify the username with the --target-org | -o flag. + +# error.insufficientPermissions + +You don't have the required "DeleteSandbox" Permission Set assigned in the production org to delete sandbox "%s". + +# error.insufficientPermissions.actions + +- Ask your administrator to create a Permission Set named "DeleteSandbox" in the production org and assign it to your user. +- Re-authenticate with the production org and try again. + +# error.missingProdOrg + +Unable to verify delete permissions for sandbox "%s" because the production org could not be resolved from the sandbox configuration. + +# error.missingProdOrg.actions + +- Ensure the production org that owns this sandbox is authenticated with the CLI using `sf org login web`. + +# error.insufficientAccess + +You don't have permission to delete this sandbox. Ask your Salesforce admin to grant you the "Manage Sandboxes" system permission on your profile or a permission set in the production org. diff --git a/src/commands/org/delete/sandbox.ts b/src/commands/org/delete/sandbox.ts index 1646a15d5..5c6e28075 100644 --- a/src/commands/org/delete/sandbox.ts +++ b/src/commands/org/delete/sandbox.ts @@ -61,20 +61,27 @@ export default class DeleteSandbox extends SfCommand { throw messages.createError('error.unknownSandbox', [username]); } + await this.verifyDeletePermission(stateAggregator, orgId, username); + + const org = await Org.create({ aliasOrUsername: username }); + if (flags['no-prompt'] || (await this.confirm({ message: messages.getMessage('prompt.confirm', [username]) }))) { try { - const org = await Org.create({ aliasOrUsername: username }); await org.delete(); this.logSuccess(messages.getMessage('success', [username])); } catch (e) { if (e instanceof Error && e.name === 'DomainNotFoundError') { - // the org has expired, so remote operations won't work - // let's clean up the files locally const authRemover = await AuthRemover.create(); await authRemover.removeAuth(username); this.logSuccess(messages.getMessage('success.Idempotent', [username])); } else if (e instanceof Error && e.name === 'SandboxNotFound') { this.logSuccess(messages.getMessage('success.Idempotent', [username])); + } else if ( + e instanceof Error && + 'errorCode' in e && + (e as { errorCode: string }).errorCode === 'INSUFFICIENT_ACCESS_OR_READONLY' + ) { + throw messages.createError('error.insufficientAccess'); } else { throw e; } @@ -82,4 +89,41 @@ export default class DeleteSandbox extends SfCommand { } return { username, orgId }; } + + /** + * Verifies the current user has the 'DeleteSandbox' PermissionSet assigned + * in the production org that owns the sandbox. + */ + // eslint-disable-next-line class-methods-use-this + private async verifyDeletePermission( + stateAggregator: StateAggregator, + orgId: string, + sandboxUsername: string + ): Promise { + const sandboxConfig = await stateAggregator.sandboxes.read(orgId); + const prodOrgUsername = sandboxConfig?.prodOrgUsername; + + if (!prodOrgUsername) { + throw messages.createError('error.missingProdOrg', [sandboxUsername]); + } + + const prodOrg = await Org.create({ aliasOrUsername: prodOrgUsername }); + const connection = prodOrg.getConnection(); + await prodOrg.refreshAuth(); + + const identity = await connection.identity(); + const userId = identity.user_id; + + if (!userId) { + throw messages.createError('error.insufficientPermissions', [sandboxUsername]); + } + + const result = await connection.query( + `SELECT Id FROM PermissionSetAssignment WHERE AssigneeId = '${userId}' AND PermissionSet.Name = 'DeleteSandbox'` + ); + + if (result.totalSize === 0) { + throw messages.createError('error.insufficientPermissions', [sandboxUsername]); + } + } } diff --git a/test/unit/org/delete.test.ts b/test/unit/org/delete.test.ts index 1cc211d8b..3efc47d6f 100644 --- a/test/unit/org/delete.test.ts +++ b/test/unit/org/delete.test.ts @@ -5,7 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { AuthInfo, Messages, Org, SfError } from '@salesforce/core'; +import { AuthInfo, Connection, Messages, Org, SfError } from '@salesforce/core'; import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; import { SinonStub } from 'sinon'; import { config, expect } from 'chai'; @@ -38,6 +38,20 @@ describe('org delete', () => { }); describe('sandbox', () => { + let sandboxReadStub: SinonStub; + + beforeEach(() => { + sandboxReadStub = $$.SANDBOX.stub(SandboxAccessor.prototype, 'read').resolves({ + sandboxOrgId: testOrg.orgId, + prodOrgUsername: testHub.username, + }); + $$.SANDBOX.stub(Org.prototype, 'refreshAuth').resolves(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, camelcase + $$.SANDBOX.stub(Connection.prototype, 'identity').resolves({ user_id: '005xx000001X' } as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + $$.SANDBOX.stub(Connection.prototype, 'query').resolves({ totalSize: 1, records: [{ Id: '0Pa000000000001' }] } as any); + }); + it('will throw an error when no org provided', async () => { await $$.stubConfig({}); try { @@ -128,6 +142,62 @@ describe('org delete', () => { JSON.stringify(sfCommandUxStubs.logSuccess.getCalls().flatMap((call) => call.args)) ).to.deep.include(sbxOrgMessages.getMessage('success.Idempotent', [testOrg.username])); }); + + it('will throw a clean SfError when the user lacks Manage Sandboxes permission', async () => { + $$.SANDBOX.stub(SandboxAccessor.prototype, 'hasFile').resolves(true); + orgDeleteStub.restore(); + const insufficientAccessError = Object.assign(new Error('INSUFFICIENT_ACCESS_OR_READONLY'), { + errorCode: 'INSUFFICIENT_ACCESS_OR_READONLY', + }); + $$.SANDBOX.stub(Org.prototype, 'delete').throws(insufficientAccessError); + try { + await DeleteSandbox.run(['--no-prompt', '--target-org', testOrg.username]); + expect.fail('should have thrown InsufficientAccessError'); + } catch (e) { + const err = e as SfError; + expect(err.name).to.equal('InsufficientAccessError'); + expect(err.message).to.equal(sbxOrgMessages.getMessage('error.insufficientAccess')); + } + }); + + describe('DeleteSandbox permission check', () => { + beforeEach(() => { + $$.SANDBOX.stub(SandboxAccessor.prototype, 'hasFile').resolves(true); + }); + + it('will allow deletion when user has DeleteSandbox permission set', async () => { + const res = await DeleteSandbox.run(['--no-prompt', '--target-org', testOrg.username]); + expect(sfCommandUxStubs.logSuccess.callCount).to.equal(1); + expect(res).to.deep.equal({ orgId: testOrg.orgId, username: testOrg.username }); + }); + + it('will block deletion when user lacks DeleteSandbox permission set', async () => { + (Connection.prototype.query as SinonStub).restore(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + $$.SANDBOX.stub(Connection.prototype, 'query').resolves({ totalSize: 0, records: [] } as any); + try { + await DeleteSandbox.run(['--no-prompt', '--target-org', testOrg.username]); + expect.fail('should have thrown InsufficientPermissionsError'); + } catch (e) { + const err = e as SfError; + expect(err.name).to.equal('InsufficientPermissionsError'); + expect(err.message).to.include(testOrg.username); + expect(err.message).to.include('DeleteSandbox'); + } + }); + + it('will throw when prodOrgUsername is missing from sandbox config', async () => { + sandboxReadStub.resolves({ sandboxOrgId: testOrg.orgId }); + try { + await DeleteSandbox.run(['--no-prompt', '--target-org', testOrg.username]); + expect.fail('should have thrown MissingProdOrgError'); + } catch (e) { + const err = e as SfError; + expect(err.name).to.equal('MissingProdOrgError'); + expect(err.message).to.include(testOrg.username); + } + }); + }); }); describe('scratch', () => {