Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLDSRV-584 Limit backbeat API versioning check to replication operations #5707

Merged
merged 1 commit into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/routes/routeBackbeat.js
Original file line number Diff line number Diff line change
Expand Up @@ -1289,7 +1289,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,
Expand Down
144 changes: 114 additions & 30 deletions tests/functional/raw-node/test/routes/routeBackbeat.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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';
Expand Down Expand Up @@ -120,7 +121,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(
{
Expand All @@ -130,6 +132,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(
{
Expand Down Expand Up @@ -161,8 +169,12 @@ describeSkipIfAWS('backbeat routes', () => {
.then(() => s3.deleteBucket({ Bucket: TEST_BUCKET }).promise())
.then(() => bucketUtil.empty(TEST_ENCRYPTED_BUCKET))
.then(() => s3.deleteBucket({ Bucket: TEST_ENCRYPTED_BUCKET }).promise())
.then(() => bucketUtil.empty(NONVERSIONED_BUCKET))
.then(() =>
s3.deleteBucket({ Bucket: NONVERSIONED_BUCKET }).promise())
.then(() => bucketUtil.empty(VERSION_SUSPENDED_BUCKET))
.then(() =>
s3.deleteBucket({ Bucket: VERSION_SUSPENDED_BUCKET }).promise())
.then(() => done(), err => done(err))
);

Expand Down Expand Up @@ -1496,37 +1508,109 @@ 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 => {
let objectMd;
async.waterfall([
next => s3.putObject({
Bucket: bucket,
Key: 'sourcekey',
Body: new Buffer(testData) },
next),
(resp, next) => makeBackbeatRequest({
method: 'GET',
resourceType: 'metadata',
bucket,
objectKey: 'sourcekey',
authCredentials: backbeatAuthCredentials,
}, (err, resp) => {
objectMd = JSON.parse(resp.body).Body;
return next();
}),
next => {
makeBackbeatRequest({
method: 'PUT', bucket,
objectKey: 'destinationkey',
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);
makeBackbeatRequest({
method: 'PUT', bucket,
objectKey: 'destinationkey',
resourceType: 'metadata',
authCredentials: backbeatAuthCredentials,
requestBody: objectMd,
}, 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({
Expand Down
185 changes: 185 additions & 0 deletions tests/unit/routes/routeBackbeat.js
Original file line number Diff line number Diff line change
@@ -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, [{}]);
});
});
Loading