From cd0840f42f9618f5aa0bc64e5fbe4c2f7d789b7b Mon Sep 17 00:00:00 2001 From: Thomas Carmet <8408330+tcarmet@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:55:53 -0700 Subject: [PATCH 1/3] CLDSRV-1021: Add tests for copy source bucket policy authorization --- .../test/object/copySourceBucketPolicy.js | 203 ++++++++++++++++++ tests/unit/api/bucketPolicyAuth.js | 98 +++++++++ tests/unit/api/objectCopy.js | 92 ++++++++ tests/unit/api/objectCopyPart.js | 106 +++++++++ 4 files changed, 499 insertions(+) create mode 100644 tests/functional/aws-node-sdk/test/object/copySourceBucketPolicy.js diff --git a/tests/functional/aws-node-sdk/test/object/copySourceBucketPolicy.js b/tests/functional/aws-node-sdk/test/object/copySourceBucketPolicy.js new file mode 100644 index 0000000000..5992bf9ea3 --- /dev/null +++ b/tests/functional/aws-node-sdk/test/object/copySourceBucketPolicy.js @@ -0,0 +1,203 @@ +const assert = require('assert'); +const { + S3Client, + PutObjectCommand, + GetObjectCommand, + CopyObjectCommand, + CreateMultipartUploadCommand, + UploadPartCopyCommand, + AbortMultipartUploadCommand, + PutBucketPolicyCommand, + DeleteBucketPolicyCommand, +} = require('@aws-sdk/client-s3'); +const { + IAMClient, + CreateUserCommand, + CreateAccessKeyCommand, + DeleteAccessKeyCommand, + DeleteUserCommand, +} = require('@aws-sdk/client-iam'); +const { v4: uuid } = require('uuid'); + +const getConfig = require('../support/config'); +const BucketUtility = require('../../lib/utility/bucket-util'); +const { config } = require('../../../../../lib/Config'); + +const bucketName = `copy-source-bp-${uuid()}`; +const userName = `copy-source-bp-user-${uuid()}`; +const forbiddenKey = 'forbidden/myfile'; +const allowedKey = 'allowed/myfile'; +const mpuKey = 'allowed/mpu-copy'; +const allowedContent = 'allowed content'; + +// this test needs a real vault to create an IAM user +const isVaultScality = config.backends.auth !== 'mem'; +const vaultHost = config.vaultd?.host || 'localhost'; + +const describeWithVault = isVaultScality ? describe : describe.skip; + +describeWithVault('Copy source with a bucket policy scoped to key prefixes', () => { + const bucketUtilAccount = new BucketUtility('default'); + const s3ClientAccount = bucketUtilAccount.s3; + + const iamConfig = getConfig('default', { region: 'us-east-1' }); + iamConfig.endpoint = `http://${vaultHost}:8600`; // define outside of getConfig for Integration + const iamClient = new IAMClient(iamConfig); + + let userS3Client; + let accessKeyId; + let uploadId; + + async function assertAccessDenied(promise) { + await assert.rejects(promise, { name: 'AccessDenied' }); + } + + before(async () => { + await bucketUtilAccount.createOne(bucketName); + await s3ClientAccount.send( + new PutObjectCommand({ + Bucket: bucketName, + Key: forbiddenKey, + Body: 'forbidden content', + }), + ); + await s3ClientAccount.send( + new PutObjectCommand({ + Bucket: bucketName, + Key: allowedKey, + Body: allowedContent, + }), + ); + await s3ClientAccount.send( + new PutBucketPolicyCommand({ + Bucket: bucketName, + Policy: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'AllowReadWriteOnAllowedPrefix', + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject', 's3:PutObject'], + Resource: `arn:aws:s3:::${bucketName}/allowed/*`, + }, + { + Sid: 'DenyReadOnForbiddenPrefix', + Effect: 'Deny', + Principal: '*', + Action: 's3:GetObject', + Resource: `arn:aws:s3:::${bucketName}/forbidden/*`, + }, + ], + }), + }), + ); + + // IAM user without any identity policy + await iamClient.send(new CreateUserCommand({ UserName: userName })); + const accessKeyResponse = await iamClient.send(new CreateAccessKeyCommand({ UserName: userName })); + accessKeyId = accessKeyResponse.AccessKey.AccessKeyId; + userS3Client = new S3Client( + getConfig('default', { + credentials: { + accessKeyId, + secretAccessKey: accessKeyResponse.AccessKey.SecretAccessKey, + }, + }), + ); + + const mpuResponse = await userS3Client.send( + new CreateMultipartUploadCommand({ + Bucket: bucketName, + Key: mpuKey, + }), + ); + uploadId = mpuResponse.UploadId; + }); + + after(async () => { + if (uploadId) { + await s3ClientAccount.send( + new AbortMultipartUploadCommand({ + Bucket: bucketName, + Key: mpuKey, + UploadId: uploadId, + }), + ); + } + await s3ClientAccount.send(new DeleteBucketPolicyCommand({ Bucket: bucketName })); + await bucketUtilAccount.empty(bucketName); + await bucketUtilAccount.deleteOne(bucketName); + if (accessKeyId) { + await iamClient.send(new DeleteAccessKeyCommand({ UserName: userName, AccessKeyId: accessKeyId })); + } + await iamClient.send(new DeleteUserCommand({ UserName: userName })); + }); + + it('should deny GetObject on a source denied by the bucket policy', async () => { + await assertAccessDenied( + userS3Client.send( + new GetObjectCommand({ + Bucket: bucketName, + Key: forbiddenKey, + }), + ), + ); + }); + + it('should deny CopyObject from a source denied by the bucket policy', async () => { + await assertAccessDenied( + userS3Client.send( + new CopyObjectCommand({ + Bucket: bucketName, + Key: 'allowed/myfile-copy', + CopySource: `${bucketName}/${forbiddenKey}`, + }), + ), + ); + }); + + it('should deny UploadPartCopy from a source denied by the bucket policy', async () => { + await assertAccessDenied( + userS3Client.send( + new UploadPartCopyCommand({ + Bucket: bucketName, + Key: mpuKey, + UploadId: uploadId, + PartNumber: 1, + CopySource: `${bucketName}/${forbiddenKey}`, + }), + ), + ); + }); + + it('should allow CopyObject from a source allowed by the bucket policy', async () => { + await userS3Client.send( + new CopyObjectCommand({ + Bucket: bucketName, + Key: 'allowed/allowed-copy', + CopySource: `${bucketName}/${allowedKey}`, + }), + ); + const getResponse = await userS3Client.send( + new GetObjectCommand({ + Bucket: bucketName, + Key: 'allowed/allowed-copy', + }), + ); + assert.strictEqual(await getResponse.Body.transformToString(), allowedContent); + }); + + it('should allow UploadPartCopy from a source allowed by the bucket policy', async () => { + const res = await userS3Client.send( + new UploadPartCopyCommand({ + Bucket: bucketName, + Key: mpuKey, + UploadId: uploadId, + PartNumber: 2, + CopySource: `${bucketName}/${allowedKey}`, + }), + ); + assert(res.CopyPartResult.ETag, 'Expected a part ETag'); + }); +}); diff --git a/tests/unit/api/bucketPolicyAuth.js b/tests/unit/api/bucketPolicyAuth.js index 618f7b8908..16334bdaf0 100644 --- a/tests/unit/api/bucketPolicyAuth.js +++ b/tests/unit/api/bucketPolicyAuth.js @@ -558,6 +558,104 @@ describe('bucket policy authorization', () => { }); }); + describe('bucket policy evaluated against a policy target', () => { + const userAuthInfo = makeAuthInfo(accessKey, 'user'); + const bucketArn = `arn:aws:s3:::${bucket.getName()}`; + const prefixPolicy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject', 's3:PutObject'], + Resource: `${bucketArn}/allowed/*`, + }, + { + Effect: 'Deny', + Principal: '*', + Action: 's3:GetObject', + Resource: `${bucketArn}/forbidden/*`, + }, + ], + }; + const versionIdPolicy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: 's3:GetObject', + Resource: `${bucketArn}/*`, + }, + { + Effect: 'Deny', + Principal: '*', + Action: 's3:GetObject', + Resource: `${bucketArn}/*`, + Condition: { + StringEquals: { 's3:VersionId': 'protected-version' }, + }, + }, + ], + }; + + // Like the source check of a copy: the request targets the + // destination key, the authorization is for another key + function makeCopyRequest() { + return new DummyRequest({ + bucketName: bucket.getName(), + objectKey: 'allowed/myfile-copy', + headers: {}, + query: {}, + socket: {}, + }); + } + + function setPolicy(policyObj) { + bucket.setBucketPolicy(new BucketPolicy(JSON.stringify(policyObj)).getBucketPolicy()); + } + + it('should deny objectGet when the target key matches a Deny statement', () => { + setPolicy(prefixPolicy); + const allowed = isObjAuthorized(bucket, object, 'objectGet', bucketOwnerCanonicalId, + userAuthInfo, log, makeCopyRequest(), { objectGet: true }, false, + { bucketName: bucket.getName(), objectKey: 'forbidden/myfile' }); + assert.strictEqual(allowed, false); + }); + + it('should allow objectGet when the target key matches an Allow statement', () => { + setPolicy(prefixPolicy); + const allowed = isObjAuthorized(bucket, object, 'objectGet', bucketOwnerCanonicalId, + userAuthInfo, log, makeCopyRequest(), { objectGet: true }, false, + { bucketName: bucket.getName(), objectKey: 'allowed/myfile' }); + assert.strictEqual(allowed, true); + }); + + it('should deny bucket-level objectGet when the target key matches a Deny statement', () => { + setPolicy(prefixPolicy); + const allowed = isBucketAuthorized(bucket, 'objectGet', bucketOwnerCanonicalId, + userAuthInfo, log, makeCopyRequest(), { objectGet: true }, false, + { bucketName: bucket.getName(), objectKey: 'forbidden/myfile' }); + assert.strictEqual(allowed, false); + }); + + it('should evaluate s3:VersionId against the target version', () => { + setPolicy(versionIdPolicy); + const allowed = isObjAuthorized(bucket, object, 'objectGet', bucketOwnerCanonicalId, + userAuthInfo, log, makeCopyRequest(), { objectGet: true }, false, + { bucketName: bucket.getName(), objectKey: 'allowed/myfile', versionId: 'protected-version' }); + assert.strictEqual(allowed, false); + }); + + it('should allow objectGet when the target version does not match a Deny condition', () => { + setPolicy(versionIdPolicy); + const allowed = isObjAuthorized(bucket, object, 'objectGet', bucketOwnerCanonicalId, + userAuthInfo, log, makeCopyRequest(), { objectGet: true }, false, + { bucketName: bucket.getName(), objectKey: 'allowed/myfile', versionId: 'other-version' }); + assert.strictEqual(allowed, true); + }); + }); + describe('validate policy resource', () => { resourceTests.forEach(t => { it(`should return ${t.name}`, done => { diff --git a/tests/unit/api/objectCopy.js b/tests/unit/api/objectCopy.js index 526bef3fb8..26d6af944e 100644 --- a/tests/unit/api/objectCopy.js +++ b/tests/unit/api/objectCopy.js @@ -790,3 +790,95 @@ describe('objectCopy source size limit', () => { }); }); }); + +describe('objectCopy with a bucket policy scoped to key prefixes', () => { + const bucketName = 'prefixpolicybucket'; + const userAuthInfo = makeAuthInfo(canonicalID, 'user'); + const putPolicyRequest = new DummyRequest({ + bucketName, + namespace, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: '/', + socket: {}, + post: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject', 's3:PutObject'], + Resource: `arn:aws:s3:::${bucketName}/allowed/*`, + }, + { + Effect: 'Deny', + Principal: '*', + Action: 's3:GetObject', + Resource: `arn:aws:s3:::${bucketName}/forbidden/*`, + }, + ], + }), + }); + + // An IAM user without identity policy: Vault returns an implicit deny + // for both the source read and the destination write + function _createUserCopyRequest(destKey) { + const request = new DummyRequest({ + bucketName, + namespace, + objectKey: destKey, + headers: {}, + url: `/${bucketName}/${destKey}`, + socket: {}, + }); + request.actionImplicitDenies = { objectGet: true, objectPut: true }; + return request; + } + + before(done => { + cleanup(); + async.series( + [ + callback => bucketPut(authInfo, _createBucketPutRequest(bucketName), log, callback), + callback => bucketPutPolicy(authInfo, putPolicyRequest, log, callback), + callback => + objectPut( + authInfo, + versioningTestUtils.createPutObjectRequest(bucketName, 'forbidden/myfile', objData[0]), + undefined, + log, + callback, + ), + callback => + objectPut( + authInfo, + versioningTestUtils.createPutObjectRequest(bucketName, 'allowed/myfile', objData[1]), + undefined, + log, + callback, + ), + ], + done, + ); + }); + + after(() => { + cleanup(); + }); + + it('should deny copying a source denied by the bucket policy to an allowed destination', done => { + const request = _createUserCopyRequest('allowed/myfile-copy'); + objectCopy(userAuthInfo, request, bucketName, 'forbidden/myfile', undefined, log, err => { + assert(err, 'expected the copy to be denied'); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should allow copying a source allowed by the bucket policy to an allowed destination', done => { + const request = _createUserCopyRequest('allowed/myfile-copy'); + objectCopy(userAuthInfo, request, bucketName, 'allowed/myfile', undefined, log, err => { + assert.ifError(err); + done(); + }); + }); +}); diff --git a/tests/unit/api/objectCopyPart.js b/tests/unit/api/objectCopyPart.js index 46c95a0452..6613d3c147 100644 --- a/tests/unit/api/objectCopyPart.js +++ b/tests/unit/api/objectCopyPart.js @@ -4,6 +4,7 @@ const sinon = require('sinon'); const { parseString } = require('xml2js'); const { storage } = require('arsenal'); const { bucketPut } = require('../../../lib/api/bucketPut'); +const bucketPutPolicy = require('../../../lib/api/bucketPutPolicy'); const objectPut = require('../../../lib/api/objectPut'); const objectPutCopyPart = require('../../../lib/api/objectPutCopyPart'); const initiateMultipartUpload @@ -155,3 +156,108 @@ describe('objectCopyPart', () => { }); }); }); + +describe('objectCopyPart with a bucket policy scoped to key prefixes', () => { + const bucketName = 'prefixpolicybucket'; + const destKey = 'allowed/mpu-copy'; + const userAuthInfo = makeAuthInfo(canonicalID, 'user'); + const putPolicyRequest = new DummyRequest({ + bucketName, + namespace, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: '/', + socket: {}, + post: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject', 's3:PutObject'], + Resource: `arn:aws:s3:::${bucketName}/allowed/*`, + }, + { + Effect: 'Deny', + Principal: '*', + Action: 's3:GetObject', + Resource: `arn:aws:s3:::${bucketName}/forbidden/*`, + }, + ], + }), + }); + const initiateUserRequest = new DummyRequest({ + bucketName, + namespace, + objectKey: destKey, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: `/${destKey}?uploads`, + socket: {}, + }); + let uploadId; + + // An IAM user without identity policy: Vault returns an implicit deny + // for both the source read and the destination write + function _createUserCopyPartRequest() { + const request = new DummyRequest({ + bucketName, + namespace, + objectKey: destKey, + headers: {}, + url: `/${bucketName}/${destKey}?partNumber=1`, + query: { + partNumber: 1, + uploadId, + }, + socket: {}, + }); + request.actionImplicitDenies = { objectGet: true, objectPut: true }; + return request; + } + + before(done => { + cleanup(); + async.waterfall([ + callback => bucketPut(authInfo, _createBucketPutRequest(bucketName), log, + err => callback(err)), + callback => bucketPutPolicy(authInfo, putPolicyRequest, log, + err => callback(err)), + callback => objectPut(authInfo, + versioningTestUtils.createPutObjectRequest(bucketName, 'forbidden/myfile', Buffer.from('foo', 'utf8')), + undefined, log, err => callback(err)), + callback => objectPut(authInfo, + versioningTestUtils.createPutObjectRequest(bucketName, 'allowed/myfile', Buffer.from('bar', 'utf8')), + undefined, log, err => callback(err)), + callback => initiateMultipartUpload(userAuthInfo, initiateUserRequest, + log, (err, res) => callback(err, res)), + ], (err, res) => { + if (err) { + return done(err); + } + return parseString(res, (err, json) => { + uploadId = json.InitiateMultipartUploadResult.UploadId[0]; + return done(err); + }); + }); + }); + + after(() => { + cleanup(); + }); + + it('should deny copying a part from a source denied by the bucket policy', done => { + objectPutCopyPart(userAuthInfo, _createUserCopyPartRequest(), bucketName, 'forbidden/myfile', + undefined, log, err => { + assert(err, 'expected the part copy to be denied'); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should allow copying a part from a source allowed by the bucket policy', done => { + objectPutCopyPart(userAuthInfo, _createUserCopyPartRequest(), bucketName, 'allowed/myfile', + undefined, log, err => { + assert.ifError(err); + done(); + }); + }); +}); From a84f273889d3dac0089d853a6cf5e58acda9e5d1 Mon Sep 17 00:00:00 2001 From: Thomas Carmet <8408330+tcarmet@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:19:56 -0700 Subject: [PATCH 2/3] CLDSRV-1021: Evaluate the bucket policy against the copy source --- .../authorization/permissionChecks.js | 31 +++++++++++++------ lib/api/objectCopy.js | 3 ++ lib/api/objectPutCopyPart.js | 3 ++ lib/metadata/metadataUtils.js | 16 ++++++++-- tests/unit/api/objectCopy.js | 5 +-- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/lib/api/apiUtils/authorization/permissionChecks.js b/lib/api/apiUtils/authorization/permissionChecks.js index d82a1bc392..ab7c888c9c 100644 --- a/lib/api/apiUtils/authorization/permissionChecks.js +++ b/lib/api/apiUtils/authorization/permissionChecks.js @@ -445,7 +445,8 @@ function _checkPrincipals(canonicalID, arn, principal, bucketOwnerCanonicalID) { // └───────┼──────────────────┘ │ // └──────────────────────────────────────────┘ // -function checkBucketPolicy(policy, requestType, canonicalID, arn, bucketOwner, log, request, actionImplicitDenies) { +function checkBucketPolicy(policy, requestType, canonicalID, arn, bucketOwner, log, request, actionImplicitDenies, + policyTarget) { let permission = checkBucketPolicyResult.DEFAULT_DENY; // if requester is user within bucket owner account, actions should be // allowed unless explicitly denied (assumes allowed by IAM policy) @@ -454,10 +455,20 @@ function checkBucketPolicy(policy, requestType, canonicalID, arn, bucketOwner, l } let copiedStatement = JSON.parse(JSON.stringify(policy.Statement)); + // The policy target, when set, is the resource being authorized when it + // differs from the one the request targets (e.g. the source of a copy) + let bucketName = request?.bucketName; + let objectKey = request?.objectKey; + let query = request?.query; + if (policyTarget) { + bucketName = policyTarget.bucketName; + objectKey = policyTarget.objectKey; + query = Object.assign({}, query, { versionId: policyTarget.versionId }); + } const ip = request ? requestUtils.getClientIp(request, config) : undefined; const isSecure = request ? requestUtils.getHttpProtocolSecurity(request, config) : undefined; - const requestContext = request ? new RequestContext(request.headers, request.query, - request.bucketName, request.objectKey, ip, + const requestContext = request ? new RequestContext(request.headers, query, + bucketName, objectKey, ip, isSecure, request.resourceType, 's3', null, null, null, null, null, null, null, null, null, null, null, request.objectLockRetentionDays) : undefined; @@ -506,7 +517,7 @@ function checkBucketPolicy(policy, requestType, canonicalID, arn, bucketOwner, l } function processBucketPolicy(requestType, bucket, canonicalID, arn, bucketOwner, log, - request, aclPermission, results, actionImplicitDenies) { + request, aclPermission, results, actionImplicitDenies, policyTarget) { const bucketPolicy = bucket.getBucketPolicy(); let processedResult = results[requestType]; let aclRequired = false; @@ -515,7 +526,7 @@ function processBucketPolicy(requestType, bucket, canonicalID, arn, bucketOwner, aclRequired = true; } else { const bucketPolicyPermission = checkBucketPolicy(bucketPolicy, requestType, canonicalID, arn, - bucketOwner, log, request, actionImplicitDenies); + bucketOwner, log, request, actionImplicitDenies, policyTarget); if (bucketPolicyPermission === checkBucketPolicyResult.EXPLICIT_DENY) { processedResult = false; @@ -534,7 +545,7 @@ function processBucketPolicy(requestType, bucket, canonicalID, arn, bucketOwner, } function isBucketAuthorized(bucket, requestTypesInput, canonicalID, authInfo, log, request, - actionImplicitDeniesInput = {}, isWebsite = false) { + actionImplicitDeniesInput = {}, isWebsite = false, policyTarget = undefined) { const requestTypes = Array.isArray(requestTypesInput) ? requestTypesInput : [requestTypesInput]; const actionImplicitDenies = !actionImplicitDeniesInput ? {} : actionImplicitDeniesInput; const mainApiCall = requestTypes[0]; @@ -568,7 +579,7 @@ function isBucketAuthorized(bucket, requestTypesInput, canonicalID, authInfo, lo actionImplicitDenies.objectGet = actionImplicitDenies.objectGet || false; } const { allowed, aclRequired } = processBucketPolicy(_requestType, bucket, canonicalID, arn, - bucket.getOwner(), log, request, aclPermission, results, actionImplicitDenies); + bucket.getOwner(), log, request, aclPermission, results, actionImplicitDenies, policyTarget); if (aclRequired && request?.serverAccessLog) { // eslint-disable-next-line no-param-reassign request.serverAccessLog.aclRequired = 'Yes'; @@ -597,7 +608,7 @@ function evaluateBucketPolicyWithIAM(bucket, requestTypesInput, canonicalID, aut } function isObjAuthorized(bucket, objectMD, requestTypesInput, canonicalID, authInfo, log, request, - actionImplicitDeniesInput = {}, isWebsite = false) { + actionImplicitDeniesInput = {}, isWebsite = false, policyTarget = undefined) { const requestTypes = Array.isArray(requestTypesInput) ? requestTypesInput : [requestTypesInput]; const actionImplicitDenies = !actionImplicitDeniesInput ? {} : actionImplicitDeniesInput; const results = {}; @@ -617,7 +628,7 @@ function isObjAuthorized(bucket, objectMD, requestTypesInput, canonicalID, authI permission = 'objectPut'; } results[_requestType] = isBucketAuthorized(bucket, permission, canonicalID, authInfo, log, request, - actionImplicitDenies, isWebsite); + actionImplicitDenies, isWebsite, policyTarget); // User is already authorized on the bucket for FULL_CONTROL or WRITE or // bucket has canned ACL public-read-write if ((parsedMethodName === 'objectPut' || parsedMethodName === 'objectDelete') @@ -651,7 +662,7 @@ function isObjAuthorized(bucket, objectMD, requestTypesInput, canonicalID, authI const aclPermission = checkObjectAcls(bucket, objectMD, parsedMethodName, canonicalID, requesterIsNotUser, isUserUnauthenticated, mainApiCall); const { allowed, aclRequired } = processBucketPolicy(_requestType, bucket, canonicalID, arn, bucketOwner, - log, request, aclPermission, results, actionImplicitDenies); + log, request, aclPermission, results, actionImplicitDenies, policyTarget); if (aclRequired && request?.serverAccessLog) { // eslint-disable-next-line no-param-reassign request.serverAccessLog.aclRequired = 'Yes'; diff --git a/lib/api/objectCopy.js b/lib/api/objectCopy.js index 6073678bc3..2832974c6e 100644 --- a/lib/api/objectCopy.js +++ b/lib/api/objectCopy.js @@ -242,6 +242,9 @@ function objectCopy(authInfo, request, sourceBucket, sourceObject, sourceVersion */ checkQuota: true, request, + // The request targets the destination, the bucket policy must be + // evaluated against the source + policyTarget: { bucketName: sourceBucket, objectKey: sourceObject, versionId: sourceVersionId }, }; const valPutParams = { authInfo, diff --git a/lib/api/objectPutCopyPart.js b/lib/api/objectPutCopyPart.js index 3f3999b212..ae8fe640de 100644 --- a/lib/api/objectPutCopyPart.js +++ b/lib/api/objectPutCopyPart.js @@ -57,6 +57,9 @@ function objectPutCopyPart(authInfo, request, sourceBucket, */ checkQuota: true, request, + // The request targets the destination, the bucket policy must be + // evaluated against the source + policyTarget: { bucketName: sourceBucket, objectKey: sourceObject, versionId: reqVersionId }, }; const partNumber = Number.parseInt(request.query.partNumber, 10); diff --git a/lib/metadata/metadataUtils.js b/lib/metadata/metadataUtils.js index 35c6e01de6..d21f0a2ff9 100644 --- a/lib/metadata/metadataUtils.js +++ b/lib/metadata/metadataUtils.js @@ -202,6 +202,8 @@ function metadataGetObjects(bucketName, objectsKeys, log, cb) { * @param {string} params.requestType - type of request * @param {string} [params.preciseRequestType] - precise type of request * @param {object} params.request - http request object + * @param {object} [params.policyTarget] - resource to evaluate the bucket + * policy against when it differs from the request target * @param {RequestLogger} log - request logger * @param {object} actionImplicitDenies - identity authorization results * @return {ArsenalError|null} returns a validation error, or null if validation OK @@ -212,7 +214,7 @@ function metadataGetObjects(bucketName, objectsKeys, log, cb) { * - AccessDenied: bucket is not authorized */ function validateBucket(bucket, params, log, actionImplicitDenies = {}) { - const { authInfo, preciseRequestType, request } = params; + const { authInfo, preciseRequestType, request, policyTarget } = params; let requestType = params.requestType; if (bucketShield(bucket, requestType)) { log.debug('bucket is shielded from request', { @@ -249,6 +251,8 @@ function validateBucket(bucket, params, log, actionImplicitDenies = {}) { log, request, actionImplicitDenies, + false, + policyTarget, ) ) { log.debug('access denied for user on bucket', { requestType }); @@ -349,13 +353,19 @@ function checkRateLimitIfNeeded(request, authInfo, bucketMD, log, callback) { * @param {object} [params.serverAccessLogOptions] - options for server access logging * @param {boolean} [params.serverAccessLogOptions.copySource] - if true, stores source bucket * information in request.sourceServerAccessLog for copy operations + * @param {object} [params.policyTarget] - resource to evaluate the bucket policy against + * when it differs from the request target, e.g. the source of a copy + * @param {string} params.policyTarget.bucketName - name of bucket + * @param {string} params.policyTarget.objectKey - name of object + * @param {string} [params.policyTarget.versionId] - version id * @param {boolean} actionImplicitDenies - identity authorization results * @param {RequestLogger} log - request logger * @param {function} callback - callback * @return {undefined} - and call callback with params err, bucket md */ function standardMetadataValidateBucketAndObj(params, actionImplicitDenies, log, callback) { - const { authInfo, bucketName, objectKey, versionId, getDeleteMarker, request, withVersionId } = params; + const { authInfo, bucketName, objectKey, versionId, getDeleteMarker, request, withVersionId, policyTarget } = + params; let requestType = params.requestType; if (!Array.isArray(requestType)) { requestType = [requestType]; @@ -433,6 +443,8 @@ function standardMetadataValidateBucketAndObj(params, actionImplicitDenies, log, log, request, actionImplicitDenies, + false, + policyTarget, ) ) { log.debug('access denied for user on object', { requestType }); diff --git a/tests/unit/api/objectCopy.js b/tests/unit/api/objectCopy.js index 26d6af944e..f29402f237 100644 --- a/tests/unit/api/objectCopy.js +++ b/tests/unit/api/objectCopy.js @@ -152,10 +152,7 @@ describe('objectCopy with versioning', () => { }); }); - // TODO: S3C-9965 - // Skipped because the policy is not checked correctly - // When source bucket policy is checked destination arn is used - it.skip('should set bucketOwnerId if requesting account differs from dest bucket owner', done => { + it('should set bucketOwnerId if requesting account differs from dest bucket owner', done => { const authInfo2 = makeAuthInfo('accessKey2'); const testObjectCopyRequest = _createObjectCopyRequest(destBucketName); const testPutSrcPolicyRequest = new DummyRequest({ From 66872d01dd06003e2b4643a2e33dbe4f99333fe8 Mon Sep 17 00:00:00 2001 From: Thomas Carmet <8408330+tcarmet@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:05:58 -0700 Subject: [PATCH 3/3] CLDSRV-1021: Authorize a copy of a specific source version like a GetObject - Compare s3:VersionId with the copy source version ID as sent by the client, for both the bucket policy and the authorization request, instead of the internal version ID - Authorize the source of a versioned copy with objectGetVersion, the action checked by the authorization service, so its implicit deny is no longer read as an allow and bucket policy statements on s3:GetObjectVersion apply - Keep evaluating quotas for a copy of a specific source version --- lib/api/api.js | 7 +- .../authorization/prepareRequestContexts.js | 3 +- lib/api/apiUtils/object/parseCopySource.js | 12 +- lib/api/apiUtils/quotas/quotaUtils.js | 6 +- lib/api/objectCopy.js | 7 +- lib/api/objectPutCopyPart.js | 7 +- .../test/object/copySourceBucketPolicy.js | 515 ++++++++++++++++++ .../api/apiUtils/object/parseCopySource.js | 38 ++ tests/unit/api/apiUtils/quotas/quotaUtils.js | 13 + tests/unit/api/bucketPolicyAuth.js | 10 +- tests/unit/api/objectCopy.js | 273 ++++++++++ tests/unit/api/objectCopyPart.js | 215 +++++++- 12 files changed, 1087 insertions(+), 19 deletions(-) create mode 100644 tests/unit/api/apiUtils/object/parseCopySource.js diff --git a/lib/api/api.js b/lib/api/api.js index 487dd7d0ce..fb6c6b84b1 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -257,7 +257,7 @@ function callApiHandler(apiMethod, apiHandler, request, response, log, callback) return apiHandler(request, log, callback); } - const { sourceBucket, sourceObject, sourceVersionId, parsingError } = parseCopySource( + const { sourceBucket, sourceObject, sourceVersionId, copySourceVersionId, parsingError } = parseCopySource( apiMethod, request.headers['x-amz-copy-source'], ); @@ -276,7 +276,7 @@ function callApiHandler(apiMethod, apiHandler, request, response, log, callback) return process.nextTick(callback, httpHeadersSizeError); } - const requestContexts = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + const requestContexts = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, copySourceVersionId); // Extract all the _apiMethods and store them in an array const apiMethods = requestContexts ? requestContexts.map(context => context._apiMethod) : []; @@ -440,6 +440,9 @@ function callApiHandler(apiMethod, apiHandler, request, response, log, callback) return apiHandler(userInfo, request, streamingV4Params, log, methodCallback, authorizationResults); } if (apiMethod === 'objectCopy' || apiMethod === 'objectPutCopyPart') { + // The bucket policy is evaluated against the version ID as + // sent, the decoded one is for the metadata lookup + request.copySourceVersionId = copySourceVersionId; return apiHandler(userInfo, request, sourceBucket, sourceObject, sourceVersionId, log, methodCallback); } if (apiMethod === 'objectGet') { diff --git a/lib/api/apiUtils/authorization/prepareRequestContexts.js b/lib/api/apiUtils/authorization/prepareRequestContexts.js index 4fcf504aeb..b6946b8cee 100644 --- a/lib/api/apiUtils/authorization/prepareRequestContexts.js +++ b/lib/api/apiUtils/authorization/prepareRequestContexts.js @@ -29,7 +29,8 @@ function isHeaderAcl(headers) { * @param {object} request - request object * @param {string} sourceBucket - name of sourceBucket if copy request * @param {string} sourceObject - name of sourceObject if copy request - * @param {string} sourceVersionId - value of sourceVersionId if copy request + * @param {string} sourceVersionId - version ID of the copy source as sent by + * the client, if copy request * @return {RequestContext []} array of requestContexts */ function prepareRequestContexts(apiMethod, request, sourceBucket, diff --git a/lib/api/apiUtils/object/parseCopySource.js b/lib/api/apiUtils/object/parseCopySource.js index a28770ded3..a374095af0 100644 --- a/lib/api/apiUtils/object/parseCopySource.js +++ b/lib/api/apiUtils/object/parseCopySource.js @@ -7,7 +7,8 @@ const { decodeVersionId } = require('./versioning'); /** parseCopySource - parse objectCopy or objectPutCopyPart copy source header * @param {string} apiMethod - api method * @param {string} copySourceHeader - 'x-amz-copy-source' request header - * @return {object} - sourceBucket, sourceObject, sourceVersionId, parsingError + * @return {object} - sourceBucket, sourceObject, sourceVersionId (decoded), + * copySourceVersionId (as sent by the client), parsingError */ function parseCopySource(apiMethod, copySourceHeader) { if (apiMethod !== 'objectCopy' && apiMethod !== 'objectPutCopyPart') { @@ -26,14 +27,17 @@ function parseCopySource(apiMethod, copySourceHeader) { // Pull the source bucket and source object separated by / const sourceBucket = source.slice(0, slashSeparator); const sourceObject = source.slice(slashSeparator + 1); - const sourceVersionId = - decodeVersionId(query ? querystring.parse(query) : undefined); + const sourceQuery = query ? querystring.parse(query) : undefined; + const sourceVersionId = decodeVersionId(sourceQuery); if (sourceVersionId instanceof Error) { const err = sourceVersionId; return { parsingError: err }; } + // Authorization compares s3:VersionId with the version ID as the client + // sent it, like for a direct GetObject + const copySourceVersionId = sourceQuery?.versionId; - return { sourceBucket, sourceObject, sourceVersionId }; + return { sourceBucket, sourceObject, sourceVersionId, copySourceVersionId }; } module.exports = parseCopySource; diff --git a/lib/api/apiUtils/quotas/quotaUtils.js b/lib/api/apiUtils/quotas/quotaUtils.js index 6c967bec94..1defbb29cd 100644 --- a/lib/api/apiUtils/quotas/quotaUtils.js +++ b/lib/api/apiUtils/quotas/quotaUtils.js @@ -253,8 +253,10 @@ function validateQuotas(request, bucket, account, apiNames, apiMethod, inflight, // meaning the source object, containing the current bytes, // is checked second. This logic handles these APIs calls by // ensuring the bytes are positives (i.e., not an object - // replacement). - if (actionNeedQuotaCheckCopy(apiName, apiMethod)) { + // replacement). The source is checked with objectGetVersion + // instead of objectGet when the copy specifies its version. + const copySourceApiName = apiName === 'objectGetVersion' ? 'objectGet' : apiName; + if (actionNeedQuotaCheckCopy(copySourceApiName, apiMethod)) { // eslint-disable-next-line no-param-reassign inflight = Math.abs(inflight); } else if (!actionNeedQuotaCheck[apiName] && !actionWithDataDeletion[apiName]) { diff --git a/lib/api/objectCopy.js b/lib/api/objectCopy.js index 2832974c6e..bbeef31c70 100644 --- a/lib/api/objectCopy.js +++ b/lib/api/objectCopy.js @@ -232,7 +232,8 @@ function objectCopy(authInfo, request, sourceBucket, sourceObject, sourceVersion objectKey: sourceObject, versionId: sourceVersionId, getDeleteMarker: true, - requestType: 'objectGet', + // Same action as the one authorized by Vault for the source + requestType: sourceVersionId ? 'objectGetVersion' : 'objectGet', /** * Authorization will first check the target object, with an objectPut * action. But in this context, the source object metadata is still @@ -243,8 +244,8 @@ function objectCopy(authInfo, request, sourceBucket, sourceObject, sourceVersion checkQuota: true, request, // The request targets the destination, the bucket policy must be - // evaluated against the source - policyTarget: { bucketName: sourceBucket, objectKey: sourceObject, versionId: sourceVersionId }, + // evaluated against the source, with its version ID as sent + policyTarget: { bucketName: sourceBucket, objectKey: sourceObject, versionId: request.copySourceVersionId }, }; const valPutParams = { authInfo, diff --git a/lib/api/objectPutCopyPart.js b/lib/api/objectPutCopyPart.js index ae8fe640de..79fb681d44 100644 --- a/lib/api/objectPutCopyPart.js +++ b/lib/api/objectPutCopyPart.js @@ -47,7 +47,8 @@ function objectPutCopyPart(authInfo, request, sourceBucket, objectKey: sourceObject, versionId: reqVersionId, getDeleteMarker: true, - requestType: 'objectGet', + // Same action as the one authorized by Vault for the source + requestType: reqVersionId ? 'objectGetVersion' : 'objectGet', /** * Authorization will first check the target object, with an objectPut * action. But in this context, the source object metadata is still @@ -58,8 +59,8 @@ function objectPutCopyPart(authInfo, request, sourceBucket, checkQuota: true, request, // The request targets the destination, the bucket policy must be - // evaluated against the source - policyTarget: { bucketName: sourceBucket, objectKey: sourceObject, versionId: reqVersionId }, + // evaluated against the source, with its version ID as sent + policyTarget: { bucketName: sourceBucket, objectKey: sourceObject, versionId: request.copySourceVersionId }, }; const partNumber = Number.parseInt(request.query.partNumber, 10); diff --git a/tests/functional/aws-node-sdk/test/object/copySourceBucketPolicy.js b/tests/functional/aws-node-sdk/test/object/copySourceBucketPolicy.js index 5992bf9ea3..74b3506467 100644 --- a/tests/functional/aws-node-sdk/test/object/copySourceBucketPolicy.js +++ b/tests/functional/aws-node-sdk/test/object/copySourceBucketPolicy.js @@ -9,6 +9,7 @@ const { AbortMultipartUploadCommand, PutBucketPolicyCommand, DeleteBucketPolicyCommand, + PutBucketVersioningCommand, } = require('@aws-sdk/client-s3'); const { IAMClient, @@ -16,6 +17,10 @@ const { CreateAccessKeyCommand, DeleteAccessKeyCommand, DeleteUserCommand, + CreatePolicyCommand, + AttachUserPolicyCommand, + DetachUserPolicyCommand, + DeletePolicyCommand, } = require('@aws-sdk/client-iam'); const { v4: uuid } = require('uuid'); @@ -201,3 +206,513 @@ describeWithVault('Copy source with a bucket policy scoped to key prefixes', () assert(res.CopyPartResult.ETag, 'Expected a part ETag'); }); }); + +describeWithVault('Copy source version with an IAM policy on s3:VersionId', () => { + const versionBucketName = `copy-source-vid-${uuid()}`; + const versionUserName = `copy-source-vid-user-${uuid()}`; + const policyName = `copy-source-vid-policy-${uuid()}`; + const sourceKey = 'doc'; + const destKey = 'doc-copy'; + const bucketUtilAccount = new BucketUtility('default'); + const s3ClientAccount = bucketUtilAccount.s3; + + const iamConfig = getConfig('default', { region: 'us-east-1' }); + iamConfig.endpoint = `http://${vaultHost}:8600`; // define outside of getConfig for Integration + const iamClient = new IAMClient(iamConfig); + + let userS3Client; + let accessKeyId; + let policyArn; + let uploadId; + // The version IDs as the client sees them + let deniedVersionId; + let allowedVersionId; + + async function assertAccessDenied(promise) { + await assert.rejects(promise, { name: 'AccessDenied' }); + } + + before(async () => { + await bucketUtilAccount.createOne(versionBucketName); + await s3ClientAccount.send( + new PutBucketVersioningCommand({ + Bucket: versionBucketName, + VersioningConfiguration: { Status: 'Enabled' }, + }), + ); + const deniedPut = await s3ClientAccount.send( + new PutObjectCommand({ + Bucket: versionBucketName, + Key: sourceKey, + Body: 'denied version', + }), + ); + deniedVersionId = deniedPut.VersionId; + const allowedPut = await s3ClientAccount.send( + new PutObjectCommand({ + Bucket: versionBucketName, + Key: sourceKey, + Body: allowedContent, + }), + ); + allowedVersionId = allowedPut.VersionId; + + // IAM user allowed to read and write, except one version of the source + const bucketArn = `arn:aws:s3:::${versionBucketName}`; + const policyResponse = await iamClient.send( + new CreatePolicyCommand({ + PolicyName: policyName, + PolicyDocument: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['s3:GetObject', 's3:GetObjectVersion', 's3:PutObject'], + Resource: `${bucketArn}/*`, + }, + { + Effect: 'Deny', + Action: ['s3:GetObject', 's3:GetObjectVersion'], + Resource: `${bucketArn}/*`, + Condition: { + StringEquals: { 's3:VersionId': deniedVersionId }, + }, + }, + ], + }), + }), + ); + policyArn = policyResponse.Policy.Arn; + await iamClient.send(new CreateUserCommand({ UserName: versionUserName })); + await iamClient.send(new AttachUserPolicyCommand({ UserName: versionUserName, PolicyArn: policyArn })); + const accessKeyResponse = await iamClient.send(new CreateAccessKeyCommand({ UserName: versionUserName })); + accessKeyId = accessKeyResponse.AccessKey.AccessKeyId; + userS3Client = new S3Client( + getConfig('default', { + credentials: { + accessKeyId, + secretAccessKey: accessKeyResponse.AccessKey.SecretAccessKey, + }, + }), + ); + + const mpuResponse = await userS3Client.send( + new CreateMultipartUploadCommand({ + Bucket: versionBucketName, + Key: destKey, + }), + ); + uploadId = mpuResponse.UploadId; + }); + + after(async () => { + if (uploadId) { + await s3ClientAccount.send( + new AbortMultipartUploadCommand({ + Bucket: versionBucketName, + Key: destKey, + UploadId: uploadId, + }), + ); + } + await bucketUtilAccount.empty(versionBucketName); + await bucketUtilAccount.deleteOne(versionBucketName); + if (accessKeyId) { + await iamClient.send( + new DeleteAccessKeyCommand({ UserName: versionUserName, AccessKeyId: accessKeyId }), + ); + } + if (policyArn) { + await iamClient.send(new DetachUserPolicyCommand({ UserName: versionUserName, PolicyArn: policyArn })); + await iamClient.send(new DeletePolicyCommand({ PolicyArn: policyArn })); + } + await iamClient.send(new DeleteUserCommand({ UserName: versionUserName })); + }); + + it('should deny GetObject on the version denied by the IAM policy', async () => { + await assertAccessDenied( + userS3Client.send( + new GetObjectCommand({ + Bucket: versionBucketName, + Key: sourceKey, + VersionId: deniedVersionId, + }), + ), + ); + }); + + it('should deny CopyObject from the version denied by the IAM policy', async () => { + await assertAccessDenied( + userS3Client.send( + new CopyObjectCommand({ + Bucket: versionBucketName, + Key: destKey, + CopySource: `${versionBucketName}/${sourceKey}?versionId=${deniedVersionId}`, + }), + ), + ); + }); + + it('should deny UploadPartCopy from the version denied by the IAM policy', async () => { + await assertAccessDenied( + userS3Client.send( + new UploadPartCopyCommand({ + Bucket: versionBucketName, + Key: destKey, + UploadId: uploadId, + PartNumber: 1, + CopySource: `${versionBucketName}/${sourceKey}?versionId=${deniedVersionId}`, + }), + ), + ); + }); + + it('should allow CopyObject from a version not denied by the IAM policy', async () => { + await userS3Client.send( + new CopyObjectCommand({ + Bucket: versionBucketName, + Key: destKey, + CopySource: `${versionBucketName}/${sourceKey}?versionId=${allowedVersionId}`, + }), + ); + const getResponse = await userS3Client.send( + new GetObjectCommand({ + Bucket: versionBucketName, + Key: destKey, + }), + ); + assert.strictEqual(await getResponse.Body.transformToString(), allowedContent); + }); +}); + +describeWithVault('Copy of a specific source version', () => { + const secretKey = 'secret'; + + function makeIamClient(profile) { + const iamConfig = getConfig(profile, { region: 'us-east-1' }); + iamConfig.endpoint = `http://${vaultHost}:8600`; // define outside of getConfig for Integration + return new IAMClient(iamConfig); + } + + // Creates an IAM user with one managed policy, returns its S3 client and + // a function removing the user + async function createUserWithPolicy(iamClient, statements) { + const name = `copy-source-version-${uuid()}`; + const policyResponse = await iamClient.send( + new CreatePolicyCommand({ + PolicyName: name, + PolicyDocument: JSON.stringify({ Version: '2012-10-17', Statement: statements }), + }), + ); + const policyArn = policyResponse.Policy.Arn; + await iamClient.send(new CreateUserCommand({ UserName: name })); + await iamClient.send(new AttachUserPolicyCommand({ UserName: name, PolicyArn: policyArn })); + const accessKeyResponse = await iamClient.send(new CreateAccessKeyCommand({ UserName: name })); + const { AccessKeyId, SecretAccessKey } = accessKeyResponse.AccessKey; + const s3Client = new S3Client( + getConfig('default', { + credentials: { + accessKeyId: AccessKeyId, + secretAccessKey: SecretAccessKey, + }, + }), + ); + async function deleteUser() { + await iamClient.send(new DeleteAccessKeyCommand({ UserName: name, AccessKeyId })); + await iamClient.send(new DetachUserPolicyCommand({ UserName: name, PolicyArn: policyArn })); + await iamClient.send(new DeletePolicyCommand({ PolicyArn: policyArn })); + await iamClient.send(new DeleteUserCommand({ UserName: name })); + } + return { s3Client, deleteUser }; + } + + async function assertAccessDenied(promise) { + await assert.rejects(promise, { name: 'AccessDenied' }); + } + + describe('in the same account, without IAM permission to read the source', () => { + const ownBucketName = `copy-source-own-${uuid()}`; + const bucketUtil = new BucketUtility('default'); + const iamClient = makeIamClient('default'); + const mpuKey = 'mine/mpu-copy'; + let user; + let uploadId; + + before(async () => { + await bucketUtil.createOne(ownBucketName); + await bucketUtil.s3.send( + new PutObjectCommand({ + Bucket: ownBucketName, + Key: secretKey, + Body: 'secret content', + }), + ); + user = await createUserWithPolicy(iamClient, [ + { + Effect: 'Allow', + Action: ['s3:GetObject', 's3:PutObject'], + Resource: `arn:aws:s3:::${ownBucketName}/mine/*`, + }, + ]); + const mpuResponse = await user.s3Client.send( + new CreateMultipartUploadCommand({ + Bucket: ownBucketName, + Key: mpuKey, + }), + ); + uploadId = mpuResponse.UploadId; + }); + + after(async () => { + if (uploadId) { + await bucketUtil.s3.send( + new AbortMultipartUploadCommand({ + Bucket: ownBucketName, + Key: mpuKey, + UploadId: uploadId, + }), + ); + } + await bucketUtil.empty(ownBucketName); + await bucketUtil.deleteOne(ownBucketName); + if (user) { + await user.deleteUser(); + } + }); + + it('should deny CopyObject from the latest version', async () => { + await assertAccessDenied( + user.s3Client.send( + new CopyObjectCommand({ + Bucket: ownBucketName, + Key: 'mine/copy', + CopySource: `${ownBucketName}/${secretKey}`, + }), + ), + ); + }); + + it('should deny CopyObject from the null version', async () => { + await assertAccessDenied( + user.s3Client.send( + new CopyObjectCommand({ + Bucket: ownBucketName, + Key: 'mine/copy', + CopySource: `${ownBucketName}/${secretKey}?versionId=null`, + }), + ), + ); + }); + + it('should deny UploadPartCopy from the null version', async () => { + await assertAccessDenied( + user.s3Client.send( + new UploadPartCopyCommand({ + Bucket: ownBucketName, + Key: mpuKey, + UploadId: uploadId, + PartNumber: 1, + CopySource: `${ownBucketName}/${secretKey}?versionId=null`, + }), + ), + ); + }); + }); + + describe('with a bucket policy denying s3:GetObjectVersion', () => { + const policyBucketName = `copy-source-policy-${uuid()}`; + const bucketUtil = new BucketUtility('default'); + const iamClient = makeIamClient('default'); + const latestContent = 'latest content'; + let user; + let oldVersionId; + + before(async () => { + await bucketUtil.createOne(policyBucketName); + await bucketUtil.s3.send( + new PutBucketVersioningCommand({ + Bucket: policyBucketName, + VersioningConfiguration: { Status: 'Enabled' }, + }), + ); + const oldPut = await bucketUtil.s3.send( + new PutObjectCommand({ + Bucket: policyBucketName, + Key: secretKey, + Body: 'old content', + }), + ); + oldVersionId = oldPut.VersionId; + await bucketUtil.s3.send( + new PutObjectCommand({ + Bucket: policyBucketName, + Key: secretKey, + Body: latestContent, + }), + ); + await bucketUtil.s3.send( + new PutBucketPolicyCommand({ + Bucket: policyBucketName, + Policy: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Deny', + Principal: '*', + Action: 's3:GetObjectVersion', + Resource: `arn:aws:s3:::${policyBucketName}/*`, + }, + ], + }), + }), + ); + user = await createUserWithPolicy(iamClient, [ + { + Effect: 'Allow', + Action: ['s3:GetObject', 's3:GetObjectVersion', 's3:PutObject'], + Resource: `arn:aws:s3:::${policyBucketName}/*`, + }, + ]); + }); + + after(async () => { + await bucketUtil.s3.send(new DeleteBucketPolicyCommand({ Bucket: policyBucketName })); + await bucketUtil.empty(policyBucketName); + await bucketUtil.deleteOne(policyBucketName); + if (user) { + await user.deleteUser(); + } + }); + + it('should deny GetObject of an older version', async () => { + await assertAccessDenied( + user.s3Client.send( + new GetObjectCommand({ + Bucket: policyBucketName, + Key: secretKey, + VersionId: oldVersionId, + }), + ), + ); + }); + + it('should deny CopyObject from an older version', async () => { + await assertAccessDenied( + user.s3Client.send( + new CopyObjectCommand({ + Bucket: policyBucketName, + Key: 'leak', + CopySource: `${policyBucketName}/${secretKey}?versionId=${oldVersionId}`, + }), + ), + ); + }); + + it('should allow CopyObject from the latest version', async () => { + await user.s3Client.send( + new CopyObjectCommand({ + Bucket: policyBucketName, + Key: 'latest-copy', + CopySource: `${policyBucketName}/${secretKey}`, + }), + ); + const getResponse = await user.s3Client.send( + new GetObjectCommand({ + Bucket: policyBucketName, + Key: 'latest-copy', + }), + ); + assert.strictEqual(await getResponse.Body.transformToString(), latestContent); + }); + }); + + describe('from another account, allowed by the source bucket policy only', () => { + const sharedBucketName = `copy-source-shared-${uuid()}`; + const destBucketName = `copy-source-dest-${uuid()}`; + const sourceBucketUtil = new BucketUtility('default'); + const destBucketUtil = new BucketUtility('lisa'); + const iamClient = makeIamClient('lisa'); + let user; + + before(async () => { + await sourceBucketUtil.createOne(sharedBucketName); + await sourceBucketUtil.s3.send( + new PutObjectCommand({ + Bucket: sharedBucketName, + Key: secretKey, + Body: 'secret content', + }), + ); + await sourceBucketUtil.s3.send( + new PutBucketPolicyCommand({ + Bucket: sharedBucketName, + Policy: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject', 's3:GetObjectVersion'], + Resource: `arn:aws:s3:::${sharedBucketName}/*`, + }, + ], + }), + }), + ); + await destBucketUtil.createOne(destBucketName); + user = await createUserWithPolicy(iamClient, [ + { + Effect: 'Allow', + Action: 's3:PutObject', + Resource: `arn:aws:s3:::${destBucketName}/*`, + }, + ]); + }); + + after(async () => { + await sourceBucketUtil.s3.send(new DeleteBucketPolicyCommand({ Bucket: sharedBucketName })); + await sourceBucketUtil.empty(sharedBucketName); + await sourceBucketUtil.deleteOne(sharedBucketName); + await destBucketUtil.empty(destBucketName); + await destBucketUtil.deleteOne(destBucketName); + if (user) { + await user.deleteUser(); + } + }); + + it('should deny GetObject', async () => { + await assertAccessDenied( + user.s3Client.send( + new GetObjectCommand({ + Bucket: sharedBucketName, + Key: secretKey, + }), + ), + ); + }); + + it('should deny CopyObject from the latest version', async () => { + await assertAccessDenied( + user.s3Client.send( + new CopyObjectCommand({ + Bucket: destBucketName, + Key: 'copy', + CopySource: `${sharedBucketName}/${secretKey}`, + }), + ), + ); + }); + + it('should deny CopyObject from the null version', async () => { + await assertAccessDenied( + user.s3Client.send( + new CopyObjectCommand({ + Bucket: destBucketName, + Key: 'copy', + CopySource: `${sharedBucketName}/${secretKey}?versionId=null`, + }), + ), + ); + }); + }); +}); diff --git a/tests/unit/api/apiUtils/object/parseCopySource.js b/tests/unit/api/apiUtils/object/parseCopySource.js new file mode 100644 index 0000000000..e7e0667ed0 --- /dev/null +++ b/tests/unit/api/apiUtils/object/parseCopySource.js @@ -0,0 +1,38 @@ +const assert = require('assert'); +const { versioning } = require('arsenal'); + +const parseCopySource = require('../../../../../lib/api/apiUtils/object/parseCopySource'); + +const { VersionID } = versioning; + +describe('parseCopySource', () => { + const decodedVersionId = VersionID.generateVersionId('0', ''); + const encodedVersionId = VersionID.encode(decodedVersionId); + + it('should return the source bucket and key', () => { + const result = parseCopySource('objectCopy', '/srcbucket/some/key'); + assert.deepStrictEqual(result, { + sourceBucket: 'srcbucket', + sourceObject: 'some/key', + sourceVersionId: undefined, + copySourceVersionId: undefined, + }); + }); + + it('should return the decoded version ID and the version ID as sent', () => { + const result = parseCopySource('objectPutCopyPart', `srcbucket/key?versionId=${encodedVersionId}`); + assert.strictEqual(result.sourceVersionId, decodedVersionId); + assert.strictEqual(result.copySourceVersionId, encodedVersionId); + }); + + it('should return the null version ID as sent', () => { + const result = parseCopySource('objectCopy', 'srcbucket/key?versionId=null'); + assert.strictEqual(result.sourceVersionId, 'null'); + assert.strictEqual(result.copySourceVersionId, 'null'); + }); + + it('should return a parsing error for an invalid version ID', () => { + const result = parseCopySource('objectCopy', 'srcbucket/key?versionId=invalid'); + assert.strictEqual(result.parsingError.is.InvalidArgument, true); + }); +}); diff --git a/tests/unit/api/apiUtils/quotas/quotaUtils.js b/tests/unit/api/apiUtils/quotas/quotaUtils.js index 67b4750c43..bfd7bf72bd 100644 --- a/tests/unit/api/apiUtils/quotas/quotaUtils.js +++ b/tests/unit/api/apiUtils/quotas/quotaUtils.js @@ -115,6 +115,19 @@ describe('validateQuotas (buckets)', () => { }); }); + ['objectCopy', 'objectPutCopyPart'].forEach(apiMethod => { + it(`should return errors.QuotaExceeded if quota is exceeded by a ${apiMethod} of a source version`, done => { + QuotaService._getLatestMetricsCallback.yields(null, { bytesTotal: BigInt(150) }); + + validateQuotas(request, mockBucket, {}, ['objectGetVersion'], apiMethod, 1, false, mockLog, err => { + assert(err, 'expected the quota to be evaluated'); + assert.strictEqual(err.is.QuotaExceeded, true); + assert.strictEqual(QuotaService._getLatestMetricsCallback.callCount, 1); + done(); + }); + }); + }); + it('should not return QuotaExceeded if quotas are exceeded but operation is creating a delete marker', done => { const result1 = { bytesTotal: BigInt(150), diff --git a/tests/unit/api/bucketPolicyAuth.js b/tests/unit/api/bucketPolicyAuth.js index 16334bdaf0..6d81cb65ad 100644 --- a/tests/unit/api/bucketPolicyAuth.js +++ b/tests/unit/api/bucketPolicyAuth.js @@ -1,5 +1,6 @@ const assert = require('assert'); const { BucketInfo, BucketPolicy } = require('arsenal').models; +const { VersionID } = require('arsenal').versioning; const AuthInfo = require('arsenal').auth.AuthInfo; const constants = require('../../../constants'); const { isBucketAuthorized, isObjAuthorized, validatePolicyResource } @@ -561,6 +562,9 @@ describe('bucket policy authorization', () => { describe('bucket policy evaluated against a policy target', () => { const userAuthInfo = makeAuthInfo(accessKey, 'user'); const bucketArn = `arn:aws:s3:::${bucket.getName()}`; + // A copy source version ID as the client sends it + const protectedVersionId = VersionID.encode(VersionID.generateVersionId('0', '')); + const otherVersionId = VersionID.encode(VersionID.generateVersionId('0', '')); const prefixPolicy = { Version: '2012-10-17', Statement: [ @@ -593,7 +597,7 @@ describe('bucket policy authorization', () => { Action: 's3:GetObject', Resource: `${bucketArn}/*`, Condition: { - StringEquals: { 's3:VersionId': 'protected-version' }, + StringEquals: { 's3:VersionId': protectedVersionId }, }, }, ], @@ -643,7 +647,7 @@ describe('bucket policy authorization', () => { setPolicy(versionIdPolicy); const allowed = isObjAuthorized(bucket, object, 'objectGet', bucketOwnerCanonicalId, userAuthInfo, log, makeCopyRequest(), { objectGet: true }, false, - { bucketName: bucket.getName(), objectKey: 'allowed/myfile', versionId: 'protected-version' }); + { bucketName: bucket.getName(), objectKey: 'allowed/myfile', versionId: protectedVersionId }); assert.strictEqual(allowed, false); }); @@ -651,7 +655,7 @@ describe('bucket policy authorization', () => { setPolicy(versionIdPolicy); const allowed = isObjAuthorized(bucket, object, 'objectGet', bucketOwnerCanonicalId, userAuthInfo, log, makeCopyRequest(), { objectGet: true }, false, - { bucketName: bucket.getName(), objectKey: 'allowed/myfile', versionId: 'other-version' }); + { bucketName: bucket.getName(), objectKey: 'allowed/myfile', versionId: otherVersionId }); assert.strictEqual(allowed, true); }); }); diff --git a/tests/unit/api/objectCopy.js b/tests/unit/api/objectCopy.js index f29402f237..d08c9188a1 100644 --- a/tests/unit/api/objectCopy.js +++ b/tests/unit/api/objectCopy.js @@ -879,3 +879,276 @@ describe('objectCopy with a bucket policy scoped to key prefixes', () => { }); }); }); + +describe('objectCopy with a bucket policy on the source version ID', () => { + const bucketName = 'versionidpolicybucket'; + const sourceKey = 'doc'; + const userAuthInfo = makeAuthInfo(canonicalID, 'user'); + const enableRequest = versioningTestUtils.createBucketPutVersioningReq(bucketName, 'Enabled'); + // The version IDs as the client sees them + const versionIds = []; + + // An IAM user without identity policy: Vault returns an implicit deny + // for both the source read and the destination write + function _createUserCopyRequest(copySourceVersionId) { + const request = new DummyRequest({ + bucketName, + namespace, + objectKey: 'doc-copy', + headers: {}, + url: `/${bucketName}/doc-copy`, + query: {}, + socket: {}, + }); + request.actionImplicitDenies = { objectGet: true, objectGetVersion: true, objectPut: true }; + request.copySourceVersionId = copySourceVersionId; + return request; + } + + function _putObject(body, callback) { + objectPut( + authInfo, + versioningTestUtils.createPutObjectRequest(bucketName, sourceKey, body), + undefined, + log, + (err, resHeaders) => { + if (err) { + return callback(err); + } + versionIds.push(resHeaders['x-amz-version-id']); + return callback(); + }, + ); + } + + // PutBucketPolicy does not accept s3:VersionId as a condition key, but a + // stored policy can still carry it, so it is written directly + function _setBucketPolicy(callback) { + const bucketArn = `arn:aws:s3:::${bucketName}`; + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject', 's3:GetObjectVersion', 's3:PutObject'], + Resource: `${bucketArn}/*`, + }, + { + Effect: 'Deny', + Principal: '*', + Action: ['s3:GetObject', 's3:GetObjectVersion'], + Resource: `${bucketArn}/*`, + Condition: { + StringEquals: { 's3:VersionId': versionIds[0] }, + }, + }, + ], + }; + metadata.getBucket(bucketName, log, (err, bucket) => { + if (err) { + return callback(err); + } + bucket.setBucketPolicy(policy); + return metadata.updateBucket(bucketName, bucket, log, callback); + }); + } + + before(done => { + cleanup(); + async.series( + [ + callback => bucketPut(authInfo, _createBucketPutRequest(bucketName), log, callback), + callback => bucketPutVersioning(authInfo, enableRequest, log, callback), + callback => _putObject(objData[0], callback), + callback => _putObject(objData[1], callback), + callback => _setBucketPolicy(callback), + ], + done, + ); + }); + + after(() => { + cleanup(); + }); + + it('should deny copying the version denied by the bucket policy', done => { + const request = _createUserCopyRequest(versionIds[0]); + const sourceVersionId = versioning.VersionID.decode(versionIds[0]); + objectCopy(userAuthInfo, request, bucketName, sourceKey, sourceVersionId, log, err => { + assert(err, 'expected the copy to be denied'); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should allow copying a version not denied by the bucket policy', done => { + const request = _createUserCopyRequest(versionIds[1]); + const sourceVersionId = versioning.VersionID.decode(versionIds[1]); + objectCopy(userAuthInfo, request, bucketName, sourceKey, sourceVersionId, log, err => { + assert.ifError(err); + done(); + }); + }); +}); + +describe('objectCopy of a specific source version', () => { + const ownBucketName = 'ownversionbucket'; + const policyBucketName = 'policyversionbucket'; + const sharedBucketName = 'sharedversionbucket'; + const otherBucketName = 'otherversionbucket'; + const otherAuthInfo = makeAuthInfo('accessKey2'); + const userAuthInfo = makeAuthInfo(canonicalID, 'user'); + const otherUserAuthInfo = makeAuthInfo('accessKey2', 'user'); + const enableRequest = versioningTestUtils.createBucketPutVersioningReq(policyBucketName, 'Enabled'); + // The version IDs of the policy bucket source, as the client sees them + const versionIds = []; + + function _createPutPolicyRequest(bucketName, statement) { + return new DummyRequest({ + bucketName, + namespace, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: '/', + socket: {}, + post: JSON.stringify({ + Version: '2012-10-17', + Statement: [statement], + }), + }); + } + + function _createUserCopyRequest(bucketName, copySourceVersionId, actionImplicitDenies) { + const request = new DummyRequest({ + bucketName, + namespace, + objectKey: 'copy', + headers: {}, + url: `/${bucketName}/copy`, + query: {}, + socket: {}, + }); + request.actionImplicitDenies = actionImplicitDenies; + request.copySourceVersionId = copySourceVersionId; + return request; + } + + function _putObject(bucketName, body, callback) { + objectPut( + authInfo, + versioningTestUtils.createPutObjectRequest(bucketName, 'secret', body), + undefined, + log, + (err, resHeaders) => callback(err, resHeaders), + ); + } + + before(done => { + cleanup(); + async.series( + [ + callback => bucketPut(authInfo, _createBucketPutRequest(ownBucketName), log, callback), + callback => _putObject(ownBucketName, objData[0], callback), + callback => bucketPut(authInfo, _createBucketPutRequest(policyBucketName), log, callback), + callback => bucketPutVersioning(authInfo, enableRequest, log, callback), + callback => + _putObject(policyBucketName, objData[0], (err, resHeaders) => { + versionIds.push(resHeaders?.['x-amz-version-id']); + callback(err); + }), + callback => + _putObject(policyBucketName, objData[1], (err, resHeaders) => { + versionIds.push(resHeaders?.['x-amz-version-id']); + callback(err); + }), + callback => + bucketPutPolicy( + authInfo, + _createPutPolicyRequest(policyBucketName, { + Effect: 'Deny', + Principal: '*', + Action: 's3:GetObjectVersion', + Resource: `arn:aws:s3:::${policyBucketName}/*`, + }), + log, + callback, + ), + callback => bucketPut(authInfo, _createBucketPutRequest(sharedBucketName), log, callback), + callback => _putObject(sharedBucketName, objData[0], callback), + callback => + bucketPutPolicy( + authInfo, + _createPutPolicyRequest(sharedBucketName, { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject', 's3:GetObjectVersion'], + Resource: `arn:aws:s3:::${sharedBucketName}/*`, + }), + log, + callback, + ), + callback => bucketPut(otherAuthInfo, _createBucketPutRequest(otherBucketName), log, callback), + ], + done, + ); + }); + + after(() => { + cleanup(); + }); + + it('should deny a null version copy when IAM does not allow reading the source version', done => { + const request = _createUserCopyRequest(ownBucketName, 'null', { objectGetVersion: true, objectPut: false }); + objectCopy(userAuthInfo, request, ownBucketName, 'secret', 'null', log, err => { + assert(err, 'expected the copy to be denied'); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should allow a null version copy when IAM allows reading the source version', done => { + const request = _createUserCopyRequest(ownBucketName, 'null', { objectGetVersion: false, objectPut: false }); + objectCopy(userAuthInfo, request, ownBucketName, 'secret', 'null', log, err => { + assert.ifError(err); + done(); + }); + }); + + it('should deny copying a version when the bucket policy denies s3:GetObjectVersion', done => { + const request = _createUserCopyRequest(policyBucketName, versionIds[0], { + objectGetVersion: false, + objectPut: false, + }); + const sourceVersionId = versioning.VersionID.decode(versionIds[0]); + objectCopy(userAuthInfo, request, policyBucketName, 'secret', sourceVersionId, log, err => { + assert(err, 'expected the copy to be denied'); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should allow copying the latest version when the bucket policy denies s3:GetObjectVersion', done => { + const request = _createUserCopyRequest(policyBucketName, undefined, { objectGet: false, objectPut: false }); + objectCopy(userAuthInfo, request, policyBucketName, 'secret', undefined, log, err => { + assert.ifError(err); + done(); + }); + }); + + it('should deny a cross-account null version copy when IAM does not allow reading the source version', done => { + const request = _createUserCopyRequest(otherBucketName, 'null', { objectGetVersion: true, objectPut: false }); + objectCopy(otherUserAuthInfo, request, sharedBucketName, 'secret', 'null', log, err => { + assert(err, 'expected the copy to be denied'); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should allow a cross-account null version copy when IAM allows reading the source version', done => { + const request = _createUserCopyRequest(otherBucketName, 'null', { objectGetVersion: false, objectPut: false }); + objectCopy(otherUserAuthInfo, request, sharedBucketName, 'secret', 'null', log, err => { + assert.ifError(err); + done(); + }); + }); +}); diff --git a/tests/unit/api/objectCopyPart.js b/tests/unit/api/objectCopyPart.js index 6613d3c147..8372889097 100644 --- a/tests/unit/api/objectCopyPart.js +++ b/tests/unit/api/objectCopyPart.js @@ -2,9 +2,10 @@ const assert = require('assert'); const async = require('async'); const sinon = require('sinon'); const { parseString } = require('xml2js'); -const { storage } = require('arsenal'); +const { storage, versioning } = require('arsenal'); const { bucketPut } = require('../../../lib/api/bucketPut'); const bucketPutPolicy = require('../../../lib/api/bucketPutPolicy'); +const bucketPutVersioning = require('../../../lib/api/bucketPutVersioning'); const objectPut = require('../../../lib/api/objectPut'); const objectPutCopyPart = require('../../../lib/api/objectPutCopyPart'); const initiateMultipartUpload @@ -261,3 +262,215 @@ describe('objectCopyPart with a bucket policy scoped to key prefixes', () => { }); }); }); + +describe('objectCopyPart with a bucket policy on the source version ID', () => { + const bucketName = 'versionidpolicybucket'; + const sourceKey = 'doc'; + const destKey = 'mpu-copy'; + const userAuthInfo = makeAuthInfo(canonicalID, 'user'); + const enableRequest = versioningTestUtils.createBucketPutVersioningReq(bucketName, 'Enabled'); + const initiateUserRequest = new DummyRequest({ + bucketName, + namespace, + objectKey: destKey, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: `/${destKey}?uploads`, + query: {}, + socket: {}, + }); + // The version IDs as the client sees them + const versionIds = []; + let uploadId; + + // An IAM user without identity policy: Vault returns an implicit deny + // for both the source read and the destination write + function _createUserCopyPartRequest(copySourceVersionId) { + const request = new DummyRequest({ + bucketName, + namespace, + objectKey: destKey, + headers: {}, + url: `/${bucketName}/${destKey}?partNumber=1`, + query: { + partNumber: 1, + uploadId, + }, + socket: {}, + }); + request.actionImplicitDenies = { objectGet: true, objectGetVersion: true, objectPut: true }; + request.copySourceVersionId = copySourceVersionId; + return request; + } + + function _putObject(body, callback) { + objectPut( + authInfo, + versioningTestUtils.createPutObjectRequest(bucketName, sourceKey, Buffer.from(body, 'utf8')), + undefined, + log, + (err, resHeaders) => { + if (err) { + return callback(err); + } + versionIds.push(resHeaders['x-amz-version-id']); + return callback(); + }, + ); + } + + // PutBucketPolicy does not accept s3:VersionId as a condition key, but a + // stored policy can still carry it, so it is written directly + function _setBucketPolicy(callback) { + const bucketArn = `arn:aws:s3:::${bucketName}`; + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject', 's3:GetObjectVersion', 's3:PutObject'], + Resource: `${bucketArn}/*`, + }, + { + Effect: 'Deny', + Principal: '*', + Action: ['s3:GetObject', 's3:GetObjectVersion'], + Resource: `${bucketArn}/*`, + Condition: { + StringEquals: { 's3:VersionId': versionIds[0] }, + }, + }, + ], + }; + metadataswitch.getBucket(bucketName, log, (err, bucket) => { + if (err) { + return callback(err); + } + bucket.setBucketPolicy(policy); + return metadataswitch.updateBucket(bucketName, bucket, log, callback); + }); + } + + before(done => { + cleanup(); + async.waterfall([ + callback => bucketPut(authInfo, _createBucketPutRequest(bucketName), log, + err => callback(err)), + callback => bucketPutVersioning(authInfo, enableRequest, log, + err => callback(err)), + callback => _putObject('foo', callback), + callback => _putObject('bar', callback), + callback => _setBucketPolicy(err => callback(err)), + callback => initiateMultipartUpload(userAuthInfo, initiateUserRequest, + log, (err, res) => callback(err, res)), + ], (err, res) => { + if (err) { + return done(err); + } + return parseString(res, (err, json) => { + uploadId = json.InitiateMultipartUploadResult.UploadId[0]; + return done(err); + }); + }); + }); + + after(() => { + cleanup(); + }); + + it('should deny copying a part from the version denied by the bucket policy', done => { + const sourceVersionId = versioning.VersionID.decode(versionIds[0]); + objectPutCopyPart(userAuthInfo, _createUserCopyPartRequest(versionIds[0]), bucketName, sourceKey, + sourceVersionId, log, err => { + assert(err, 'expected the part copy to be denied'); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should allow copying a part from a version not denied by the bucket policy', done => { + const sourceVersionId = versioning.VersionID.decode(versionIds[1]); + objectPutCopyPart(userAuthInfo, _createUserCopyPartRequest(versionIds[1]), bucketName, sourceKey, + sourceVersionId, log, err => { + assert.ifError(err); + done(); + }); + }); +}); + +describe('objectCopyPart of a specific source version', () => { + const bucketName = 'ownversionbucket'; + const sourceKey = 'secret'; + const destKey = 'mpu-copy'; + const userAuthInfo = makeAuthInfo(canonicalID, 'user'); + const initiateUserRequest = new DummyRequest({ + bucketName, + namespace, + objectKey: destKey, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: `/${destKey}?uploads`, + query: {}, + socket: {}, + }); + let uploadId; + + function _createUserCopyPartRequest(actionImplicitDenies) { + const request = new DummyRequest({ + bucketName, + namespace, + objectKey: destKey, + headers: {}, + url: `/${bucketName}/${destKey}?partNumber=1`, + query: { + partNumber: 1, + uploadId, + }, + socket: {}, + }); + request.actionImplicitDenies = actionImplicitDenies; + request.copySourceVersionId = 'null'; + return request; + } + + before(done => { + cleanup(); + async.waterfall([ + callback => bucketPut(authInfo, _createBucketPutRequest(bucketName), log, + err => callback(err)), + callback => objectPut(authInfo, + versioningTestUtils.createPutObjectRequest(bucketName, sourceKey, Buffer.from('foo', 'utf8')), + undefined, log, err => callback(err)), + callback => initiateMultipartUpload(userAuthInfo, initiateUserRequest, + log, (err, res) => callback(err, res)), + ], (err, res) => { + if (err) { + return done(err); + } + return parseString(res, (err, json) => { + uploadId = json.InitiateMultipartUploadResult.UploadId[0]; + return done(err); + }); + }); + }); + + after(() => { + cleanup(); + }); + + it('should deny a null version part copy when IAM does not allow reading the source version', done => { + const request = _createUserCopyPartRequest({ objectGetVersion: true, objectPut: false }); + objectPutCopyPart(userAuthInfo, request, bucketName, sourceKey, 'null', log, err => { + assert(err, 'expected the part copy to be denied'); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should allow a null version part copy when IAM allows reading the source version', done => { + const request = _createUserCopyPartRequest({ objectGetVersion: false, objectPut: false }); + objectPutCopyPart(userAuthInfo, request, bucketName, sourceKey, 'null', log, err => { + assert.ifError(err); + done(); + }); + }); +});