From 8720e33068857b60dd10ba4e76bc472c37bcfe39 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/6] Support user metadata Issue: CLDSRV-844 --- .../apiUtils/object/parseAttributesHeader.js | 9 +- lib/api/objectGetAttributes.js | 35 +++ .../test/object/objectGetAttributes.js | 268 ++++++++++++++++++ .../apiUtils/object/parseAttributesHeader.js | 52 ++++ tests/unit/api/objectGetAttributes.js | 160 +++++++++++ 5 files changed, 520 insertions(+), 4 deletions(-) diff --git a/lib/api/apiUtils/object/parseAttributesHeader.js b/lib/api/apiUtils/object/parseAttributesHeader.js index 685ac93884..e1927dc6e7 100644 --- a/lib/api/apiUtils/object/parseAttributesHeader.js +++ b/lib/api/apiUtils/object/parseAttributesHeader.js @@ -2,10 +2,11 @@ const { errorInstances } = require('arsenal'); const { supportedGetObjectAttributes } = require('../../../../constants'); /** - * parseAttributesHeaders - Parse and validate the x-amz-object-attributes header - * @param {object} headers - request headers + * Parse and validate attribute headers from a request. + * @param {object} headers - Request headers object * @returns {Set} - set of requested attribute names - * @throws {Error} - InvalidRequest if header is missing/empty, InvalidArgument if attribute is invalid + * @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()) ?? []; @@ -15,7 +16,7 @@ function parseAttributesHeaders(headers) { ); } - if (attributes.some(attr => !supportedGetObjectAttributes.has(attr))) { + if (attributes.some(attr => !attr.startsWith('x-amz-meta-') && !supportedGetObjectAttributes.has(attr))) { throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.'); } diff --git a/lib/api/objectGetAttributes.js b/lib/api/objectGetAttributes.js index ffec684cca..420281f9a4 100644 --- a/lib/api/objectGetAttributes.js +++ b/lib/api/objectGetAttributes.js @@ -21,11 +21,15 @@ const OBJECT_GET_ATTRIBUTES = 'objectGetAttributes'; */ function buildXmlResponse(objMD, requestedAttrs) { const xml = []; + xml.push( '', '', ); + const customAttributes = new Set(); + const userMetadata = getUserMetadata(objMD); + for (const attribute of requestedAttrs) { switch (attribute) { case 'ETag': @@ -48,13 +52,44 @@ function buildXmlResponse(objMD, requestedAttrs) { case 'ObjectSize': xml.push(`${objMD['content-length']}`); break; + case 'x-amz-meta-*': + for (const key of Object.keys(userMetadata)) { + customAttributes.add(key); + } + break; + default: + if (userMetadata[attribute]) { + customAttributes.add(attribute); + } } } + for (const key of customAttributes) { + xml.push(`<${key}>${userMetadata[key]}`); + } + xml.push(''); return xml.join(''); } + +/** + * getUserMetadata - Retrieves all object user metadata + * @param {object} objMD - object metadata + * @returns {object} - user metadata key-value pairs + */ +function getUserMetadata(objMD) { + const metadata = {}; + + for (const key in objMD) { + if (key.startsWith('x-amz-meta-') && objMD[key] !== undefined) { + metadata[key] = objMD[key]; + } + } + + return metadata; +} + /** * 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 8a665dc12e..ededb57d8b 100644 --- a/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js +++ b/tests/functional/aws-node-sdk/test/object/objectGetAttributes.js @@ -9,6 +9,8 @@ const { UploadPartCommand, CompleteMultipartUploadCommand, } = require('@aws-sdk/client-s3'); +const { streamCollector } = require('@smithy/node-http-handler'); +const { parseStringPromise } = require('xml2js'); const withV4 = require('../support/withV4'); const BucketUtility = require('../../lib/utility/bucket-util'); @@ -252,3 +254,269 @@ describe('Test get object attributes with multipart upload', () => { }); }); }); + +describe('objectGetAttributes with user metadata', () => { + withV4(sigCfg => { + let bucketUtil; + let s3; + + const getObjectAttributesWithUserMetadata = async (client, params, attributes) => { + let rawXml = ''; + + const addHeaderMiddleware = next => async args => { + // eslint-disable-next-line no-param-reassign + args.request.headers['x-amz-object-attributes'] = attributes; + return next(args); + }; + + const originalHandler = client.config.requestHandler; + const wrappedHandler = { + async handle(request, options) { + const { response } = await originalHandler.handle(request, options); + + if (response && response.body) { + const collected = await streamCollector(response.body); + const buffer = Buffer.from(collected); + rawXml = buffer.toString('utf-8'); + + const { Readable } = require('stream'); + response.body = Readable.from([buffer]); + } + + return { response }; + }, + }; + + // eslint-disable-next-line no-param-reassign + client.config.requestHandler = wrappedHandler; + client.middlewareStack.add(addHeaderMiddleware, { + step: 'build', + name: 'addObjectAttributesHeader', + }); + + try { + const result = await client.send(new GetObjectAttributesCommand({ + Bucket: params.Bucket, + Key: params.Key, + ObjectAttributes: ['ETag'], + })); + + if (!rawXml) { + return result; + } + + const parsedXml = await parseStringPromise(rawXml); + const parsedData = parsedXml?.GetObjectAttributesResponse; + + if (!parsedData) { + return result; + } + + Object.keys(parsedData).forEach(k => { + if (k.startsWith('x-amz-meta-')) { + result[k] = parsedData[k][0]; + } + }); + + return result; + } finally { + // eslint-disable-next-line no-param-reassign + client.config.requestHandler = originalHandler; + client.middlewareStack.remove('addObjectAttributesHeader'); + } + }; + + before(() => { + bucketUtil = new BucketUtility('default', sigCfg); + s3 = bucketUtil.s3; + }); + + beforeEach(async () => { + await s3.send(new CreateBucketCommand({ Bucket: bucket })); + }); + + afterEach(async () => { + await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); + await s3.send(new DeleteBucketCommand({ Bucket: bucket })); + }); + + it('should return specific user metadata when requested', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + 'custom-key': 'custom-value', + 'another-key': 'another-value', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(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.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + foo: 'foo-value', + bar: 'bar-value', + baz: 'baz-value', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(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 only all user metadata when x-amz-meta-* is requested', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + key1: 'value1', + key2: 'value2', + key3: 'value3', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(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'); + assert.strictEqual(response['x-amz-meta-*'], undefined, 'wildcard marker should not be in response'); + }); + + it('should return empty response when object has no user metadata and x-amz-meta-* is requested', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'ETag,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.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + existing: 'value', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'ETag,x-amz-meta-nonexistent'); + + assert.strictEqual(response['x-amz-meta-nonexistent'], undefined); + }); + + it('should return user metadata along with standard attributes', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + custom: 'custom-value', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(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 return all metadata once wildcard is provided', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + key1: 'value1', + key2: 'value2', + key3: 'value3', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-*,x-amz-meta-key1'); + + 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 handle duplicate wildcard requests without duplicating results', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + key1: 'value1', + key2: 'value2', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-*,x-amz-meta-*'); + + assert.strictEqual(response['x-amz-meta-key1'], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'], 'value2'); + }); + + it('should handle duplicate specific metadata requests without duplicating results', async () => { + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + Metadata: { + key1: 'value1', + key2: 'value2', + }, + })); + + const response = await getObjectAttributesWithUserMetadata(s3, { + Bucket: bucket, + Key: key, + }, 'x-amz-meta-key1,x-amz-meta-key1'); + + assert.strictEqual(response['x-amz-meta-key1'], 'value1'); + assert.strictEqual(response['x-amz-meta-key2'], undefined); + }); + }); +}); diff --git a/tests/unit/api/apiUtils/object/parseAttributesHeader.js b/tests/unit/api/apiUtils/object/parseAttributesHeader.js index b722668880..a7bce6af70 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(result instanceof Set); + assert.deepStrictEqual(result, new Set(['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(result instanceof Set); + assert.deepStrictEqual(result, new Set(['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(result instanceof Set); + assert.deepStrictEqual(result, new Set(['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(result instanceof Set); + assert.deepStrictEqual(result, new Set(['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 1f50bb9866..4c951fbcdd 100644 --- a/tests/unit/api/objectGetAttributes.js +++ b/tests/unit/api/objectGetAttributes.js @@ -347,6 +347,166 @@ 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 objectGetAttributes(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 objectGetAttributes(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 objectGetAttributes(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'); + assert.strictEqual(response['x-amz-meta-*'], undefined, 'wildcard marker should not be in response'); + }); + + 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 objectGetAttributes(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 objectGetAttributes(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 objectGetAttributes(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 return all metadata when wildcard is combined with specific metadata key', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-key1': 'value1', + 'x-amz-meta-key2': 'value2', + 'x-amz-meta-key3': 'value3', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*', 'x-amz-meta-key1']); + const { xml } = await objectGetAttributes(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 handle duplicate wildcard requests without duplicating results', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-key1': 'value1', + 'x-amz-meta-key2': 'value2', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-*', 'x-amz-meta-*']); + const { xml } = await objectGetAttributes(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'); + }); + + it('should handle duplicate specific metadata requests without duplicating results', async () => { + await createObjectWithMetadata({ + 'x-amz-meta-key1': 'value1', + 'x-amz-meta-key2': 'value2', + }); + + const testGetRequest = createGetAttributesRequest(['x-amz-meta-key1', 'x-amz-meta-key1']); + const { xml } = await objectGetAttributes(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'], undefined); + }); +}); + describe('objectGetAttributes API with versioning', () => { const enableVersioningRequest = versioningTestUtils.createBucketPutVersioningReq(bucketName, 'Enabled'); From d5d79521e75b05e777d2bc30166aea708bac8b98 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/6] Add a new permission to get user metadata Issue: CLDSRV-844 --- .../authorization/prepareRequestContexts.js | 7 +++ .../authorization/prepareRequestContexts.js | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/api/apiUtils/authorization/prepareRequestContexts.js b/lib/api/apiUtils/authorization/prepareRequestContexts.js index c62ace060a..c75d675d2e 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.trim().toLowerCase().startsWith('x-amz-meta-'))) { + requestContexts.push(generateRequestContext('objectGetAttributesWithUserMetadata')); + } } else { const requestContext = generateRequestContext(apiMethodAfterVersionCheck); 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 a35badff897bdb505e552b9870e47ed428623989 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/6] Parse optional attributes header with utility function Issue: CLDSRV-844 --- lib/api/apiUtils/object/objectAttributes.js | 114 ++++++++ .../apiUtils/object/parseAttributesHeader.js | 26 -- lib/api/bucketGet.js | 60 +--- lib/api/metadataSearch.js | 2 +- lib/api/objectGetAttributes.js | 63 +---- lib/routes/veeam/list.js | 2 +- .../api/apiUtils/object/objectAttributes.js | 186 +++++++++++++ .../apiUtils/object/parseAttributesHeader.js | 263 ------------------ 8 files changed, 331 insertions(+), 385 deletions(-) create mode 100644 lib/api/apiUtils/object/objectAttributes.js delete mode 100644 lib/api/apiUtils/object/parseAttributesHeader.js create mode 100644 tests/unit/api/apiUtils/object/objectAttributes.js delete mode 100644 tests/unit/api/apiUtils/object/parseAttributesHeader.js diff --git a/lib/api/apiUtils/object/objectAttributes.js b/lib/api/apiUtils/object/objectAttributes.js new file mode 100644 index 0000000000..e2752cc8ad --- /dev/null +++ b/lib/api/apiUtils/object/objectAttributes.js @@ -0,0 +1,114 @@ +const { errorInstances } = require('arsenal'); +const { getPartCountFromMd5 } = require('./partInfo'); + +/** + * 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} supportedAttributes - Set of valid attribute names + * @returns {Set} - set of requested attribute names + * @throws {arsenal.errors.InvalidRequest} When header is required but missing/empty + * @throws {arsenal.errors.InvalidArgument} When an invalid attribute name is specified + * @example + * // Input headers: + * { 'headerName': 'ETag, ObjectSize, x-amz-meta-custom' } + * + * // Parsed result: + * ['ETag', 'ObjectSize', 'x-amz-meta-custom'] + */ +function parseAttributesHeaders(headers, headerName, supportedAttributes) { + const result = new Set(); + + const rawValue = headers[headerName]; + if (rawValue === null || rawValue === undefined) { + return result; + } + + for (const rawAttr of rawValue.split(',')) { + let attr = rawAttr.trim(); + + if (!supportedAttributes.has(attr)) { + attr = attr.toLowerCase(); + } + + if (!attr.startsWith('x-amz-meta-') && !supportedAttributes.has(attr)) { + throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.'); + } + + result.add(attr); + } + + return result; +} + +/** + * buildAttributesXml - Builds XML reponse for requested object attributes + * @param {Object} objectMD - The internal metadata object for the file/object. + * @param {string} [objectMD.content-md5] - The MD5 hash used for the ETag. + * @param {string} [objectMD.x-amz-storage-class] - The storage tier of the object. + * @param {number} [objectMD.content-length] - The size of the object in bytes. + * @param {Object} [objectMD.restoreStatus] - Information regarding the restoration of archived objects. + * @param {boolean} [objectMD.restoreStatus.inProgress] - Whether a restore is currently active. + * @param {string} [objectMD.restoreStatus.expiryDate] - The date after which the restored copy expires. + * @param {Object.} userMetadata - Key-value pairs of user-defined metadata. + * @param {string[]} requestedAttrs - A list of specific attributes to include in the output. + * Supports 'ETag', 'ObjectParts', 'StorageClass', 'ObjectSize', + * 'RestoreStatus', and 'x-amz-meta-*' for all user metadata. + * @param {string[]} xml - The string array acting as the output buffer/collector. + * @returns {void} - this function does not return a value, it mutates the `xml` param. + */ +function buildAttributesXml(objectMD, userMetadata, requestedAttrs, xml) { + const customAttributes = new Set(); + for (const attribute of requestedAttrs) { + switch (attribute) { + case 'ETag': + xml.push(`${objectMD['content-md5']}`); + break; + case 'ObjectParts': { + const partCount = getPartCountFromMd5(objectMD); + if (partCount) { + xml.push( + '', + `${partCount}`, + '', + ); + } + break; + } + case 'StorageClass': + xml.push(`${objectMD['x-amz-storage-class']}`); + break; + case 'ObjectSize': + xml.push(`${objectMD['content-length']}`); + break; + case 'RestoreStatus': + xml.push(''); + xml.push(`${!!objectMD.restoreStatus?.inProgress}`); + + if (objectMD.restoreStatus?.expiryDate) { + xml.push(`${objectMD.restoreStatus?.expiryDate}`); + } + + xml.push(''); + break; + case 'x-amz-meta-*': + for (const key of Object.keys(userMetadata)) { + customAttributes.add(key); + } + break; + default: + if (userMetadata[attribute]) { + customAttributes.add(attribute); + } + } + } + + for (const key of customAttributes) { + xml.push(`<${key}>${userMetadata[key]}`); + } +} + +module.exports = { + parseAttributesHeaders, + buildAttributesXml, +}; diff --git a/lib/api/apiUtils/object/parseAttributesHeader.js b/lib/api/apiUtils/object/parseAttributesHeader.js deleted file mode 100644 index e1927dc6e7..0000000000 --- a/lib/api/apiUtils/object/parseAttributesHeader.js +++ /dev/null @@ -1,26 +0,0 @@ -const { errorInstances } = require('arsenal'); -const { supportedGetObjectAttributes } = require('../../../../constants'); - -/** - * Parse and validate attribute headers from a request. - * @param {object} headers - Request headers object - * @returns {Set} - set of requested 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) { - throw errorInstances.InvalidRequest.customizeDescription( - 'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty', - ); - } - - if (attributes.some(attr => !attr.startsWith('x-amz-meta-') && !supportedGetObjectAttributes.has(attr))) { - throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.'); - } - - return new Set(attributes); -} - -module.exports = parseAttributesHeaders; diff --git a/lib/api/bucketGet.js b/lib/api/bucketGet.js index c40029f017..371680c6f4 100644 --- a/lib/api/bucketGet.js +++ b/lib/api/bucketGet.js @@ -10,6 +10,9 @@ const { pushMetric } = require('../utapi/utilities'); const versionIdUtils = versioning.VersionID; const monitoring = require('../utilities/monitoringHandler'); const { generateToken, decryptToken } = require('../api/apiUtils/object/continueToken'); +const { parseAttributesHeaders, buildAttributesXml } = require('./apiUtils/object/objectAttributes'); + +const OPTIONAL_ATTRIBUTES = new Set(['RestoreStatus']); const xmlParamsToSkipUrlEncoding = new Set(['ContinuationToken', 'NextContinuationToken']); @@ -150,7 +153,11 @@ function processVersions(bucketName, listParams, list) { `${v.Owner.ID}`, `${v.Owner.DisplayName}`, '', - ...processOptionalAttributes(v, listParams.optionalAttributes), + ); + + buildAttributesXml(v, v.userMetadata, listParams.optionalAttributes, xml), + + xml.push( `${v.StorageClass}`, v.IsDeleteMarker ? '' : '' ); @@ -231,7 +238,7 @@ function processMasterVersions(bucketName, listParams, list) { ); } - xml.push(...processOptionalAttributes(v, listParams.optionalAttributes)); + buildAttributesXml(v, v.userMetadata, listParams.optionalAttributes, xml); return xml.push( `${v.StorageClass}`, @@ -246,41 +253,6 @@ function processMasterVersions(bucketName, listParams, list) { return xml.join(''); } -function processOptionalAttributes(item, optionalAttributes) { - const xml = []; - const userMetadata = new Set(); - - for (const attribute of optionalAttributes) { - switch (attribute) { - case 'RestoreStatus': - xml.push(''); - xml.push(`${!!item.restoreStatus?.inProgress}`); - - if (item.restoreStatus?.expiryDate) { - xml.push(`${item.restoreStatus?.expiryDate}`); - } - - xml.push(''); - break; - case 'x-amz-meta-*': - for (const key of Object.keys(item.userMetadata)) { - userMetadata.add(key); - } - break; - default: - if (item.userMetadata?.[attribute]) { - userMetadata.add(attribute); - } - } - } - - for (const key of userMetadata) { - xml.push(`<${key}>${item.userMetadata[key]}`); - } - - return xml; -} - function handleResult(listParams, requestMaxKeys, encoding, authInfo, bucketName, list, log) { // eslint-disable-next-line no-param-reassign listParams.maxKeys = requestMaxKeys; @@ -321,15 +293,11 @@ async 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')) { - throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified'); - } + const optionalAttributes = parseAttributesHeaders( + request.headers, + 'x-amz-optional-object-attributes', + OPTIONAL_ATTRIBUTES, + ); if (v2 !== undefined && Number.parseInt(v2, 10) !== 2) { throw errorInstances.InvalidArgument.customizeDescription('Invalid List Type specified in Request'); diff --git a/lib/api/metadataSearch.js b/lib/api/metadataSearch.js index 900b0cceef..ba4f355abc 100644 --- a/lib/api/metadataSearch.js +++ b/lib/api/metadataSearch.js @@ -20,7 +20,7 @@ function handleResult(listParams, requestMaxKeys, encoding, authInfo, // eslint-disable-next-line no-param-reassign listParams.encoding = encoding; // eslint-disable-next-line no-param-reassign - listParams.optionalAttributes = []; + listParams.optionalAttributes = new Set(); let res; if (listParams.listingType === 'DelimiterVersions') { res = processVersions(bucketName, listParams, list); diff --git a/lib/api/objectGetAttributes.js b/lib/api/objectGetAttributes.js index 420281f9a4..6fdbee19df 100644 --- a/lib/api/objectGetAttributes.js +++ b/lib/api/objectGetAttributes.js @@ -1,12 +1,12 @@ const { promisify } = require('util'); -const { errors } = require('arsenal'); +const { errors, errorInstances } = require('arsenal'); const { standardMetadataValidateBucketAndObj } = require('../metadata/metadataUtils'); const collectCorsHeaders = require('../utilities/collectCorsHeaders'); -const parseAttributesHeaders = require('./apiUtils/object/parseAttributesHeader'); const { decodeVersionId, getVersionIdResHeader } = require('./apiUtils/object/versioning'); const { checkExpectedBucketOwner } = require('./apiUtils/authorization/bucketOwner'); const { pushMetric } = require('../utapi/utilities'); -const { getPartCountFromMd5 } = require('./apiUtils/object/partInfo'); +const { supportedGetObjectAttributes } = require('../../constants'); +const { parseAttributesHeaders, buildAttributesXml } = require('./apiUtils/object/objectAttributes'); const checkExpectedBucketOwnerPromise = promisify(checkExpectedBucketOwner); const validateBucketAndObj = promisify(standardMetadataValidateBucketAndObj); @@ -20,53 +20,13 @@ const OBJECT_GET_ATTRIBUTES = 'objectGetAttributes'; * @returns {string} XML response */ function buildXmlResponse(objMD, requestedAttrs) { - const xml = []; - - xml.push( + const xml = [ '', '', - ); + ]; - const customAttributes = new Set(); const userMetadata = getUserMetadata(objMD); - - for (const attribute of requestedAttrs) { - switch (attribute) { - case 'ETag': - xml.push(`${objMD['content-md5']}`); - break; - case 'ObjectParts': { - const partCount = getPartCountFromMd5(objMD); - if (partCount) { - xml.push( - '', - `${partCount}`, - '', - ); - } - break; - } - case 'StorageClass': - xml.push(`${objMD['x-amz-storage-class']}`); - break; - case 'ObjectSize': - xml.push(`${objMD['content-length']}`); - break; - case 'x-amz-meta-*': - for (const key of Object.keys(userMetadata)) { - customAttributes.add(key); - } - break; - default: - if (userMetadata[attribute]) { - customAttributes.add(attribute); - } - } - } - - for (const key of customAttributes) { - xml.push(`<${key}>${userMetadata[key]}`); - } + buildAttributesXml(objMD, userMetadata, requestedAttrs, xml); xml.push(''); return xml.join(''); @@ -82,7 +42,7 @@ function getUserMetadata(objMD) { const metadata = {}; for (const key in objMD) { - if (key.startsWith('x-amz-meta-') && objMD[key] !== undefined) { + if (key.startsWith('x-amz-meta-')) { metadata[key] = objMD[key]; } } @@ -176,7 +136,14 @@ async function objectGetAttributes(authInfo, request, log, callback) { throw err; } - const requestedAttrs = parseAttributesHeaders(headers); + const attrHeader = headers['x-amz-object-attributes']; + if (attrHeader === undefined) { + throw errorInstances.InvalidRequest.customizeDescription( + 'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty', + ); + } + + const requestedAttrs = parseAttributesHeaders(headers, 'x-amz-object-attributes', supportedGetObjectAttributes, true); if (requestedAttrs.has('Checksum')) { log.debug('Checksum attribute requested but not implemented', { diff --git a/lib/routes/veeam/list.js b/lib/routes/veeam/list.js index 5fd8585bfb..1c4c760259 100644 --- a/lib/routes/veeam/list.js +++ b/lib/routes/veeam/list.js @@ -25,7 +25,7 @@ function buildXMLResponse(request, arrayOfFiles, versioned = false) { prefix: validPath, maxKeys: parsedQs['max-keys'] || 1000, delimiter: '/', - optionalAttributes: [], + optionalAttributes: new Set(), }; const list = { IsTruncated: false, diff --git a/tests/unit/api/apiUtils/object/objectAttributes.js b/tests/unit/api/apiUtils/object/objectAttributes.js new file mode 100644 index 0000000000..8adb4b9feb --- /dev/null +++ b/tests/unit/api/apiUtils/object/objectAttributes.js @@ -0,0 +1,186 @@ +const assert = require('assert'); +const { + parseAttributesHeaders, + buildAttributesXml +} = require('../../../../../lib/api/apiUtils/object/objectAttributes'); + +const headerName = 'x-amz-object-attributes'; +const allowedAttributes = new Set(['ETag', 'StorageClass', 'ObjectSize']); + +describe('parseAttributesHeaders', () => { + it('should throw InvalidArgument when attribute is invalid', () => { + const headers = { [headerName]: 'InvalidAttribute' }; + + assert.throws( + () => parseAttributesHeaders(headers, headerName, allowedAttributes), + err => { + assert.strictEqual(err.is.InvalidArgument, true); + return true; + }, + ); + }); + + it('should return empty array when header is missing', () => { + const result = parseAttributesHeaders({}, headerName, allowedAttributes); + + assert.deepStrictEqual(result, new Set([])); + }); + + it('should parse valid attributes', () => { + const headers = { [headerName]: 'ETag,ObjectSize,x-amz-meta-custom,x-amz-meta-*' }; + const result = parseAttributesHeaders(headers, headerName, allowedAttributes); + + assert.deepStrictEqual(result, new Set(['ETag', 'ObjectSize', 'x-amz-meta-custom', 'x-amz-meta-*'])); + }); + + it('should lowercase attributes not in allowedAttributes', () => { + const headers = { [headerName]: 'ETag,X-AMZ-META-CUSTOM' }; + const result = parseAttributesHeaders(headers, headerName, allowedAttributes); + + assert.deepStrictEqual(result, new Set(['ETag', 'x-amz-meta-custom'])); + }); + + it('should trim whitespace around attribute names', () => { + const headers = { [headerName]: ' ETag , ObjectSize ' }; + const result = parseAttributesHeaders(headers, headerName, allowedAttributes); + + assert.deepStrictEqual(result, new Set(['ETag', 'ObjectSize'])); + }); +}); + + +describe('buildXmlAttributes', () => { + const objectMD = { + 'content-md5': '16e37e19194511993498801d4692795f', + 'content-length': 5000, + 'x-amz-storage-class': 'STANDARD', + 'restoreStatus': { + inProgress: false, + expiryDate: 'Fri, 20 Feb 2026 12:00:00 GMT' + } + }; + + const userMetadata = { + 'x-amz-meta-foo': 'foo', + 'x-amz-meta-bar': 'bar', + }; + + describe('with object attributes', () => { + it('should generate empty XML when attributes is empty', () => { + const result = []; + buildAttributesXml(objectMD, userMetadata, [], result); + + assert.strictEqual(result.length, 0); + }); + + it('should generate ETag XML', () => { + const result = []; + buildAttributesXml(objectMD, userMetadata, ['ETag'], result); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0], '16e37e19194511993498801d4692795f'); + }); + + it('should generate StorageClass XML', () => { + const result = []; + buildAttributesXml(objectMD, userMetadata, ['StorageClass'], result); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0], 'STANDARD'); + }); + + it('should generate ObjectSize XML', () => { + const result = []; + buildAttributesXml(objectMD, userMetadata, ['ObjectSize'], result); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0], '5000'); + }); + + it('should generate ObjectParts XML when parts exist', () => { + const result = []; + const objectMDWithParts = { ...objectMD, 'content-md5': `${objectMD['content-md5']}-10` }; + buildAttributesXml(objectMDWithParts, {}, ['ObjectParts'], result); + + assert.strictEqual(result.length, 3); + assert.strictEqual(result[0], ''); + assert.strictEqual(result[1], '10'); + assert.strictEqual(result[2], ''); + }); + + it('should generate RestoreStatus XML with expiry date', () => { + const result = []; + buildAttributesXml(objectMD, userMetadata, ['RestoreStatus'], result); + + assert.strictEqual(result.length, 4); + assert.strictEqual(result[0], ''); + assert.strictEqual(result[1], 'false'); + assert.strictEqual(result[2], 'Fri, 20 Feb 2026 12:00:00 GMT'); + assert.strictEqual(result[3], ''); + }); + + it('should ignore unknown attributes', () => { + const result = []; + buildAttributesXml(objectMD, userMetadata, ['UnknownAttribute', 'ETag'], result); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0], '16e37e19194511993498801d4692795f'); + }); + }); + + describe('with user metadata', () => { + it('should include all user metadata when x-amz-meta-* is used', () => { + const result = []; + buildAttributesXml({}, userMetadata, ['x-amz-meta-*'], result); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0], 'foo'); + assert.strictEqual(result[1], 'bar'); + }); + + it('should include specific user metadata keys', () => { + const result = []; + buildAttributesXml({}, userMetadata, ['x-amz-meta-foo'], result); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0], 'foo'); + }); + + it('should de-duplicate keys when both specific key and wildcard are requested', () => { + const result = []; + buildAttributesXml({}, userMetadata, ['x-amz-meta-foo', 'x-amz-meta-*'], result); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0], 'foo'); + assert.strictEqual(result[1], 'bar'); + }); + }); + + describe('with object attributes and user metadata', () => { + it('should build a comprehensive XML array with all supported features', () => { + const result = []; + const objectMDWithParts = { ...objectMD, 'content-md5': `${objectMD['content-md5']}-10` }; + const requested = ['ETag', 'ObjectSize', 'ObjectParts', 'RestoreStatus', 'x-amz-meta-*', 'x-amz-meta-foo']; + buildAttributesXml(objectMDWithParts, userMetadata, requested, result); + + const expected = [ + '16e37e19194511993498801d4692795f-10', + '5000', + '', + '10', + '', + '', + 'false', + 'Fri, 20 Feb 2026 12:00:00 GMT', + '', + 'foo', + 'bar', + ]; + + assert.strictEqual(result.length, 11); + for (const [i, elem] of expected.entries()) { + assert.strictEqual(result[i], elem); + } + }); + }); +}); diff --git a/tests/unit/api/apiUtils/object/parseAttributesHeader.js b/tests/unit/api/apiUtils/object/parseAttributesHeader.js deleted file mode 100644 index a7bce6af70..0000000000 --- a/tests/unit/api/apiUtils/object/parseAttributesHeader.js +++ /dev/null @@ -1,263 +0,0 @@ -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': ' ' }; - - 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('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 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; - }, - ); - }); - }); - - describe('valid attribute names', () => { - it('should return set with single valid attribute ETag', () => { - const headers = { 'x-amz-object-attributes': 'ETag' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ETag'])); - }); - - it('should return set with single valid attribute StorageClass', () => { - const headers = { 'x-amz-object-attributes': 'StorageClass' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['StorageClass'])); - }); - - it('should return set with single valid attribute ObjectSize', () => { - const headers = { 'x-amz-object-attributes': 'ObjectSize' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ObjectSize'])); - }); - - it('should return set with single valid attribute ObjectParts', () => { - const headers = { 'x-amz-object-attributes': 'ObjectParts' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ObjectParts'])); - }); - - it('should return set with single valid attribute Checksum', () => { - const headers = { 'x-amz-object-attributes': 'Checksum' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['Checksum'])); - }); - - it('should return set with multiple valid attributes', () => { - const headers = { 'x-amz-object-attributes': 'ETag,ObjectSize,StorageClass' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ETag', 'ObjectSize', 'StorageClass'])); - }); - - it('should return set with all valid attributes', () => { - const headers = { 'x-amz-object-attributes': 'StorageClass,ObjectSize,ObjectParts,Checksum,ETag' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.strictEqual(result.size, 5); - assert(result.has('StorageClass')); - assert(result.has('ObjectSize')); - assert(result.has('ObjectParts')); - assert(result.has('Checksum')); - assert(result.has('ETag')); - }); - }); - - 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(result instanceof Set); - assert.deepStrictEqual(result, new Set(['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(result instanceof Set); - assert.deepStrictEqual(result, new Set(['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(result instanceof Set); - assert.deepStrictEqual(result, new Set(['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(result instanceof Set); - assert.deepStrictEqual(result, new Set(['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 ' }; - const result = parseAttributesHeaders(headers); - - assert(result instanceof Set); - assert.deepStrictEqual(result, new Set(['ETag', 'ObjectSize'])); - }); - - 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; - }, - ); - }); - - it('should throw InvalidArgument for leading and trailing commas', () => { - 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; - }, - ); - }); - }); -}); From 7fa26499699e417c5c9fdcb0703da1c047ffb9d1 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/6] 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 c75d675d2e..eece6848e9 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.trim().toLowerCase().startsWith('x-amz-meta-'))) { - requestContexts.push(generateRequestContext('objectGetAttributesWithUserMetadata')); + requestContexts.push(generateRequestContext('objectGetAttributesCustom')); } } else { const requestContext = diff --git a/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js b/tests/unit/api/apiUtils/authorization/prepareRequestContexts.js index 9453fdb4f0..865480d61e 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:GetObjectAttributesCustom'); }); - 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:GetObjectAttributesCustom'); }); - 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:GetObjectAttributesCustom'); }); }); }); From 3ab3e83872d99445b67ae68566e4d319a0361a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20DONNART?= Date: Mon, 16 Feb 2026 12:48:00 +0100 Subject: [PATCH 5/6] Add objectGetAttributes in server access logs Issue: CLDSRV-844 --- lib/utilities/serverAccessLogger.js | 1 + .../testServerAccessLogFile.js | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lib/utilities/serverAccessLogger.js b/lib/utilities/serverAccessLogger.js index e6869df940..0a4e9e1664 100644 --- a/lib/utilities/serverAccessLogger.js +++ b/lib/utilities/serverAccessLogger.js @@ -262,6 +262,7 @@ const methodToResType = Object.freeze({ 'objectDelete': 'OBJECT', 'objectDeleteTagging': 'TAGGING', 'objectGet': 'OBJECT', + 'objectGetAttributes': 'OBJECT', 'objectGetACL': 'ACL', 'objectGetLegalHold': 'LEGALHOLD', 'objectGetRetention': 'OBJECT_LOCK_RETENTION', diff --git a/tests/functional/aws-node-sdk/test/serverAccessLogs/testServerAccessLogFile.js b/tests/functional/aws-node-sdk/test/serverAccessLogs/testServerAccessLogFile.js index 509fda5c0a..84372953db 100644 --- a/tests/functional/aws-node-sdk/test/serverAccessLogs/testServerAccessLogFile.js +++ b/tests/functional/aws-node-sdk/test/serverAccessLogs/testServerAccessLogFile.js @@ -1946,6 +1946,41 @@ describe('Server Access Logs - File Output', async () => { ], }; })(), + (() => { + // This operation tests getting object attributes. + const method = async () => { + await s3.createBucket({ Bucket: bucketName }); + await s3.putObject({ Bucket: bucketName, Key: objectKey, Body: 'test data' }); + await s3.getObjectAttributes({ Bucket: bucketName, Key: objectKey, ObjectAttributes: ['ETag'] }); + }; + return { + method, + methodName: 'objectGetAttributes', + expected: [ + { + ...commonProperties, + operation: 'REST.PUT.BUCKET', + action: 'CreateBucket', + bucketOwner: null, + httpMethod: 'PUT', + }, + { + ...commonProperties, + operation: 'REST.PUT.OBJECT', + action: 'PutObject', + objectKey, + httpMethod: 'PUT', + }, + { + ...commonProperties, + operation: 'REST.GET.OBJECT', + action: 'GetObjectAttributes', + objectKey, + httpMethod: 'GET', + } + ], + }; + })(), (() => { // This operation tests getting object ACL. const method = async () => { From cc074cb511fad0d02a49017afb1e5405f99c0d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20DONNART?= Date: Wed, 18 Feb 2026 14:24:55 +0100 Subject: [PATCH 6/6] Bump package.json to 9.3.3 Issue: CLDSRV-844 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 495cbb7a8d..b7aa3d691d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenko/cloudserver", - "version": "9.3.2", + "version": "9.3.3", "description": "Zenko CloudServer, an open-source Node.js implementation of a server handling the Amazon S3 protocol", "main": "index.js", "engines": {