Skip to content

Commit 6362794

Browse files
committed
Merge remote-tracking branch 'origin/w/9.2/feature/CLDSRV-813/optional-attributes-response' into w/9.3/feature/CLDSRV-813/optional-attributes-response
2 parents 889b623 + d8cfbc0 commit 6362794

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
@@ -5,13 +5,21 @@ const {
55
ListObjectsCommand,
66
ListObjectsV2Command,
77
} = require('@aws-sdk/client-s3');
8+
const { parseString } = require('xml2js');
89

910
const withV4 = require('../support/withV4');
1011
const BucketUtility = require('../../lib/utility/bucket-util');
1112
const bucketSchema = require('../../schema/bucket');
1213
const bucketSchemaV2 = require('../../schema/bucketV2');
13-
const { generateToken, decryptToken } =
14-
require('../../../../../lib/api/apiUtils/object/continueToken');
14+
const { generateToken, decryptToken } = require('../../../../../lib/api/apiUtils/object/continueToken');
15+
const AWS = require('aws-sdk');
16+
const { IAM } = AWS;
17+
const getConfig = require('../support/config');
18+
const { config } = require('../../../../../lib/Config');
19+
20+
const isVaultScality = config.backends.auth !== 'mem';
21+
const internalPortBypassBP = config.internalPort;
22+
const vaultHost = config.vaultd?.host || 'localhost';
1523

