From 18780db1fe52a5652b63e2aaa744bdbef3beca4d 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 c33fcc9c82..82d51a5c37 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 6cf2e3c591..9ce738acb1 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 e9ffdff0e301ceeb2d176124db4b4e87f6f1b377 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 e1c035ecd30a0d7eb2eb1201b6f7ac196b736c03 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.0.32-4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4673e7f186..bddc6cdd23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenko/cloudserver", - "version": "9.0.32-3", + "version": "9.0.32-4", "description": "Zenko CloudServer, an open-source Node.js implementation of a server handling the Amazon S3 protocol", "main": "index.js", "engines": { From 075b472b7b17d0f9bc6101f6ecb55743b4880f5e 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 ae28bdcd21..6015059ac8 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 @@ -759,7 +759,7 @@ jobs: if: always() kmip-cluster-ft-tests: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: build env: S3BACKEND: file @@ -851,7 +851,7 @@ jobs: }}${{ matrix.opts.globalEncryptionEnabled && '-global' || '' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: build env: S3BACKEND: file