Skip to content

Commit 1d3ef8a

Browse files
committed
Merge remote-tracking branch 'origin/feature/CLDSRV-813/optional-attributes-response' into w/9.2/feature/CLDSRV-813/optional-attributes-response
2 parents 146226a + 09c36ce commit 1d3ef8a

File tree

5 files changed

+362
-3
lines changed

5 files changed

+362
-3
lines changed

lib/api/bucketGet.js

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ function processVersions(bucketName, listParams, list) {
138138
const objectKey = escapeXmlFn(item.key);
139139
const isLatest = lastKey !== objectKey;
140140
lastKey = objectKey;
141+
141142
xml.push(
142143
v.IsDeleteMarker ? '<DeleteMarker>' : '<Version>',
143144
`<Key>${objectKey}</Key>`,
@@ -153,14 +154,17 @@ function processVersions(bucketName, listParams, list) {
153154
`<ID>${v.Owner.ID}</ID>`,
154155
`<DisplayName>${v.Owner.DisplayName}</DisplayName>`,
155156
'</Owner>',
157+
...processOptionalAttributes(v, listParams.optionalAttributes),
156158
`<StorageClass>${v.StorageClass}</StorageClass>`,
157159
v.IsDeleteMarker ? '</DeleteMarker>' : '</Version>'
158160
);
159161
});
162+
160163
list.CommonPrefixes.forEach(item => {
161164
const val = escapeXmlFn(item);
162165
xml.push(`<CommonPrefixes><Prefix>${val}</Prefix></CommonPrefixes>`);
163166
});
167+
164168
xml.push('</ListVersionsResult>');
165169
return xml.join('');
166170
}
@@ -224,6 +228,7 @@ function processMasterVersions(bucketName, listParams, list) {
224228
if (v.isDeleteMarker) {
225229
return null;
226230
}
231+
227232
const objectKey = escapeXmlFn(item.key);
228233
xml.push(
229234
'<Contents>',
@@ -232,6 +237,7 @@ function processMasterVersions(bucketName, listParams, list) {
232237
`<ETag>&quot;${v.ETag}&quot;</ETag>`,
233238
`<Size>${v.Size}</Size>`
234239
);
240+
235241
if (!listParams.v2 || listParams.fetchOwner) {
236242
xml.push(
237243
'<Owner>',
@@ -240,6 +246,9 @@ function processMasterVersions(bucketName, listParams, list) {
240246
'</Owner>'
241247
);
242248
}
249+
250+
xml.push(...processOptionalAttributes(v, listParams.optionalAttributes));
251+
243252
return xml.push(
244253
`<StorageClass>${v.StorageClass}</StorageClass>`,
245254
'</Contents>'
@@ -253,20 +262,58 @@ function processMasterVersions(bucketName, listParams, list) {
253262
return xml.join('');
254263
}
255264

265+
function processOptionalAttributes(item, optionalAttributes) {
266+
const xml = [];
267+
const userMetadata = new Set();
268+
269+
for (const attribute of optionalAttributes) {
270+
switch (attribute) {
271+
case 'RestoreStatus':
272+
xml.push('<RestoreStatus>');
273+
xml.push(`<IsRestoreInProgress>${!!item.restoreStatus?.inProgress}</IsRestoreInProgress>`);
274+
275+
if (item.restoreStatus?.expiryDate) {
276+
xml.push(`<RestoreExpiryDate>${item.restoreStatus?.expiryDate}</RestoreExpiryDate>`);
277+
}
278+
279+
xml.push('</RestoreStatus>');
280+
break;
281+
case 'x-amz-meta-*':
282+
for (const key of Object.keys(item.userMetadata)) {
283+
userMetadata.add(key);
284+
}
285+
break;
286+
default:
287+
if (item.userMetadata?.[attribute]) {
288+
userMetadata.add(attribute);
289+
}
290+
}
291+
}
292+
293+
for (const key of userMetadata) {
294+
xml.push(`<${key}>${item.userMetadata[key]}</${key}>`);
295+
}
296+
297+
return xml;
298+
}
299+
256300
function handleResult(listParams, requestMaxKeys, encoding, authInfo,
257301
bucketName, list, corsHeaders, log, callback) {
258302
// eslint-disable-next-line no-param-reassign
259303
listParams.maxKeys = requestMaxKeys;
260304
// eslint-disable-next-line no-param-reassign
261305
listParams.encoding = encoding;
306+
262307
let res;
263308
if (listParams.listingType === 'DelimiterVersions') {
264309
res = processVersions(bucketName, listParams, list);
265310
} else {
266311
res = processMasterVersions(bucketName, listParams, list);
267312
}
313+
268314
pushMetric('listBucket', log, { authInfo, bucket: bucketName });
269315
monitoring.promMetrics('GET', bucketName, '200', 'listBucket');
316+
270317
return callback(null, res, corsHeaders);
271318
}
272319

@@ -286,7 +333,11 @@ function bucketGet(authInfo, request, log, callback) {
286333
const v2 = params['list-type'];
287334

288335
const optionalAttributes =
289-
request.headers['x-amz-optional-object-attributes']?.split(',').map(attr => attr.trim()) ?? [];
336+
request.headers['x-amz-optional-object-attributes']
337+
?.split(',')
338+
.map(attr => attr.trim())
339+
.map(attr => attr !== 'RestoreStatus' ? attr.toLowerCase() : attr)
340+
?? [];
290341
if (optionalAttributes.some(attr => !attr.startsWith('x-amz-meta-') && attr != 'RestoreStatus')) {
291342
return callback(
292343
errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified')
@@ -344,6 +395,7 @@ function bucketGet(authInfo, request, log, callback) {
344395
listingType: 'DelimiterMaster',
345396
maxKeys: actualMaxKeys,
346397
prefix: params.prefix,
398+
optionalAttributes,
347399
};
348400

349401
if (params.delimiter) {

lib/api/metadataSearch.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ function handleResult(listParams, requestMaxKeys, encoding, authInfo,
1919
listParams.maxKeys = requestMaxKeys;
2020
// eslint-disable-next-line no-param-reassign
2121
listParams.encoding = encoding;
22+
// eslint-disable-next-line no-param-reassign
23+
listParams.optionalAttributes = [];
2224
let res;
2325
if (listParams.listingType === 'DelimiterVersions') {
2426
res = processVersions(bucketName, listParams, list);

lib/routes/veeam/list.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ function buildXMLResponse(request, arrayOfFiles, versioned = false) {
2525
prefix: validPath,
2626
maxKeys: parsedQs['max-keys'] || 1000,
2727
delimiter: '/',
28+
optionalAttributes: [],
2829
};
2930
const list = {
3031
IsTruncated: false,

tests/functional/aws-node-sdk/test/bucket/get.js

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
const assert = require('assert');
22
const tv4 = require('tv4');
3+
const { parseString } = require('xml2js');
34

45
const withV4 = require('../support/withV4');
56
const BucketUtility = require('../../lib/utility/bucket-util');
67
const bucketSchema = require('../../schema/bucket');
78
const bucketSchemaV2 = require('../../schema/bucketV2');
8-
const { generateToken, decryptToken } =
9-
require('../../../../../lib/api/apiUtils/object/continueToken');
9+
const { generateToken, decryptToken } = require('../../../../../lib/api/apiUtils/object/continueToken');
10+
const AWS = require('aws-sdk');
11+
const { IAM } = AWS;
12+
const getConfig = require('../support/config');
13+
const { config } = require('../../../../../lib/Config');
14+
15+
const isVaultScality = config.backends.auth !== 'mem';
16+
const internalPortBypassBP = config.internalPort;
17+
const vaultHost = config.vaultd?.host || 'localhost';
1018

1119
const tests = [
1220
{
@@ -490,5 +498,176 @@ describe('GET Bucket - AWS.S3.listObjects', () => {
490498
decryptToken(data.NextContinuationToken), k);
491499
});
492500
});
501+
502+
const describeBypass = isVaultScality && internalPortBypassBP ? describe : describe.skip;
503+
describeBypass('x-amz-optional-attributes header', () => {
504+
let policyWithoutPermission;
505+
let userWithoutPermission;
506+
let s3ClientWithoutPermission;
507+
508+
const iamConfig = getConfig('default', { region: 'us-east-1' });
509+
iamConfig.endpoint = `http://${vaultHost}:8600`;
510+
const iamClient = new IAM(iamConfig);
511+
512+
before(async () => {
513+
const policyRes = await iamClient
514+
.createPolicy({
515+
PolicyName: 'bp-bypass-policy',
516+
PolicyDocument: JSON.stringify({
517+
Version: '2012-10-17',
518+
Statement: [{
519+
Sid: 'AllowS3ListBucket',
520+
Effect: 'Allow',
521+
Action: [
522+
's3:ListBucket',
523+
],
524+
Resource: ['*'],
525+
}],
526+
}),
527+
})
528+
.promise();
529+
policyWithoutPermission = policyRes.Policy;
530+
const userRes = await iamClient.createUser({ UserName: 'user-without-permission' }).promise();
531+
userWithoutPermission = userRes.User;
532+
await iamClient
533+
.attachUserPolicy({
534+
UserName: userWithoutPermission.UserName,
535+
PolicyArn: policyWithoutPermission.Arn,
536+
})
537+
.promise();
538+
539+
const accessKeyRes = await iamClient.createAccessKey({
540+
UserName: userWithoutPermission.UserName,
541+
}).promise();
542+
const accessKey = accessKeyRes.AccessKey;
543+
const s3Config = getConfig('default', {
544+
credentials: new AWS.Credentials(accessKey.AccessKeyId, accessKey.SecretAccessKey),
545+
});
546+
s3ClientWithoutPermission = new AWS.S3(s3Config);
547+
});
548+
549+
after(async () => {
550+
await iamClient
551+
.detachUserPolicy({
552+
UserName: userWithoutPermission.UserName,
553+
PolicyArn: policyWithoutPermission.Arn,
554+
})
555+
.promise();
556+
await iamClient.deletePolicy({ PolicyArn: policyWithoutPermission.Arn }).promise();
557+
await iamClient.deleteUser({ UserName: userWithoutPermission.UserName }).promise();
558+
});
559+
560+
// eslint-disable-next-line max-len
561+
const listObjectsV2WithOptionalAttributes = async (s3, bucket, headerValue) => await new Promise((resolve, reject) => {
562+
let rawXml = '';
563+
const req = s3.listObjectsV2({ Bucket: bucket });
564+
565+
req.on('build', () => {
566+
req.httpRequest.headers['x-amz-optional-object-attributes'] = headerValue;
567+
});
568+
req.on('httpData', chunk => { rawXml += chunk; });
569+
req.on('error', err => reject(err));
570+
req.on('success', response => {
571+
parseString(rawXml, (err, parsedXml) => {
572+
if (err) {
573+
return reject(err);
574+
}
575+
576+
const contents = response.data.Contents;
577+
const parsedContents = parsedXml.ListBucketResult.Contents;
578+
579+
if (!contents || !parsedContents) {
580+
return resolve(response.data);
581+
}
582+
583+
if (parsedContents[0]?.['x-amz-meta-department']) {
584+
contents[0]['x-amz-meta-department'] = parsedContents[0]['x-amz-meta-department'][0];
585+
}
586+
587+
if (parsedContents[0]?.['x-amz-meta-hr']) {
588+
contents[0]['x-amz-meta-hr'] = parsedContents[0]['x-amz-meta-hr'][0];
589+
}
590+
591+
return resolve(response.data);
592+
});
593+
});
594+
595+
req.send();
596+
});
597+
598+
it('should return an XML if the header is set', async () => {
599+
const s3 = bucketUtil.s3;
600+
const Bucket = bucketName;
601+
602+
await s3.putObject({
603+
Bucket,
604+
Key: 'super-power-object',
605+
Metadata: {
606+
Department: 'sales',
607+
HR: 'true',
608+
},
609+
}).promise();
610+
const result = await listObjectsV2WithOptionalAttributes(
611+
s3,
612+
Bucket,
613+
'x-amz-meta-*,RestoreStatus,x-amz-meta-department',
614+
);
615+
616+
assert.strictEqual(result.Contents.length, 1);
617+
assert.strictEqual(result.Contents[0].Key, 'super-power-object');
618+
assert.strictEqual(result.Contents[0]['x-amz-meta-department'], 'sales');
619+
assert.strictEqual(result.Contents[0]['x-amz-meta-hr'], 'true');
620+
});
621+
622+
it('should reject the request if the user does not have the permission', async () => {
623+
const s3 = bucketUtil.s3;
624+
const Bucket = bucketName;
625+
626+
await s3.putObject({
627+
Bucket,
628+
Key: 'super-power-object',
629+
Metadata: {
630+
Department: 'sales',
631+
HR: 'true',
632+
},
633+
}).promise();
634+
635+
try {
636+
await listObjectsV2WithOptionalAttributes(
637+
s3ClientWithoutPermission,
638+
Bucket,
639+
'x-amz-meta-*,RestoreStatus,x-amz-meta-department',
640+
);
641+
throw new Error('Request should have been rejected');
642+
} catch (err) {
643+
assert.strictEqual(err.statusCode, 403);
644+
assert.strictEqual(err.code, 'AccessDenied');
645+
}
646+
});
647+
648+
it('should always (ignore permission) return an XML when the header is RestoreStatus', async () => {
649+
const s3 = bucketUtil.s3;
650+
const Bucket = bucketName;
651+
652+
await s3.putObject({
653+
Bucket,
654+
Key: 'super-power-object',
655+
Metadata: {
656+
Department: 'sales',
657+
HR: 'true',
658+
},
659+
}).promise();
660+
const result = await listObjectsV2WithOptionalAttributes(
661+
s3ClientWithoutPermission,
662+
Bucket,
663+
'RestoreStatus',
664+
);
665+
666+
assert.strictEqual(result.Contents.length, 1);
667+
assert.strictEqual(result.Contents[0].Key, 'super-power-object');
668+
assert.strictEqual(result.Contents[0]['x-amz-meta-department'], undefined);
669+
assert.strictEqual(result.Contents[0]['x-amz-meta-hr'], undefined);
670+
});
671+
});
493672
});
494673
});

0 commit comments

Comments
 (0)