1624
const tests = [
1725
{
@@ -535,5 +543,176 @@ describe('GET Bucket - AWS.S3.listObjects', () => {
535543
assert.strictEqual($metadata.httpStatusCode, 200);
536544
});
537545
});
546+
547+
const describeBypass = isVaultScality && internalPortBypassBP ? describe : describe.skip;
548+
describeBypass('x-amz-optional-attributes header', () => {
549+
let policyWithoutPermission;
550+
let userWithoutPermission;
551+
let s3ClientWithoutPermission;
552+
553+
const iamConfig = getConfig('default', { region: 'us-east-1' });
554+
iamConfig.endpoint = `http://${vaultHost}:8600`;
555+
const iamClient = new IAM(iamConfig);
556+
557+
before(async () => {
558+
const policyRes = await iamClient
559+
.createPolicy({
560+
PolicyName: 'bp-bypass-policy',
561+
PolicyDocument: JSON.stringify({
562+
Version: '2012-10-17',
563+
Statement: [{
564+
Sid: 'AllowS3ListBucket',
565+
Effect: 'Allow',
566+
Action: [
567+
's3:ListBucket',
568+
],
569+
Resource: ['*'],
570+
}],
571+
}),
572+
})
573+
.promise();
574+
policyWithoutPermission = policyRes.Policy;
575+
const userRes = await iamClient.createUser({ UserName: 'user-without-permission' }).promise();
576+
userWithoutPermission = userRes.User;
577+
await iamClient
578+
.attachUserPolicy({
579+
UserName: userWithoutPermission.UserName,
580+
PolicyArn: policyWithoutPermission.Arn,
581+
})
582+
.promise();
583+
584+
const accessKeyRes = await iamClient.createAccessKey({
585+
UserName: userWithoutPermission.UserName,
586+
}).promise();
587+
const accessKey = accessKeyRes.AccessKey;
588+
const s3Config = getConfig('default', {
589+
credentials: new AWS.Credentials(accessKey.AccessKeyId, accessKey.SecretAccessKey),
590+
});
591+
s3ClientWithoutPermission = new AWS.S3(s3Config);
592+
});
593+
594+
after(async () => {
595+
await iamClient
596+
.detachUserPolicy({
597+
UserName: userWithoutPermission.UserName,
598+
PolicyArn: policyWithoutPermission.Arn,
599+
})
600+
.promise();
601+
await iamClient.deletePolicy({ PolicyArn: policyWithoutPermission.Arn }).promise();
602+
await iamClient.deleteUser({ UserName: userWithoutPermission.UserName }).promise();
603+
});
604+
605+
// eslint-disable-next-line max-len
606+
const listObjectsV2WithOptionalAttributes = async (s3, bucket, headerValue) => await new Promise((resolve, reject) => {
607+
let rawXml = '';
608+
const req = s3.listObjectsV2({ Bucket: bucket });
609+
610+
req.on('build', () => {
611+
req.httpRequest.headers['x-amz-optional-object-attributes'] = headerValue;
612+
});
613+
req.on('httpData', chunk => { rawXml += chunk; });
614+
req.on('error', err => reject(err));
615+
req.on('success', response => {
616+
parseString(rawXml, (err, parsedXml) => {
617+
if (err) {
618+
return reject(err);
619+
}
620+
621+
const contents = response.data.Contents;
622+
const parsedContents = parsedXml.ListBucketResult.Contents;
623+
624+
if (!contents || !parsedContents) {
625+
return resolve(response.data);
626+
}
627+
628+
if (parsedContents[0]?.['x-amz-meta-department']) {
629+
contents[0]['x-amz-meta-department'] = parsedContents[0]['x-amz-meta-department'][0];
630+
}
631+
632+
if (parsedContents[0]?.['x-amz-meta-hr']) {
633+
contents[0]['x-amz-meta-hr'] = parsedContents[0]['x-amz-meta-hr'][0];
634+
}
635+
636+
return resolve(response.data);
637+
});
638+
});
639+
640+
req.send();
641+
});
642+
643+
it('should return an XML if the header is set', async () => {
644+
const s3 = bucketUtil.s3;
645+
const Bucket = bucketName;
646+
647+
await s3.putObject({
648+
Bucket,
649+
Key: 'super-power-object',
650+
Metadata: {
651+
Department: 'sales',
652+
HR: 'true',
653+
},
654+
}).promise();
655+
const result = await listObjectsV2WithOptionalAttributes(
656+
s3,
657+
Bucket,
658+
'x-amz-meta-*,RestoreStatus,x-amz-meta-department',
659+
);
660+
661+
assert.strictEqual(result.Contents.length, 1);
662+
assert.strictEqual(result.Contents[0].Key, 'super-power-object');
663+
assert.strictEqual(result.Contents[0]['x-amz-meta-department'], 'sales');
664+
assert.strictEqual(result.Contents[0]['x-amz-meta-hr'], 'true');
665+
});
666+
667+
it('should reject the request if the user does not have the permission', async () => {
668+
const s3 = bucketUtil.s3;
669+
const Bucket = bucketName;
670+
671+
await s3.putObject({
672+
Bucket,
673+
Key: 'super-power-object',
674+
Metadata: {
675+
Department: 'sales',
676+
HR: 'true',
677+
},
678+
}).promise();
679+
680+
try {
681+
await listObjectsV2WithOptionalAttributes(
682+
s3ClientWithoutPermission,
683+
Bucket,
684+
'x-amz-meta-*,RestoreStatus,x-amz-meta-department',
685+
);
686+
throw new Error('Request should have been rejected');
687+
} catch (err) {
688+
assert.strictEqual(err.statusCode, 403);
689+
assert.strictEqual(err.code, 'AccessDenied');
690+
}
691+
});
692+
693+
it('should always (ignore permission) return an XML when the header is RestoreStatus', async () => {
694+
const s3 = bucketUtil.s3;
695+
const Bucket = bucketName;
696+
697+
await s3.putObject({
698+
Bucket,
699+
Key: 'super-power-object',
700+
Metadata: {
701+
Department: 'sales',
702+
HR: 'true',
703+
},
704+
}).promise();
705+
const result = await listObjectsV2WithOptionalAttributes(
706+
s3ClientWithoutPermission,
707+
Bucket,
708+
'RestoreStatus',
709+
);
710+
711+
assert.strictEqual(result.Contents.length, 1);
712+
assert.strictEqual(result.Contents[0].Key, 'super-power-object');
713+
assert.strictEqual(result.Contents[0]['x-amz-meta-department'], undefined);
714+
assert.strictEqual(result.Contents[0]['x-amz-meta-hr'], undefined);
715+
});
716+
});
538717
});
539718
});

0 commit comments

Comments
 (0)