diff --git a/lib/routes/routeBackbeat.js b/lib/routes/routeBackbeat.js index 4ed7632763..e42ad4467e 100644 --- a/lib/routes/routeBackbeat.js +++ b/lib/routes/routeBackbeat.js @@ -1368,7 +1368,10 @@ function routeBackbeat(clientIP, request, response, log) { [request.query.operation](request, response, log, next); } const versioningConfig = bucketInfo.getVersioningConfiguration(); - if (!versioningConfig || versioningConfig.Status !== 'Enabled') { + // The following makes sure that only replication destination-related operations + // target buckets with versioning enabled. + const isVersioningRequired = request.headers['x-scal-versioning-required'] === 'true'; + if (isVersioningRequired && (!versioningConfig || versioningConfig.Status !== 'Enabled')) { log.debug('bucket versioning is not enabled', { method: request.method, bucketName: request.bucketName, diff --git a/tests/functional/raw-node/test/routes/routeBackbeat.js b/tests/functional/raw-node/test/routes/routeBackbeat.js index 98d0af3925..8efa81fdf6 100644 --- a/tests/functional/raw-node/test/routes/routeBackbeat.js +++ b/tests/functional/raw-node/test/routes/routeBackbeat.js @@ -23,6 +23,7 @@ const TEST_BUCKET = 'backbeatbucket'; const TEST_ENCRYPTED_BUCKET = 'backbeatbucket-encrypted'; const TEST_KEY = 'fookey'; const NONVERSIONED_BUCKET = 'backbeatbucket-non-versioned'; +const VERSION_SUSPENDED_BUCKET = 'backbeatbucket-version-suspended'; const BUCKET_FOR_NULL_VERSION = 'backbeatbucket-null-version'; const testArn = 'aws::iam:123456789012:user/bart'; @@ -129,7 +130,8 @@ describeSkipIfAWS('backbeat routes', () => { bucketUtil = new BucketUtility( 'default', { signatureVersion: 'v4' }); s3 = bucketUtil.s3; - bucketUtil.emptyManyIfExists([TEST_BUCKET, TEST_ENCRYPTED_BUCKET, NONVERSIONED_BUCKET]) + bucketUtil.emptyManyIfExists([TEST_BUCKET, TEST_ENCRYPTED_BUCKET, NONVERSIONED_BUCKET, + VERSION_SUSPENDED_BUCKET]) .then(() => s3.createBucket({ Bucket: TEST_BUCKET }).promise()) .then(() => s3.putBucketVersioning( { @@ -139,6 +141,12 @@ describeSkipIfAWS('backbeat routes', () => { .then(() => s3.createBucket({ Bucket: NONVERSIONED_BUCKET, }).promise()) + .then(() => s3.createBucket({ Bucket: VERSION_SUSPENDED_BUCKET }).promise()) + .then(() => s3.putBucketVersioning( + { + Bucket: VERSION_SUSPENDED_BUCKET, + VersioningConfiguration: { Status: 'Suspended' }, + }).promise()) .then(() => s3.createBucket({ Bucket: TEST_ENCRYPTED_BUCKET }).promise()) .then(() => s3.putBucketVersioning( { @@ -172,6 +180,8 @@ describeSkipIfAWS('backbeat routes', () => { .then(() => s3.deleteBucket({ Bucket: TEST_ENCRYPTED_BUCKET }).promise()) .then(() => s3.deleteBucket({ Bucket: NONVERSIONED_BUCKET }).promise()) + .then(() => + s3.deleteBucket({ Bucket: VERSION_SUSPENDED_BUCKET }).promise()) .then(() => done(), err => done(err)) ); @@ -1508,37 +1518,95 @@ describeSkipIfAWS('backbeat routes', () => { }); }); - it('should refuse PUT data if bucket is not versioned', - done => makeBackbeatRequest({ - method: 'PUT', bucket: NONVERSIONED_BUCKET, - objectKey: testKey, resourceType: 'data', - queryObj: { v2: '' }, - headers: { - 'content-length': testData.length, - 'x-scal-canonical-id': testArn, + const testCases = [ + { + description: 'bucket is version suspended', + bucket: VERSION_SUSPENDED_BUCKET, }, - authCredentials: backbeatAuthCredentials, - requestBody: testData, - }, - err => { - assert.strictEqual(err.code, 'InvalidBucketState'); - done(); - })); - - it('should refuse PUT metadata if bucket is not versioned', - done => makeBackbeatRequest({ - method: 'PUT', bucket: NONVERSIONED_BUCKET, - objectKey: testKey, resourceType: 'metadata', - queryObj: { - versionId: versionIdUtils.encode(testMd.versionId), + { + description: 'bucket is not versioned', + bucket: NONVERSIONED_BUCKET, }, - authCredentials: backbeatAuthCredentials, - requestBody: JSON.stringify(testMd), - }, - err => { - assert.strictEqual(err.code, 'InvalidBucketState'); - done(); - })); + ]; + + testCases.forEach(({ description, bucket }) => { + it(`should PUT metadata and data if ${description} and x-scal-versioning-required is not set`, done => { + async.waterfall([next => { + makeBackbeatRequest({ + method: 'PUT', bucket, + objectKey: 'test-updatemd-key', + resourceType: 'data', + queryObj: { v2: '' }, + headers: { + 'content-length': testData.length, + 'x-scal-canonical-id': testArn, + }, + authCredentials: backbeatAuthCredentials, + requestBody: testData, + }, next); + }, (response, next) => { + assert.strictEqual(response.statusCode, 200); + const newMd = getMetadataToPut(response); + makeBackbeatRequest({ + method: 'PUT', bucket, + objectKey: 'test-updatemd-key', + resourceType: 'metadata', + queryObj: { + versionId: versionIdUtils.encode(testMd.versionId), + }, + authCredentials: backbeatAuthCredentials, + requestBody: JSON.stringify(newMd), + }, next); + }], err => { + assert.ifError(err); + done(); + }); + }); + }); + + testCases.forEach(({ description, bucket }) => { + it(`should refuse PUT data if ${description} and x-scal-versioning-required is true`, done => { + makeBackbeatRequest({ + method: 'PUT', + bucket, + objectKey: testKey, + resourceType: 'data', + queryObj: { v2: '' }, + headers: { + 'content-length': testData.length, + 'x-scal-canonical-id': testArn, + 'x-scal-versioning-required': 'true', + }, + authCredentials: backbeatAuthCredentials, + requestBody: testData, + }, err => { + assert.strictEqual(err.code, 'InvalidBucketState'); + done(); + }); + }); + }); + + testCases.forEach(({ description, bucket }) => { + it(`should refuse PUT metadata if ${description} and x-scal-versioning-required is true`, done => { + makeBackbeatRequest({ + method: 'PUT', + bucket, + objectKey: testKey, + resourceType: 'metadata', + queryObj: { + versionId: versionIdUtils.encode(testMd.versionId), + }, + headers: { + 'x-scal-versioning-required': 'true', + }, + authCredentials: backbeatAuthCredentials, + requestBody: JSON.stringify(testMd), + }, err => { + assert.strictEqual(err.code, 'InvalidBucketState'); + done(); + }); + }); + }); it('should refuse PUT data if no x-scal-canonical-id header ' + 'is provided', done => makeBackbeatRequest({ diff --git a/tests/unit/routes/routeBackbeat.js b/tests/unit/routes/routeBackbeat.js new file mode 100644 index 0000000000..58f8b2106b --- /dev/null +++ b/tests/unit/routes/routeBackbeat.js @@ -0,0 +1,185 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const metadataUtils = require('../../../lib/metadata/metadataUtils'); +const storeObject = require('../../../lib/api/apiUtils/object/storeObject'); +const metadata = require('../../../lib/metadata/wrapper'); +const { DummyRequestLogger } = require('../helpers'); +const DummyRequest = require('../DummyRequest'); + +const log = new DummyRequestLogger(); + +function prepareDummyRequest(headers = {}) { + const request = new DummyRequest({ + hostname: 'localhost', + method: 'PUT', + url: '/_/backbeat/metadata/bucket0/key0', + port: 80, + headers, + socket: { + remoteAddress: '0.0.0.0', + }, + }, '{"replicationInfo":"{}"}'); + return request; +} + +describe('routeBackbeat', () => { + let mockResponse; + let mockRequest; + let sandbox; + let endPromise; + let resolveEnd; + let routeBackbeat; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // create a Promise that resolves when response.end is called + endPromise = new Promise((resolve) => { resolveEnd = resolve; }); + + mockResponse = { + statusCode: null, + body: null, + setHeader: () => {}, + writeHead: sandbox.spy(statusCode => { + mockResponse.statusCode = statusCode; + }), + end: sandbox.spy((body, encoding, callback) => { + mockResponse.body = JSON.parse(body); + if (callback) callback(); + resolveEnd(); // Resolve the Promise when end is called + }), + }; + + mockRequest = prepareDummyRequest(); + + sandbox.stub(metadataUtils, 'standardMetadataValidateBucketAndObj'); + sandbox.stub(storeObject, 'dataStore'); + + // Clear require cache for routeBackbeat to make sure fresh module with stubbed dependencies + delete require.cache[require.resolve('../../../lib/routes/routeBackbeat')]; + routeBackbeat = require('../../../lib/routes/routeBackbeat'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const rejectionTests = [ + { + description: 'should reject CRR destination (putData) requests when versioning is disabled', + method: 'PUT', + url: '/_/backbeat/data/bucket0/key0', + }, + { + description: 'should reject CRR destination (putMetadata) requests when versioning is disabled', + method: 'PUT', + url: '/_/backbeat/metadata/bucket0/key0', + }, + ]; + + rejectionTests.forEach(({ description, method, url }) => { + it(description, async () => { + mockRequest.method = method; + mockRequest.url = url; + mockRequest.headers = { + 'x-scal-versioning-required': 'true', + }; + metadataUtils.standardMetadataValidateBucketAndObj.callsFake((params, denies, log, callback) => { + const bucketInfo = { + getVersioningConfiguration: () => ({ Status: 'Disabled' }), + }; + const objMd = {}; + callback(null, bucketInfo, objMd); + }); + + routeBackbeat('127.0.0.1', mockRequest, mockResponse, log); + + void await endPromise; + + assert.strictEqual(mockResponse.statusCode, 409); + assert.strictEqual(mockResponse.body.code, 'InvalidBucketState'); + }); + }); + + it('should allow non-CRR destination (getMetadata) requests regardless of versioning', async () => { + mockRequest.method = 'GET'; + + metadataUtils.standardMetadataValidateBucketAndObj.callsFake((params, denies, log, callback) => { + const bucketInfo = { + getVersioningConfiguration: () => ({ Status: 'Disabled' }), + }; + const objMd = {}; + callback(null, bucketInfo, objMd); + }); + + routeBackbeat('127.0.0.1', mockRequest, mockResponse, log); + + void await endPromise; + + assert.strictEqual(mockResponse.statusCode, 200); + assert.deepStrictEqual(mockResponse.body, { Body: '{}' }); + }); + + it('should allow CRR destination requests (putMetadata) when versioning is enabled', async () => { + mockRequest.method = 'PUT'; + mockRequest.url = '/_/backbeat/metadata/bucket0/key0'; + mockRequest.headers = { + 'x-scal-versioning-required': 'true', + }; + mockRequest.destroy = () => {}; + + sandbox.stub(metadata, 'putObjectMD').callsFake((bucketName, objectKey, omVal, options, logParam, cb) => { + cb(null, {}); + }); + + metadataUtils.standardMetadataValidateBucketAndObj.callsFake((params, denies, log, callback) => { + const bucketInfo = { + getVersioningConfiguration: () => ({ Status: 'Enabled' }), + isVersioningEnabled: () => true, + }; + const objMd = {}; + callback(null, bucketInfo, objMd); + }); + + routeBackbeat('127.0.0.1', mockRequest, mockResponse, log); + + void await endPromise; + + assert.strictEqual(mockResponse.statusCode, 200); + assert.deepStrictEqual(mockResponse.body, {}); + }); + + it('should allow CRR destination requests (putData) when versioning is enabled', async () => { + const md5 = '1234'; + mockRequest.method = 'PUT'; + mockRequest.url = '/_/backbeat/data/bucket0/key0'; + mockRequest.headers = { + 'x-scal-canonical-id': 'id', + 'content-md5': md5, + 'content-length': '0', + 'x-scal-versioning-required': 'true', + }; + mockRequest.destroy = () => {}; + + metadataUtils.standardMetadataValidateBucketAndObj.callsFake((params, denies, log, callback) => { + const bucketInfo = { + getVersioningConfiguration: () => ({ Status: 'Enabled' }), + isVersioningEnabled: () => true, + getLocationConstraint: () => undefined, + }; + const objMd = {}; + callback(null, bucketInfo, objMd); + }); + storeObject.dataStore.callsFake((objectContext, cipherBundle, stream, size, + streamingV4Params, backendInfo, log, callback) => { + callback(null, {}, md5); + }); + + routeBackbeat('127.0.0.1', mockRequest, mockResponse, log); + + void await endPromise; + + assert.strictEqual(mockResponse.statusCode, 200); + assert.deepStrictEqual(mockResponse.body, [{}]); + }); +});