From 4413d39cc2e93764bd56b8cd860e103a7d533796 Mon Sep 17 00:00:00 2001 From: Leif Henriksen Date: Fri, 6 Nov 2026 18:23:42 +0100 Subject: [PATCH 1/4] CLDSRV-1060: Check the governance bypass permission on DeleteObject The bypass action was added to the authorization only when sourceVersionId was set, which is the version of a copy source and is never set on a delete. So a delete of a specific version with x-amz-bypass-governance-retention removed a GOVERNANCE-locked version with only s3:DeleteObjectVersion, and a Deny on s3:BypassGovernanceRetention was ignored. Authorize s3:BypassGovernanceRetention on every delete of a specific version that sends the header. Call prepareRequestContexts in the unit tests as the API does for a delete, without a copy source, and add functional tests with IAM users against Vault. (cherry picked from commit 8cfe61b5e6278076f7bc397b5fdcff7967335950) --- .../authorization/prepareRequestContexts.js | 4 +- .../object/deleteObjectGovernanceBypass.js | 202 ++++++++++++++++++ .../authorization/prepareRequestContexts.js | 12 +- 3 files changed, 207 insertions(+), 11 deletions(-) create mode 100644 tests/functional/aws-node-sdk/test/object/deleteObjectGovernanceBypass.js diff --git a/lib/api/apiUtils/authorization/prepareRequestContexts.js b/lib/api/apiUtils/authorization/prepareRequestContexts.js index c62ace060a..9775ab14a5 100644 --- a/lib/api/apiUtils/authorization/prepareRequestContexts.js +++ b/lib/api/apiUtils/authorization/prepareRequestContexts.js @@ -241,9 +241,7 @@ function prepareRequestContexts(apiMethod, request, sourceBucket, generateRequestContext('objectPutTaggingVersion'); requestContexts.push(putObjectVersionRequestContext); } - // AWS only returns an object lock error if a version id - // is specified, else continue to create a delete marker - } else if (sourceVersionId && apiMethodAfterVersionCheck === 'objectDeleteVersion') { + } else if (apiMethodAfterVersionCheck === 'objectDeleteVersion') { const deleteRequestContext = generateRequestContext(apiMethodAfterVersionCheck); requestContexts.push(deleteRequestContext); diff --git a/tests/functional/aws-node-sdk/test/object/deleteObjectGovernanceBypass.js b/tests/functional/aws-node-sdk/test/object/deleteObjectGovernanceBypass.js new file mode 100644 index 0000000000..431b8390ff --- /dev/null +++ b/tests/functional/aws-node-sdk/test/object/deleteObjectGovernanceBypass.js @@ -0,0 +1,202 @@ +const assert = require('assert'); +const { + CreateBucketCommand, + PutObjectCommand, + PutObjectRetentionCommand, + DeleteObjectCommand, + HeadObjectCommand, + PutBucketPolicyCommand, + DeleteBucketPolicyCommand, +} = require('@aws-sdk/client-s3'); +const { v4: uuid } = require('uuid'); + +const BucketUtility = require('../../lib/utility/bucket-util'); +const { makeIamClient, createUserWithPolicy } = require('../../lib/utility/iam-util'); +const { config } = require('../../../../../lib/Config'); + +// this test needs a real vault to create IAM users +const isVaultScality = config.backends.auth !== 'mem'; + +const describeWithVault = isVaultScality ? describe : describe.skip; + +describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', () => { + const bucketName = `delete-governance-bypass-${uuid()}`; + const bucketUtil = new BucketUtility('default'); + const iamClient = makeIamClient('default'); + const users = []; + + const allowDelete = { + Effect: 'Allow', + Action: ['s3:DeleteObject'], + Resource: `arn:aws:s3:::${bucketName}/*`, + }; + const allowDeleteVersion = { + Effect: 'Allow', + Action: ['s3:DeleteObjectVersion'], + Resource: `arn:aws:s3:::${bucketName}/*`, + }; + const allowBypass = { + Effect: 'Allow', + Action: ['s3:BypassGovernanceRetention'], + Resource: `arn:aws:s3:::${bucketName}/*`, + }; + // The bucket policy validation refuses s3:BypassGovernanceRetention on an object resource when it is the only + // statement, so the bucket policies name the action with a wildcard + const allowBypassInBucketPolicy = { ...allowBypass, Principal: '*', Action: 's3:Bypass*' }; + + async function putVersion(key) { + const { VersionId } = await bucketUtil.s3.send( + new PutObjectCommand({ + Bucket: bucketName, + Key: key, + Body: 'content', + }), + ); + return VersionId; + } + + async function putLockedVersion(key) { + const versionId = await putVersion(key); + await bucketUtil.s3.send( + new PutObjectRetentionCommand({ + Bucket: bucketName, + Key: key, + VersionId: versionId, + Retention: { + Mode: 'GOVERNANCE', + RetainUntilDate: new Date(Date.now() + 24 * 3600 * 1000), + }, + }), + ); + return versionId; + } + + async function createUser(statements) { + const user = await createUserWithPolicy(iamClient, statements); + users.push(user); + return user.s3Client; + } + + function putBucketPolicy(statement) { + return bucketUtil.s3.send( + new PutBucketPolicyCommand({ + Bucket: bucketName, + Policy: JSON.stringify({ Version: '2012-10-17', Statement: [statement] }), + }), + ); + } + + function deleteBucketPolicy() { + return bucketUtil.s3.send(new DeleteBucketPolicyCommand({ Bucket: bucketName })); + } + + function deleteWithBypass(s3Client, key, versionId) { + return s3Client.send( + new DeleteObjectCommand({ + Bucket: bucketName, + Key: key, + VersionId: versionId, + BypassGovernanceRetention: true, + }), + ); + } + + function headVersion(key, versionId) { + return bucketUtil.s3.send( + new HeadObjectCommand({ + Bucket: bucketName, + Key: key, + VersionId: versionId, + }), + ); + } + + before(async () => { + await bucketUtil.s3.send( + new CreateBucketCommand({ + Bucket: bucketName, + ObjectLockEnabledForBucket: true, + }), + ); + }); + + after(async () => { + await bucketUtil.empty(bucketName, true); + await bucketUtil.deleteOne(bucketName); + await Promise.all(users.map(user => user.deleteUser())); + }); + + it('should deny the bypass header to a user without s3:BypassGovernanceRetention', async () => { + const key = 'no-bypass-permission'; + const versionId = await putLockedVersion(key); + const s3Client = await createUser([allowDeleteVersion]); + + await assert.rejects(deleteWithBypass(s3Client, key, versionId), { name: 'AccessDenied' }); + await headVersion(key, versionId); + }); + + it('should deny the bypass header on an unlocked version without s3:BypassGovernanceRetention', async () => { + const key = 'unlocked-no-bypass-permission'; + const versionId = await putVersion(key); + const s3Client = await createUser([allowDeleteVersion]); + + await assert.rejects(deleteWithBypass(s3Client, key, versionId), { name: 'AccessDenied' }); + await headVersion(key, versionId); + }); + + it('should add a delete marker with the bypass header and no version ID', async () => { + const key = 'no-version-id'; + const versionId = await putLockedVersion(key); + const s3Client = await createUser([allowDelete]); + + const { DeleteMarker } = await deleteWithBypass(s3Client, key); + assert.strictEqual(DeleteMarker, true); + await headVersion(key, versionId); + }); + + it('should deny the bypass header when an IAM policy denies s3:BypassGovernanceRetention', async () => { + const key = 'iam-deny-bypass'; + const versionId = await putLockedVersion(key); + const s3Client = await createUser([allowDeleteVersion, { ...allowBypass, Effect: 'Deny' }]); + + await assert.rejects(deleteWithBypass(s3Client, key, versionId), { name: 'AccessDenied' }); + await headVersion(key, versionId); + }); + + it('should deny the bypass header when the bucket policy denies s3:BypassGovernanceRetention', async () => { + const key = 'bucket-policy-deny-bypass'; + const versionId = await putLockedVersion(key); + const s3Client = await createUser([allowDeleteVersion, allowBypass]); + await putBucketPolicy({ ...allowBypassInBucketPolicy, Effect: 'Deny' }); + + try { + await assert.rejects(deleteWithBypass(s3Client, key, versionId), { name: 'AccessDenied' }); + } finally { + await deleteBucketPolicy(); + } + await headVersion(key, versionId); + }); + + it('should delete the version for a user with s3:BypassGovernanceRetention', async () => { + const key = 'bypass-permission'; + const versionId = await putLockedVersion(key); + const s3Client = await createUser([allowDeleteVersion, allowBypass]); + + await deleteWithBypass(s3Client, key, versionId); + await assert.rejects(headVersion(key, versionId), err => err.$metadata.httpStatusCode === 404); + }); + + it('should delete the version for a user allowed s3:BypassGovernanceRetention by the bucket policy', async () => { + const key = 'bucket-policy-allow-bypass'; + const versionId = await putLockedVersion(key); + const s3Client = await createUser([allowDeleteVersion]); + await putBucketPolicy(allowBypassInBucketPolicy); + + try { + await deleteWithBypass(s3Client, key, versionId); + } finally { + await deleteBucketPolicy(); + } + await assert.rejects(headVersion(key, versionId), err => err.$metadata.httpStatusCode === 404); + }); +}); diff --git a/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js b/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js index 0a08b104f3..474f043c02 100644 --- a/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js +++ b/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js @@ -257,8 +257,7 @@ describe('prepareRequestContexts', () => { it('should return s3:DeleteObject for objectDelete method', () => { const apiMethod = 'objectDelete'; const request = makeRequest(); - const results = prepareRequestContexts(apiMethod, request, sourceBucket, - sourceObject, sourceVersionId); + const results = prepareRequestContexts(apiMethod, request); assert.strictEqual(results.length, 1); assert.strictEqual(results[0].getAction(), 's3:DeleteObject'); @@ -269,8 +268,7 @@ describe('prepareRequestContexts', () => { const request = makeRequest({}, { versionId: 'vid1', }); - const results = prepareRequestContexts(apiMethod, request, sourceBucket, - sourceObject, sourceVersionId); + const results = prepareRequestContexts(apiMethod, request); assert.strictEqual(results.length, 1); assert.strictEqual(results[0].getAction(), 's3:DeleteObjectVersion'); @@ -285,8 +283,7 @@ describe('prepareRequestContexts', () => { }, { versionId: 'vid1', }); - const results = prepareRequestContexts(apiMethod, request, sourceBucket, - sourceObject, sourceVersionId); + const results = prepareRequestContexts(apiMethod, request); assert.strictEqual(results.length, 2); const expectedAction1 = 's3:DeleteObjectVersion'; @@ -303,8 +300,7 @@ describe('prepareRequestContexts', () => { const request = makeRequest({ 'x-amz-bypass-governance-retention': 'true', }); - const results = prepareRequestContexts(apiMethod, request, sourceBucket, - sourceObject, sourceVersionId); + const results = prepareRequestContexts(apiMethod, request); assert.strictEqual(results.length, 1); const expectedAction = 's3:DeleteObject'; From d352120654b52743db619de9e4b0e079bc8455ff Mon Sep 17 00:00:00 2001 From: Leif Henriksen Date: Mon, 9 Nov 2026 17:43:30 +0100 Subject: [PATCH 2/4] CLDSRV-1060: Use the AWS SDK v2 in the functional test 9.2.36 has neither the AWS SDK v3 nor the IAM test helper, so create the IAM users with the v2 IAM client, as bucketPolicyBypassPort.js does. (cherry picked from commit 63de57a1ad23aea9cb53017ded70d719bb300f43) --- .../object/deleteObjectGovernanceBypass.js | 108 ++++++++++-------- 1 file changed, 62 insertions(+), 46 deletions(-) diff --git a/tests/functional/aws-node-sdk/test/object/deleteObjectGovernanceBypass.js b/tests/functional/aws-node-sdk/test/object/deleteObjectGovernanceBypass.js index 431b8390ff..88d88c86a2 100644 --- a/tests/functional/aws-node-sdk/test/object/deleteObjectGovernanceBypass.js +++ b/tests/functional/aws-node-sdk/test/object/deleteObjectGovernanceBypass.js @@ -1,28 +1,23 @@ const assert = require('assert'); -const { - CreateBucketCommand, - PutObjectCommand, - PutObjectRetentionCommand, - DeleteObjectCommand, - HeadObjectCommand, - PutBucketPolicyCommand, - DeleteBucketPolicyCommand, -} = require('@aws-sdk/client-s3'); +const { Credentials, IAM, S3 } = require('aws-sdk'); const { v4: uuid } = require('uuid'); const BucketUtility = require('../../lib/utility/bucket-util'); -const { makeIamClient, createUserWithPolicy } = require('../../lib/utility/iam-util'); +const getConfig = require('../support/config'); const { config } = require('../../../../../lib/Config'); // this test needs a real vault to create IAM users const isVaultScality = config.backends.auth !== 'mem'; +const vaultHost = config.vaultd?.host || 'localhost'; const describeWithVault = isVaultScality ? describe : describe.skip; describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', () => { const bucketName = `delete-governance-bypass-${uuid()}`; const bucketUtil = new BucketUtility('default'); - const iamClient = makeIamClient('default'); + const iamConfig = getConfig('default', { region: 'us-east-1' }); + iamConfig.endpoint = `http://${vaultHost}:8600`; // define outside of getConfig for Integration + const iamClient = new IAM(iamConfig); const users = []; const allowDelete = { @@ -45,20 +40,20 @@ describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', const allowBypassInBucketPolicy = { ...allowBypass, Principal: '*', Action: 's3:Bypass*' }; async function putVersion(key) { - const { VersionId } = await bucketUtil.s3.send( - new PutObjectCommand({ + const { VersionId } = await bucketUtil.s3 + .putObject({ Bucket: bucketName, Key: key, Body: 'content', - }), - ); + }) + .promise(); return VersionId; } async function putLockedVersion(key) { const versionId = await putVersion(key); - await bucketUtil.s3.send( - new PutObjectRetentionCommand({ + await bucketUtil.s3 + .putObjectRetention({ Bucket: bucketName, Key: key, VersionId: versionId, @@ -66,64 +61,85 @@ describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', Mode: 'GOVERNANCE', RetainUntilDate: new Date(Date.now() + 24 * 3600 * 1000), }, - }), - ); + }) + .promise(); return versionId; } + // Creates an IAM user with one managed policy and returns its S3 client async function createUser(statements) { - const user = await createUserWithPolicy(iamClient, statements); - users.push(user); - return user.s3Client; + const name = `test-user-${uuid()}`; + const { Policy } = await iamClient + .createPolicy({ + PolicyName: name, + PolicyDocument: JSON.stringify({ Version: '2012-10-17', Statement: statements }), + }) + .promise(); + await iamClient.createUser({ UserName: name }).promise(); + await iamClient.attachUserPolicy({ UserName: name, PolicyArn: Policy.Arn }).promise(); + const { AccessKey } = await iamClient.createAccessKey({ UserName: name }).promise(); + users.push({ name, policyArn: Policy.Arn, accessKeyId: AccessKey.AccessKeyId }); + return new S3( + getConfig('default', { + credentials: new Credentials(AccessKey.AccessKeyId, AccessKey.SecretAccessKey), + }), + ); + } + + async function deleteUser({ name, policyArn, accessKeyId }) { + await iamClient.deleteAccessKey({ UserName: name, AccessKeyId: accessKeyId }).promise(); + await iamClient.detachUserPolicy({ UserName: name, PolicyArn: policyArn }).promise(); + await iamClient.deletePolicy({ PolicyArn: policyArn }).promise(); + await iamClient.deleteUser({ UserName: name }).promise(); } function putBucketPolicy(statement) { - return bucketUtil.s3.send( - new PutBucketPolicyCommand({ + return bucketUtil.s3 + .putBucketPolicy({ Bucket: bucketName, Policy: JSON.stringify({ Version: '2012-10-17', Statement: [statement] }), - }), - ); + }) + .promise(); } function deleteBucketPolicy() { - return bucketUtil.s3.send(new DeleteBucketPolicyCommand({ Bucket: bucketName })); + return bucketUtil.s3.deleteBucketPolicy({ Bucket: bucketName }).promise(); } function deleteWithBypass(s3Client, key, versionId) { - return s3Client.send( - new DeleteObjectCommand({ + return s3Client + .deleteObject({ Bucket: bucketName, Key: key, VersionId: versionId, BypassGovernanceRetention: true, - }), - ); + }) + .promise(); } function headVersion(key, versionId) { - return bucketUtil.s3.send( - new HeadObjectCommand({ + return bucketUtil.s3 + .headObject({ Bucket: bucketName, Key: key, VersionId: versionId, - }), - ); + }) + .promise(); } before(async () => { - await bucketUtil.s3.send( - new CreateBucketCommand({ + await bucketUtil.s3 + .createBucket({ Bucket: bucketName, ObjectLockEnabledForBucket: true, - }), - ); + }) + .promise(); }); after(async () => { await bucketUtil.empty(bucketName, true); await bucketUtil.deleteOne(bucketName); - await Promise.all(users.map(user => user.deleteUser())); + await Promise.all(users.map(deleteUser)); }); it('should deny the bypass header to a user without s3:BypassGovernanceRetention', async () => { @@ -131,7 +147,7 @@ describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', const versionId = await putLockedVersion(key); const s3Client = await createUser([allowDeleteVersion]); - await assert.rejects(deleteWithBypass(s3Client, key, versionId), { name: 'AccessDenied' }); + await assert.rejects(deleteWithBypass(s3Client, key, versionId), { code: 'AccessDenied' }); await headVersion(key, versionId); }); @@ -140,7 +156,7 @@ describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', const versionId = await putVersion(key); const s3Client = await createUser([allowDeleteVersion]); - await assert.rejects(deleteWithBypass(s3Client, key, versionId), { name: 'AccessDenied' }); + await assert.rejects(deleteWithBypass(s3Client, key, versionId), { code: 'AccessDenied' }); await headVersion(key, versionId); }); @@ -159,7 +175,7 @@ describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', const versionId = await putLockedVersion(key); const s3Client = await createUser([allowDeleteVersion, { ...allowBypass, Effect: 'Deny' }]); - await assert.rejects(deleteWithBypass(s3Client, key, versionId), { name: 'AccessDenied' }); + await assert.rejects(deleteWithBypass(s3Client, key, versionId), { code: 'AccessDenied' }); await headVersion(key, versionId); }); @@ -170,7 +186,7 @@ describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', await putBucketPolicy({ ...allowBypassInBucketPolicy, Effect: 'Deny' }); try { - await assert.rejects(deleteWithBypass(s3Client, key, versionId), { name: 'AccessDenied' }); + await assert.rejects(deleteWithBypass(s3Client, key, versionId), { code: 'AccessDenied' }); } finally { await deleteBucketPolicy(); } @@ -183,7 +199,7 @@ describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', const s3Client = await createUser([allowDeleteVersion, allowBypass]); await deleteWithBypass(s3Client, key, versionId); - await assert.rejects(headVersion(key, versionId), err => err.$metadata.httpStatusCode === 404); + await assert.rejects(headVersion(key, versionId), { statusCode: 404 }); }); it('should delete the version for a user allowed s3:BypassGovernanceRetention by the bucket policy', async () => { @@ -197,6 +213,6 @@ describeWithVault('DeleteObject of a GOVERNANCE-locked version by an IAM user', } finally { await deleteBucketPolicy(); } - await assert.rejects(headVersion(key, versionId), err => err.$metadata.httpStatusCode === 404); + await assert.rejects(headVersion(key, versionId), { statusCode: 404 }); }); }); From 40a5a3e06c62c0674cf278051845ff8da14adf53 Mon Sep 17 00:00:00 2001 From: Leif Henriksen Date: Tue, 10 Nov 2026 14:28:27 +0100 Subject: [PATCH 3/4] Bump version to 9.2.24-7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ab91602749..9b9d7dc36e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenko/cloudserver", - "version": "9.2.24-6", + "version": "9.2.24-7", "description": "Zenko CloudServer, an open-source Node.js implementation of a server handling the Amazon S3 protocol", "main": "index.js", "engines": { From 1072739d9b33520d26f141801cf35ba91abc34d2 Mon Sep 17 00:00:00 2001 From: Mickael Bourgois Date: Mon, 26 Oct 2026 15:13:59 +0100 Subject: [PATCH 4/4] CLDSRV-1020: Pin CI ubuntu 24.04 The latest is 26 with breaks with tests needing python 3.9 (cherry picked from commit 8d58737cea879450c7909f5bc9cb9eb63ef123b9) --- .github/workflows/tests.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 56a144e128..d67de458c8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -155,7 +155,7 @@ jobs: if: always() build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read packages: write @@ -789,7 +789,7 @@ jobs: if: always() kmip-cluster-ft-tests: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: build env: S3BACKEND: file @@ -879,7 +879,7 @@ jobs: }}${{ matrix.opts.globalEncryptionEnabled && '-global' || '' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: build env: S3BACKEND: file