From adf96aadcdda699f0a8f4477a7bf8f61eddefe04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20DONNART?= Date: Fri, 30 Jan 2026 17:05:54 +0100 Subject: [PATCH 1/4] Support user metadata Issue: CLDSRV-844 --- .../apiUtils/object/parseAttributesHeader.js | 2 +- lib/api/objectGetAttributes.js | 23 +++ .../test/object/objectGetAttributes.js | 194 ++++++++++++++++++ .../apiUtils/object/parseAttributesHeader.js | 52 +++++ tests/unit/api/objectGetAttributes.js | 126 ++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) diff --git a/lib/api/apiUtils/object/parseAttributesHeader.js b/lib/api/apiUtils/object/parseAttributesHeader.js index 0abe19a697..03dc0ba629 100644 --- a/lib/api/apiUtils/object/parseAttributesHeader.js +++ b/lib/api/apiUtils/object/parseAttributesHeader.js @@ -15,7 +15,7 @@ function parseAttributesHeaders(headers) { ); } - if (attributes.some(attr => !allowedObjectAttributes.has(attr))) { + if (attributes.some(attr => !attr.startsWith('x-amz-meta-') && !allowedObjectAttributes.has(attr))) { throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.'); } diff --git a/lib/api/objectGetAttributes.js b/lib/api/objectGetAttributes.js index ed22ab0e45..9fa3d9c4d6 100644 --- a/lib/api/objectGetAttributes.js +++ b/lib/api/objectGetAttributes.js @@ -47,10 +47,33 @@ function buildXmlResponse(objMD, attributes) { attrResp.ObjectSize = objMD['content-length']; } + if (attributes.some(attr => attr.startsWith('x-amz-meta-'))) { + const userMetadata = parseUserMetadata(objMD, attributes); + Object.assign(attrResp, userMetadata); + } + const builder = new xml2js.Builder(); return builder.buildObject({ GetObjectAttributesResponse: attrResp }); } +/** + * parseUserMetadata - Extract user metadata from object metadata + * @param {object} objMD - object metadata + * @param {string[]} attributes - requested attributes + * @returns {object} - object containing requested user metadata key-value pairs + */ +function parseUserMetadata(objMD, attributes) { + const sourceKeys = attributes.includes('x-amz-meta-*') ? Object.keys(objMD) : attributes; + + return sourceKeys + .filter(key => key.startsWith('x-amz-meta-') && key !== 'x-amz-meta-*' && objMD[key]) + .reduce((acc, key) => { + // eslint-disable-next-line no-param-reassign + acc[key] = objMD[key]; + return acc; + }, {}); +} + /** * objectGetAttributes - Retrieves all metadata from an object without returning the object itself * @param {AuthInfo} authInfo - Instance of AuthInfo class with requester's info diff --git a/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js b/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js index 0eb10e561e..1b6cbe0c3c 100644 --- a/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js +++ b/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js @@ -1,4 +1,5 @@ const assert = require('assert'); +const { parseStringPromise } = require('xml2js'); const { S3 } = require('aws-sdk'); const getConfig = require('../support/config'); @@ -262,3 +263,196 @@ describe('Test get object attributes with multipart upload', () => { assert.strictEqual(data.StorageClass, 'STANDARD'); }); }); + +describe('objectGetAttributes with user metadata', () => { + let s3; + + const getObjectAttributesWithUserAttributes = (s3, params, attributes) => new Promise((resolve, reject) => { + const request = s3.makeRequest('getObjectAttributes', { + Bucket: params.Bucket, + Key: params.Key, + ObjectAttributes: ['ETag'], + }); + + request.on('build', () => { + request.httpRequest.headers['x-amz-object-attributes'] = attributes; + }); + + request.on('error', err => reject(err)); + + request.on('success', async response => { + try { + const body = response.httpResponse.body.toString(); + const parsedXml = await parseStringPromise(body); + + const data = response.data; + const parsedData = parsedXml.GetObjectAttributesResponse; + + if (!data || !parsedData) { + return resolve(response.data); + } + + Object.keys(parsedData).forEach(key => { + if (key.startsWith('x-amz-meta-')) { + data[key] = parsedData[key][0]; + } + }); + + return resolve(response.data); + } catch (err) { + return reject(err); + } + }); + + request.send(); + }); + + before(() => { + const config = getConfig('default', { signatureVersion: 'v4' }); + s3 = new S3(config); + }); + + beforeEach(async () => { + await s3.createBucket({ Bucket: bucket }).promise(); + }); + + afterEach(async () => { + await s3.deleteObject({ Bucket: bucket, Key: key }).promise(); + await s3.deleteBucket({ Bucket: bucket }).promise(); + }); + + it('should return specific user metadata when requested', async () => { + await s3.putObject({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + 'custom-key': 'custom-value', + 'another-key': 'another-value', + }, + }).promise(); + + const response = await getObjectAttributesWithUserAttributes(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-custom-key'); + + assert.strictEqual(response['x-amz-meta-custom-key'], 'custom-value'); + }); + + it('should return multiple user metadata when requested', async () => { + await s3.putObject({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + foo: 'foo-value', + bar: 'bar-value', + baz: 'baz-value', + }, + }).promise(); + + const response = await getObjectAttributesWithUserAttributes(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-foo,x-amz-meta-bar'); + + assert.strictEqual(response['x-amz-meta-foo'], 'foo-value'); + assert.strictEqual(response['x-amz-meta-bar'], 'bar-value'); + }); + + it('should return all user metadata when x-amz-meta-* is requested', async () => { + await s3.putObject({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + key1: 'value1', + key2: 'value2', + key3: 'value3', + }, + }).promise(); + + const response = await getObjectAttributesWithUserAttributes(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-*'); + + assert.strictEqual(response['x-amz-meta-key1'], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'], 'value2'); + assert.strictEqual(response['x-amz-meta-key3'], 'value3'); + }); + + it('should return empty response when object has no user metadata and x-amz-meta-* is requested', async () => { + await s3.putObject({ + Bucket: bucket, + Key: key, + Body: body, + }).promise(); + + const response = await getObjectAttributesWithUserAttributes(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-*'); + + const metadataKeys = Object.keys(response).filter(k => k.startsWith('x-amz-meta-')); + assert.strictEqual(metadataKeys.length, 0); + }); + + it('should return empty response when requested metadata key does not exist', async () => { + await s3.putObject({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + existing: 'value', + }, + }).promise(); + + const response = await getObjectAttributesWithUserAttributes(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-nonexistent'); + + assert.strictEqual(response['x-amz-meta-nonexistent'], undefined); + }); + + it('should return user metadata along with standard attributes', async () => { + await s3.putObject({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + custom: 'custom-value', + }, + }).promise(); + + const response = await getObjectAttributesWithUserAttributes(s3, { + Bucket: bucket, + Key: key, + }, 'ETag,x-amz-meta-custom,ObjectSize'); + + assert.strictEqual(response.ETag, expectedMD5); + assert.strictEqual(response.ObjectSize, body.length); + assert.strictEqual(response['x-amz-meta-custom'], 'custom-value'); + }); + + it('should not include x-amz-meta-* marker in response when wildcard is used', async () => { + await s3.putObject({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + test: 'test-value', + }, + }).promise(); + + const response = await getObjectAttributesWithUserAttributes(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-*'); + + assert.strictEqual(response['x-amz-meta-*'], undefined); + assert.strictEqual(response['x-amz-meta-test'], 'test-value'); + }); +}); diff --git a/tests/unit/api/apiUtils/object/parseAttributesHeader.js b/tests/unit/api/apiUtils/object/parseAttributesHeader.js index 68acf14a57..4b742c7dc2 100644 --- a/tests/unit/api/apiUtils/object/parseAttributesHeader.js +++ b/tests/unit/api/apiUtils/object/parseAttributesHeader.js @@ -171,6 +171,58 @@ describe('parseAttributesHeaders', () => { }); }); + describe('user metadata attributes (x-amz-meta-*)', () => { + it('should return array with single user metadata attribute', () => { + const headers = { 'x-amz-object-attributes': 'x-amz-meta-custom' }; + const result = parseAttributesHeaders(headers); + + assert(Array.isArray(result)); + assert.deepStrictEqual(result, ['x-amz-meta-custom']); + }); + + it('should return array with multiple user metadata attributes', () => { + const headers = { 'x-amz-object-attributes': 'x-amz-meta-foo,x-amz-meta-bar' }; + const result = parseAttributesHeaders(headers); + + assert(Array.isArray(result)); + assert.deepStrictEqual(result, ['x-amz-meta-foo', 'x-amz-meta-bar']); + }); + + it('should return array with mixed valid attributes and user metadata', () => { + const headers = { 'x-amz-object-attributes': 'ETag,x-amz-meta-custom,ObjectSize' }; + const result = parseAttributesHeaders(headers); + + assert(Array.isArray(result)); + assert.deepStrictEqual(result, ['ETag', 'x-amz-meta-custom', 'ObjectSize']); + }); + + it('should allow user metadata with special characters in name', () => { + const headers = { 'x-amz-object-attributes': 'x-amz-meta-*' }; + const result = parseAttributesHeaders(headers); + + assert(Array.isArray(result)); + assert.deepStrictEqual(result, ['x-amz-meta-*']); + }); + + it('should reject attributes without the required x-amz-meta- prefix', () => { + const invalidAttributes = ['x-amz-met', 'x-amz-other']; + + invalidAttributes.forEach(attr => { + const headers = { 'x-amz-object-attributes': attr }; + + assert.throws( + () => parseAttributesHeaders(headers), + err => { + assert(err.is); + assert.strictEqual(err.is.InvalidArgument, true); + assert.strictEqual(err.description, 'Invalid attribute name specified.'); + return true; + }, + ); + }); + }); + }); + describe('whitespace handling', () => { it('should trim whitespace around attribute names', () => { const headers = { 'x-amz-object-attributes': ' ETag , ObjectSize ' }; diff --git a/tests/unit/api/objectGetAttributes.js b/tests/unit/api/objectGetAttributes.js index 2e2064d15d..63215fa2da 100644 --- a/tests/unit/api/objectGetAttributes.js +++ b/tests/unit/api/objectGetAttributes.js @@ -346,6 +346,132 @@ describe('objectGetAttributes API with multipart upload', () => { }); }); +describe('objectGetAttributes API with user metadata', () => { + beforeEach(async () => { + cleanup(); + await bucketPutAsync(authInfo, testPutBucketRequest, log); + }); + + const createObjectWithMetadata = async (metadata = {}) => { + const testPutObjectRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { + 'content-length': `${postBody.length}`, + ...metadata, + }, + parsedContentLength: postBody.length, + url: `/${bucketName}/${objectName}`, + }, + postBody, + ); + await objectPutAsync(authInfo, testPutObjectRequest, undefined, log); + }; + + it('should return specific user metadata when requested', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-custom-key': 'custom-value', + 'x-amz-meta-another-key': 'another-value', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-custom-key']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-custom-key'][0], 'custom-value'); + }); + + it('should return multiple user metadata when requested', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-foo': 'foo-value', + 'x-amz-meta-bar': 'bar-value', + 'x-amz-meta-baz': 'baz-value', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-foo', 'x-amz-meta-bar']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-foo'][0], 'foo-value'); + assert.strictEqual(response['x-amz-meta-bar'][0], 'bar-value'); + }); + + it('should return all user metadata when x-amz-meta-* is requested', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-key1': 'value1', + 'x-amz-meta-key2': 'value2', + 'x-amz-meta-key3': 'value3', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-key1'][0], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'][0], 'value2'); + assert.strictEqual(response['x-amz-meta-key3'][0], 'value3'); + }); + + it('should return empty response when object has no user metadata and x-amz-meta-* is requested', async () => { + await createObjectWithMetadata({}); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + const metadataKeys = Object.keys(response).filter(k => k.startsWith('x-amz-meta-')); + assert.strictEqual(metadataKeys.length, 0); + }); + + it('should return empty response when requested metadata key does not exist', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-existing': 'value', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-nonexistent']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-nonexistent'], undefined); + }); + + it('should return user metadata along with standard attributes', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-custom': 'custom-value', + }); + + const testGetRequest = createGetAttributesRequest(['ETag', 'x-amz-meta-custom', 'ObjectSize']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response.ETag[0], expectedMD5); + assert.strictEqual(response.ObjectSize[0], String(body.length)); + assert.strictEqual(response['x-amz-meta-custom'][0], 'custom-value'); + }); + + it('should not include x-amz-meta-* marker in response when wildcard is used', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-test': 'test-value', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*']); + const [xml] = await objectGetAttributesAsync(authInfo, testGetRequest, log); + const result = await parseStringPromise(xml); + const response = result.GetObjectAttributesResponse; + + assert.strictEqual(response['x-amz-meta-*'], undefined); + assert.strictEqual(response['x-amz-meta-test'][0], 'test-value'); + }); +}); + describe('objectGetAttributes API with versioning', () => { const enableVersioningRequest = versioningTestUtils.createBucketPutVersioningReq(bucketName, 'Enabled'); From e644bee43b86550d0324dce5129547c9c49581fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20DONNART?= Date: Mon, 2 Feb 2026 19:21:31 +0100 Subject: [PATCH 2/4] Add a new permission to get user metadata Issue: CLDSRV-844 --- .../authorization/prepareRequestContexts.js | 7 +++ .../test/object/objectGetAttributes.js | 16 +++---- .../authorization/prepareRequestContexts.js | 48 +++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/lib/api/apiUtils/authorization/prepareRequestContexts.js b/lib/api/apiUtils/authorization/prepareRequestContexts.js index c62ace060a..578036eae0 100644 --- a/lib/api/apiUtils/authorization/prepareRequestContexts.js +++ b/lib/api/apiUtils/authorization/prepareRequestContexts.js @@ -261,6 +261,13 @@ function prepareRequestContexts(apiMethod, request, sourceBucket, if (requestedAttributes.filter(attr => attr != 'RestoreStatus').length > 0) { requestContexts.push(generateRequestContext('listObjectsV2OptionalAttributes')); } + } else if (apiMethodAfterVersionCheck === 'objectGetAttributes') { + requestContexts.push(generateRequestContext(apiMethodAfterVersionCheck)); + + const attributes = request.headers['x-amz-object-attributes']?.split(',') ?? []; + if (attributes.some(attr => attr.startsWith('x-amz-meta-'))) { + requestContexts.push(generateRequestContext('getObjectAttributes')); + } } else { const requestContext = generateRequestContext(apiMethodAfterVersionCheck); diff --git a/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js b/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js index 1b6cbe0c3c..80ec60d3da 100644 --- a/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js +++ b/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js @@ -267,7 +267,7 @@ describe('Test get object attributes with multipart upload', () => { describe('objectGetAttributes with user metadata', () => { let s3; - const getObjectAttributesWithUserAttributes = (s3, params, attributes) => new Promise((resolve, reject) => { + const getObjectAttributesWithUserMetadata = (s3, params, attributes) => new Promise((resolve, reject) => { const request = s3.makeRequest('getObjectAttributes', { Bucket: params.Bucket, Key: params.Key, @@ -332,7 +332,7 @@ describe('objectGetAttributes with user metadata', () => { }, }).promise(); - const response = await getObjectAttributesWithUserAttributes(s3, { + const response = await getObjectAttributesWithUserMetadata(s3, { Bucket: bucket, Key: key, }, 'x-amz-meta-custom-key'); @@ -352,7 +352,7 @@ describe('objectGetAttributes with user metadata', () => { }, }).promise(); - const response = await getObjectAttributesWithUserAttributes(s3, { + const response = await getObjectAttributesWithUserMetadata(s3, { Bucket: bucket, Key: key, }, 'x-amz-meta-foo,x-amz-meta-bar'); @@ -373,7 +373,7 @@ describe('objectGetAttributes with user metadata', () => { }, }).promise(); - const response = await getObjectAttributesWithUserAttributes(s3, { + const response = await getObjectAttributesWithUserMetadata(s3, { Bucket: bucket, Key: key, }, 'x-amz-meta-*'); @@ -390,7 +390,7 @@ describe('objectGetAttributes with user metadata', () => { Body: body, }).promise(); - const response = await getObjectAttributesWithUserAttributes(s3, { + const response = await getObjectAttributesWithUserMetadata(s3, { Bucket: bucket, Key: key, }, 'x-amz-meta-*'); @@ -409,7 +409,7 @@ describe('objectGetAttributes with user metadata', () => { }, }).promise(); - const response = await getObjectAttributesWithUserAttributes(s3, { + const response = await getObjectAttributesWithUserMetadata(s3, { Bucket: bucket, Key: key, }, 'x-amz-meta-nonexistent'); @@ -427,7 +427,7 @@ describe('objectGetAttributes with user metadata', () => { }, }).promise(); - const response = await getObjectAttributesWithUserAttributes(s3, { + const response = await getObjectAttributesWithUserMetadata(s3, { Bucket: bucket, Key: key, }, 'ETag,x-amz-meta-custom,ObjectSize'); @@ -447,7 +447,7 @@ describe('objectGetAttributes with user metadata', () => { }, }).promise(); - const response = await getObjectAttributesWithUserAttributes(s3, { + const response = await getObjectAttributesWithUserMetadata(s3, { Bucket: bucket, Key: key, }, 'x-amz-meta-*'); diff --git a/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js b/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js index 0a08b104f3..9453fdb4f0 100644 --- a/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js +++ b/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js @@ -396,4 +396,52 @@ describe('prepareRequestContexts', () => { }); }); }); + + describe('objectGetAttributes', () => { + describe('x-amz-object-attributes header', () => { + it('should request for specific permission if the header is set', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-object-attributes': 'x-amz-meta-department', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].getAction(), 's3:GetObjectAttributes'); + assert.strictEqual(results[1].getAction(), 'scality:GetObjectAttributes'); + }); + + it('should request for specific permission if the header is set with multiple value', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-object-attributes': 'x-amz-meta-department,ETag', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].getAction(), 's3:GetObjectAttributes'); + assert.strictEqual(results[1].getAction(), 'scality:GetObjectAttributes'); + }); + + it('should not request permission if the header contains only RestoreStatus', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-object-attributes': 'RestoreStatus', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].getAction(), 's3:GetObjectAttributes'); + }); + + it('should not request permission if the header does not exists', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({}); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].getAction(), 's3:GetObjectAttributes'); + }); + }); + }); }); From 260e8b3ba37ab9d29ae4f9d3594e7a0cf818a8ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20DONNART?= Date: Tue, 3 Feb 2026 12:07:11 +0100 Subject: [PATCH 3/4] Parse optional attributes header with utility function --- .../apiUtils/object/parseAttributesHeader.js | 28 +- lib/api/bucketGet.js | 17 +- lib/api/objectGetAttributes.js | 3 +- .../apiUtils/object/parseAttributesHeader.js | 278 +++--------------- 4 files changed, 63 insertions(+), 263 deletions(-) diff --git a/lib/api/apiUtils/object/parseAttributesHeader.js b/lib/api/apiUtils/object/parseAttributesHeader.js index 03dc0ba629..fa82157fd3 100644 --- a/lib/api/apiUtils/object/parseAttributesHeader.js +++ b/lib/api/apiUtils/object/parseAttributesHeader.js @@ -1,21 +1,29 @@ const { errorInstances } = require('arsenal'); -const { allowedObjectAttributes } = require('../../../../constants'); /** - * parseAttributesHeaders - Parse and validate the x-amz-object-attributes header - * @param {object} headers - request headers - * @returns {string[]} - array of valid attribute names - * @throws {Error} - InvalidRequest if header is missing/empty, InvalidArgument if attribute is invalid + * Parse and validate attribute headers from a request. + * @param {object} headers - Request headers object + * @param {string} headerName - Name of the header to parse (e.g., 'x-amz-object-attributes') + * @param {Set} allowedAttributes - Set of valid attribute names + * @param {boolean} [isRequired=false] - If true, throws when header is missing/empty + * @returns {string[]} Array of validated attribute names + * @throws {arsenal.errors.InvalidRequest} When header is required but missing/empty + * @throws {arsenal.errors.InvalidArgument} When an invalid attribute name is specified */ -function parseAttributesHeaders(headers) { - const attributes = headers['x-amz-object-attributes']?.split(',').map(attr => attr.trim()) ?? []; - if (attributes.length === 0) { +function parseAttributesHeaders(headers, headerName, allowedAttributes, isRequired = false) { + const attributes = + headers[headerName] + ?.split(',') + .map(attr => attr.trim()) + .map(attr => (allowedAttributes.has(attr) ? attr : attr.toLowerCase())) ?? []; + + if (isRequired && attributes.length === 0) { throw errorInstances.InvalidRequest.customizeDescription( - 'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty', + `The ${headerName} header specifying the attributes to be retrieved is either missing or empty`, ); } - if (attributes.some(attr => !attr.startsWith('x-amz-meta-') && !allowedObjectAttributes.has(attr))) { + if (attributes.some(attr => !attr.startsWith('x-amz-meta-') && !allowedAttributes.has(attr))) { throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.'); } diff --git a/lib/api/bucketGet.js b/lib/api/bucketGet.js index 3f5e352b75..343722b670 100644 --- a/lib/api/bucketGet.js +++ b/lib/api/bucketGet.js @@ -10,6 +10,7 @@ const versionIdUtils = versioning.VersionID; const monitoring = require('../utilities/monitoringHandler'); const { generateToken, decryptToken } = require('../api/apiUtils/object/continueToken'); +const parseAttributesHeaders = require('./apiUtils/object/parseAttributesHeader'); // do not url encode the continuation tokens const skipUrlEncoding = new Set([ @@ -332,16 +333,12 @@ function bucketGet(authInfo, request, log, callback) { const bucketName = request.bucketName; const v2 = params['list-type']; - const optionalAttributes = - request.headers['x-amz-optional-object-attributes'] - ?.split(',') - .map(attr => attr.trim()) - .map(attr => attr !== 'RestoreStatus' ? attr.toLowerCase() : attr) - ?? []; - if (optionalAttributes.some(attr => !attr.startsWith('x-amz-meta-') && attr != 'RestoreStatus')) { - return callback( - errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified') - ); + let optionalAttributes; + try { + const headerName = 'x-amz-optional-object-attributes'; + optionalAttributes = parseAttributesHeaders(request.headers, headerName, new Set(['RestoreStatus'])); + } catch (err) { + return callback(err); } if (v2 !== undefined && Number.parseInt(v2, 10) !== 2) { diff --git a/lib/api/objectGetAttributes.js b/lib/api/objectGetAttributes.js index 9fa3d9c4d6..1ca7e33551 100644 --- a/lib/api/objectGetAttributes.js +++ b/lib/api/objectGetAttributes.js @@ -8,6 +8,7 @@ const { decodeVersionId, getVersionIdResHeader } = require('./apiUtils/object/ve const { checkExpectedBucketOwner } = require('./apiUtils/authorization/bucketOwner'); const { pushMetric } = require('../utapi/utilities'); const { getPartCountFromMd5 } = require('./apiUtils/object/partInfo'); +const { allowedObjectAttributes } = require('../../constants'); const OBJECT_GET_ATTRIBUTES = 'objectGetAttributes'; @@ -160,7 +161,7 @@ async function objectGetAttributes(authInfo, request, log, callback) { throw err; } - const attributes = parseAttributesHeaders(headers); + const attributes = parseAttributesHeaders(headers, 'x-amz-object-attributes', allowedObjectAttributes, true); pushMetric(OBJECT_GET_ATTRIBUTES, log, { authInfo, diff --git a/tests/unit/api/apiUtils/object/parseAttributesHeader.js b/tests/unit/api/apiUtils/object/parseAttributesHeader.js index 4b742c7dc2..a8ab8a4487 100644 --- a/tests/unit/api/apiUtils/object/parseAttributesHeader.js +++ b/tests/unit/api/apiUtils/object/parseAttributesHeader.js @@ -2,262 +2,56 @@ const assert = require('assert'); const parseAttributesHeaders = require('../../../../../lib/api/apiUtils/object/parseAttributesHeader'); -describe('parseAttributesHeaders', () => { - describe('missing or empty header', () => { - it('should throw InvalidRequest error when header is missing', () => { - const headers = {}; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidRequest, true); - assert.strictEqual( - err.description, - 'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty', - ); - return true; - }, - ); - }); - - it('should throw InvalidArgument error when header is empty string', () => { - const headers = { 'x-amz-object-attributes': '' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); - - it('should throw InvalidArgument error when header contains only whitespace', () => { - const headers = { 'x-amz-object-attributes': ' ' }; +const headerName = 'x-amz-object-attributes'; +const allowedAttributes = new Set(['ETag', 'StorageClass', 'ObjectSize']); - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); - - it('should throw InvalidArgument error when header contains only commas', () => { - const headers = { 'x-amz-object-attributes': ',,,' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); +describe('parseAttributesHeaders', () => { + it('should throw InvalidRequest when header is missing and isRequired is true', () => { + assert.throws( + () => parseAttributesHeaders({}, headerName, allowedAttributes, true), + err => { + assert.strictEqual(err.is.InvalidRequest, true); + return true; + }, + ); }); - describe('invalid attribute names', () => { - it('should throw InvalidArgument error for single invalid attribute', () => { - const headers = { 'x-amz-object-attributes': 'InvalidAttribute' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); + it('should throw InvalidArgument when attribute is invalid', () => { + const headers = { [headerName]: 'InvalidAttribute' }; - it('should throw InvalidArgument error when one attribute is invalid among valid ones', () => { - const headers = { 'x-amz-object-attributes': 'ETag,InvalidAttribute,ObjectSize' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); - - it('should throw InvalidArgument error for multiple invalid attributes', () => { - const headers = { 'x-amz-object-attributes': 'Invalid1,Invalid2' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); + assert.throws( + () => parseAttributesHeaders(headers, headerName, allowedAttributes), + err => { + assert.strictEqual(err.is.InvalidArgument, true); + return true; + }, + ); }); - describe('valid attribute names', () => { - it('should return array with single valid attribute ETag', () => { - const headers = { 'x-amz-object-attributes': 'ETag' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['ETag']); - }); - - it('should return array with single valid attribute StorageClass', () => { - const headers = { 'x-amz-object-attributes': 'StorageClass' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['StorageClass']); - }); - - it('should return array with single valid attribute ObjectSize', () => { - const headers = { 'x-amz-object-attributes': 'ObjectSize' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['ObjectSize']); - }); - - it('should return array with single valid attribute ObjectParts', () => { - const headers = { 'x-amz-object-attributes': 'ObjectParts' }; - const result = parseAttributesHeaders(headers); + it('should return empty array when header is missing and isRequired is false', () => { + const result = parseAttributesHeaders({}, headerName, allowedAttributes); - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['ObjectParts']); - }); - - it('should return array with single valid attribute Checksum', () => { - const headers = { 'x-amz-object-attributes': 'Checksum' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['Checksum']); - }); - - it('should return array with multiple valid attributes', () => { - const headers = { 'x-amz-object-attributes': 'ETag,ObjectSize,StorageClass' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['ETag', 'ObjectSize', 'StorageClass']); - }); - - it('should return array with all valid attributes', () => { - const headers = { 'x-amz-object-attributes': 'StorageClass,ObjectSize,ObjectParts,Checksum,ETag' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.strictEqual(result.length, 5); - assert(result.includes('StorageClass')); - assert(result.includes('ObjectSize')); - assert(result.includes('ObjectParts')); - assert(result.includes('Checksum')); - assert(result.includes('ETag')); - }); + assert.deepStrictEqual(result, []); }); - describe('user metadata attributes (x-amz-meta-*)', () => { - it('should return array with single user metadata attribute', () => { - const headers = { 'x-amz-object-attributes': 'x-amz-meta-custom' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['x-amz-meta-custom']); - }); - - it('should return array with multiple user metadata attributes', () => { - const headers = { 'x-amz-object-attributes': 'x-amz-meta-foo,x-amz-meta-bar' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['x-amz-meta-foo', 'x-amz-meta-bar']); - }); + it('should parse valid attributes', () => { + const headers = { [headerName]: 'ETag,ObjectSize,x-amz-meta-custom,x-amz-meta-*' }; + const result = parseAttributesHeaders(headers, headerName, allowedAttributes); - it('should return array with mixed valid attributes and user metadata', () => { - const headers = { 'x-amz-object-attributes': 'ETag,x-amz-meta-custom,ObjectSize' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['ETag', 'x-amz-meta-custom', 'ObjectSize']); - }); - - it('should allow user metadata with special characters in name', () => { - const headers = { 'x-amz-object-attributes': 'x-amz-meta-*' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['x-amz-meta-*']); - }); - - it('should reject attributes without the required x-amz-meta- prefix', () => { - const invalidAttributes = ['x-amz-met', 'x-amz-other']; - - invalidAttributes.forEach(attr => { - const headers = { 'x-amz-object-attributes': attr }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); - }); + assert.deepStrictEqual(result, ['ETag', 'ObjectSize', 'x-amz-meta-custom', 'x-amz-meta-*']); }); - describe('whitespace handling', () => { - it('should trim whitespace around attribute names', () => { - const headers = { 'x-amz-object-attributes': ' ETag , ObjectSize ' }; - const result = parseAttributesHeaders(headers); - - assert(Array.isArray(result)); - assert.deepStrictEqual(result, ['ETag', 'ObjectSize']); - }); + it('should lowercase attributes not in allowedAttributes', () => { + const headers = { [headerName]: 'ETag,X-AMZ-META-CUSTOM' }; + const result = parseAttributesHeaders(headers, headerName, allowedAttributes); - it('should throw InvalidArgument for extra commas between attributes', () => { - const headers = { 'x-amz-object-attributes': 'ETag,,ObjectSize' }; - - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); + assert.deepStrictEqual(result, ['ETag', 'x-amz-meta-custom']); + }); - it('should throw InvalidArgument for leading and trailing commas', () => { - const headers = { 'x-amz-object-attributes': ',ETag,ObjectSize,' }; + it('should trim whitespace around attribute names', () => { + const headers = { [headerName]: ' ETag , ObjectSize ' }; + const result = parseAttributesHeaders(headers, headerName, allowedAttributes); - assert.throws( - () => parseAttributesHeaders(headers), - err => { - assert(err.is); - assert.strictEqual(err.is.InvalidArgument, true); - assert.strictEqual(err.description, 'Invalid attribute name specified.'); - return true; - }, - ); - }); + assert.deepStrictEqual(result, ['ETag', 'ObjectSize']); }); }); From 78dd47a670661045a75d25e550842b36064fe052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20DONNART?= Date: Wed, 4 Feb 2026 16:22:31 +0100 Subject: [PATCH 4/4] Handle rights for GetObjectAttributes Issue: CLDSRV-844 --- .../authorization/prepareRequestContexts.js | 14 ++++- .../authorization/prepareRequestContexts.js | 60 ++++++++++++++----- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/lib/api/apiUtils/authorization/prepareRequestContexts.js b/lib/api/apiUtils/authorization/prepareRequestContexts.js index 578036eae0..f30b0ab56b 100644 --- a/lib/api/apiUtils/authorization/prepareRequestContexts.js +++ b/lib/api/apiUtils/authorization/prepareRequestContexts.js @@ -262,11 +262,21 @@ function prepareRequestContexts(apiMethod, request, sourceBucket, requestContexts.push(generateRequestContext('listObjectsV2OptionalAttributes')); } } else if (apiMethodAfterVersionCheck === 'objectGetAttributes') { - requestContexts.push(generateRequestContext(apiMethodAfterVersionCheck)); + if (request.headers['x-amz-version-id']) { + requestContexts.push( + generateRequestContext('objectGetVersion'), + generateRequestContext('objectGetVersionAttributes'), + ); + } else { + requestContexts.push( + generateRequestContext('objectGet'), + generateRequestContext('objectGetAttributes'), + ); + } const attributes = request.headers['x-amz-object-attributes']?.split(',') ?? []; if (attributes.some(attr => attr.startsWith('x-amz-meta-'))) { - requestContexts.push(generateRequestContext('getObjectAttributes')); + requestContexts.push(generateRequestContext('objectGetAttributesWithUserMetadata')); } } else { const requestContext = diff --git a/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js b/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js index 9453fdb4f0..5272b1934e 100644 --- a/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js +++ b/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js @@ -399,48 +399,80 @@ describe('prepareRequestContexts', () => { describe('objectGetAttributes', () => { describe('x-amz-object-attributes header', () => { - it('should request for specific permission if the header is set', () => { + it('should include scality:GetObjectAttributes with x-amz-meta attribute', () => { const apiMethod = 'objectGetAttributes'; const request = makeRequest({ 'x-amz-object-attributes': 'x-amz-meta-department', }); const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); - assert.strictEqual(results.length, 2); - assert.strictEqual(results[0].getAction(), 's3:GetObjectAttributes'); - assert.strictEqual(results[1].getAction(), 'scality:GetObjectAttributes'); + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].getAction(), 's3:GetObject'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectAttributes'); + assert.strictEqual(results[2].getAction(), 'scality:GetObjectAttributes'); }); - it('should request for specific permission if the header is set with multiple value', () => { + it('should include scality:GetObjectAttributes with multiple attributes', () => { const apiMethod = 'objectGetAttributes'; const request = makeRequest({ 'x-amz-object-attributes': 'x-amz-meta-department,ETag', }); const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); - assert.strictEqual(results.length, 2); - assert.strictEqual(results[0].getAction(), 's3:GetObjectAttributes'); - assert.strictEqual(results[1].getAction(), 'scality:GetObjectAttributes'); + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].getAction(), 's3:GetObject'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectAttributes'); + assert.strictEqual(results[2].getAction(), 'scality:GetObjectAttributes'); }); - it('should not request permission if the header contains only RestoreStatus', () => { + it('should not include scality:GetObjectAttributes with only RestoreStatus', () => { const apiMethod = 'objectGetAttributes'; const request = makeRequest({ 'x-amz-object-attributes': 'RestoreStatus', }); const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].getAction(), 's3:GetObjectAttributes'); + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].getAction(), 's3:GetObject'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectAttributes'); }); - it('should not request permission if the header does not exists', () => { + it('should not include scality:GetObjectAttributes without header', () => { const apiMethod = 'objectGetAttributes'; const request = makeRequest({}); const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].getAction(), 's3:GetObjectAttributes'); + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].getAction(), 's3:GetObject'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectAttributes'); + }); + }); + + describe('x-amz-version-id header', () => { + it('should return version-specific actions with x-amz-version-id', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-version-id': '0987654323456789', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].getAction(), 's3:GetObjectVersion'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectVersionAttributes'); + }); + + it('should include scality:GetObjectAttributes with x-amz-version-id and x-amz-meta', () => { + const apiMethod = 'objectGetAttributes'; + const request = makeRequest({ + 'x-amz-version-id': '0987654323456789', + 'x-amz-object-attributes': 'x-amz-meta-department', + }); + const results = prepareRequestContexts(apiMethod, request, sourceBucket, sourceObject, sourceVersionId); + + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].getAction(), 's3:GetObjectVersion'); + assert.strictEqual(results[1].getAction(), 's3:GetObjectVersionAttributes'); + assert.strictEqual(results[2].getAction(), 'scality:GetObjectAttributes'); }); }); });