From 60b89c73b9be8df1d1b36c080991be71497bb83d Mon Sep 17 00:00:00 2001 From: Anurag Mittal <1321012+anurag4DSB@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:33:19 +0100 Subject: [PATCH 1/2] CLDSRV-1065: test destination Get Add unit tests for the XML element of a destination record. They check: - every field, in order - brokers in stored order - every value escaped - Auth reduced to its Type, for each auth type - no secret string in the output Add unit tests for the Get handler on the in-memory backend, with records that Create stores. They check: - found, unknown and other account 404 - invalid name with no read - store and record errors - no record value in the logs Add functional tests through Create. They check: - every field, and no password, client key or key password in the body - brokers in order - CreatedAt and UpdatedAt in ISO 8601 UTC, to the millisecond - 404, 400 and anonymous 403 - an IAM user allowed on one destination ARN reads that one only Each IAM test makes its own IAM user and destinations, with names that no other test uses, and removes the user when it ends. --- .../test/routes/notificationDestination.js | 145 ++++++++++++ .../apiUtils/notificationDestination/xml.js | 175 ++++++++++++++ tests/unit/api/notificationDestinationGet.js | 220 ++++++++++++++++++ 3 files changed, 540 insertions(+) create mode 100644 tests/unit/api/apiUtils/notificationDestination/xml.js create mode 100644 tests/unit/api/notificationDestinationGet.js diff --git a/tests/functional/raw-node/test/routes/notificationDestination.js b/tests/functional/raw-node/test/routes/notificationDestination.js index 0d2909a7a2..fca88d4f2c 100644 --- a/tests/functional/raw-node/test/routes/notificationDestination.js +++ b/tests/functional/raw-node/test/routes/notificationDestination.js @@ -228,6 +228,14 @@ function destinationRequest(method, queryObj, requestBody, credentials = authCre } const createDestination = (name, body, credentials) => destinationRequest('PUT', { name }, body, credentials); +const getDestination = (name, credentials) => destinationRequest('GET', { name }, undefined, credentials); + +const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// The text of each element of that name in an XML body. +function elementValues(body, element) { + return [...body.matchAll(new RegExp(`<${element}>([^<]*)`, 'g'))].map(match => match[1]); +} function assertError({ err, res }, code, statusCode) { assert(err, `expected ${code}, got ${res?.statusCode}`); @@ -235,6 +243,20 @@ function assertError({ err, res }, code, statusCode) { assert.strictEqual(res?.statusCode, statusCode); } +// A destination name that no other test of the run uses. Thus an IAM test +// creates destinations of its own and reads none that another test left. +let nameSequence = 0; +const testName = prefix => uniqueName(`${prefix}-${++nameSequence}`); + +// Create a destination of the test account, and return its Arn: the Arn +// that Get answers is the resource that a policy names. +async function createDestinationArn(name) { + assert.ifError((await createDestination(name, saslSslBody)).err); + const { err, res } = await getDestination(name); + assert.ifError(err); + return elementValues(res.body, 'Arn')[0]; +} + describeOff('notification destination calls with the API off', () => { [ ['CreateNotificationDestination', 'PUT', { name: 'orders' }, saslSslBody], @@ -344,6 +366,95 @@ describeOn('CreateNotificationDestination', () => { }); }); +describeOn('GetNotificationDestination', () => { + // Strings that only a secret field holds, so that finding one in a body + // means a secret leaked. + const secrets = { + password: 'sentinel-pass-3e8d', + clientKey: 'sentinel-key-3e8d', + clientKeyPassword: 'sentinel-keypass-3e8d', + }; + const brokers = ['broker2.example.com:9093', 'broker1.example.com:9094']; + const connection = + '' + + `${brokers.map(broker => `${broker}`).join('')}` + + 'bucket-events1lz4' + + ''; + const bodies = { + // eslint-disable-next-line camelcase + sasl_ssl: + `kafka${connection}` + + 'sasl_ssl' + + `SCRAM-SHA-256events${secrets.password}` + + 'ca-cert' + + '', + mtls: + `kafka${connection}` + + 'mtlsclient-cert' + + `${secrets.clientKey}${secrets.clientKeyPassword}` + + '', + }; + + Object.keys(bodies).forEach(authType => { + it(`should answer every field of a ${authType} destination, and no secret`, async () => { + const name = uniqueName(`get-${authType}`); + const created = await createDestination(name, bodies[authType]); + assert.ifError(created.err); + const { err, res } = await getDestination(name); + assert.ifError(err); + assert.strictEqual(res.statusCode, 200); + const { body } = res; + assert.match(body, /^<\?xml version="1\.0" encoding="UTF-8"\?>/); + assert.deepStrictEqual(elementValues(body, 'Name'), [name]); + const [accountId] = elementValues(body, 'AccountId'); + assert.match(accountId, /^[0-9]{12}$/); + assert.deepStrictEqual(elementValues(body, 'Arn'), [`arn:scality:bucketnotif::${accountId}:${name}`]); + assert.match( + body, + new RegExp( + escapeRegExp(`kafka${connection}${authType}`), + ), + ); + Object.values(secrets).forEach(secret => assert.doesNotMatch(body, new RegExp(secret))); + assert.doesNotMatch(body, /|||||client-cert|ca-cert/); + }); + }); + + it('should answer the brokers in the order they were created', async () => { + const name = uniqueName('get-brokers'); + assert.ifError((await createDestination(name, bodies.mtls)).err); + const { err, res } = await getDestination(name); + assert.ifError(err); + assert.deepStrictEqual(elementValues(res.body, 'Broker'), brokers); + }); + + it('should answer CreatedAt and UpdatedAt in ISO 8601 UTC, to the millisecond', async () => { + const name = uniqueName('get-times'); + const before = Date.now(); + assert.ifError((await createDestination(name, bodies.mtls)).err); + const { err, res } = await getDestination(name); + assert.ifError(err); + const [createdAt] = elementValues(res.body, 'CreatedAt'); + const [updatedAt] = elementValues(res.body, 'UpdatedAt'); + assert.match(createdAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + assert.strictEqual(updatedAt, createdAt); + assert(Date.parse(createdAt) >= before, `${createdAt} is before the Create`); + assert(Date.parse(createdAt) <= Date.now(), `${createdAt} is after the Get`); + }); + + it('should answer NoSuchDestination to an unknown name', async () => { + assertError(await getDestination(uniqueName('unknown')), 'NoSuchDestination', 404); + }); + + it('should answer InvalidDestination to a name outside the grammar', async () => { + assertError(await getDestination('bad/name'), 'InvalidDestination', 400); + }); + + it('should answer AccessDenied to an anonymous request', async () => { + assertError(await getDestination(uniqueName('anonymous'), null), 'AccessDenied', 403); + }); +}); + describeIam('CreateNotificationDestination by an IAM user', () => { const iamClient = makeIamClient(); const userName = uniqueName('destination-no-policy'); @@ -361,3 +472,37 @@ describeIam('CreateNotificationDestination by an IAM user', () => { assertError(await createDestination(uniqueName('denied'), saslSslBody, credentials), 'AccessDenied', 403); }); }); + +describeIam('GetNotificationDestination by an IAM user', () => { + const iamClient = makeIamClient(); + const userName = uniqueName('destination-get-one'); + let allowedName; + let otherName; + let credentials; + + beforeEach(async () => { + allowedName = testName('get-allowed'); + otherName = testName('get-other'); + const allowedArn = await createDestinationArn(allowedName); + assert.ifError((await createDestination(otherName, saslSslBody)).err); + credentials = await createIamCaller(iamClient, userName, { + action: 'scality:GetBucketNotificationDestination', + resource: allowedArn, + }); + }); + + afterEach(async () => { + await removeIamCaller(iamClient, userName); + }); + + it('should read the destination that the policy names', async () => { + const { err, res } = await getDestination(allowedName, credentials); + assert.ifError(err); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(elementValues(res.body, 'Name'), [allowedName]); + }); + + it('should answer AccessDenied to another destination', async () => { + assertError(await getDestination(otherName, credentials), 'AccessDenied', 403); + }); +}); diff --git a/tests/unit/api/apiUtils/notificationDestination/xml.js b/tests/unit/api/apiUtils/notificationDestination/xml.js new file mode 100644 index 0000000000..a4fad833ea --- /dev/null +++ b/tests/unit/api/apiUtils/notificationDestination/xml.js @@ -0,0 +1,175 @@ +const assert = require('assert'); + +const { destinationXml } = require('../../../../../lib/api/apiUtils/notificationDestination/xml'); + +const accountId = '123456789012'; + +// Strings that only a secret field holds, so that finding one in the output +// means a secret leaked. +const secrets = { + username: 'sentinel-user-5c1e', + password: 'sentinel-pass-5c1e', + clientCertificate: 'sentinel-cert-5c1e', + clientKey: 'sentinel-key-5c1e', + clientKeyPassword: 'sentinel-keypass-5c1e', + caCertificate: 'sentinel-ca-5c1e', +}; + +const auths = { + none: { Type: 'none' }, + // eslint-disable-next-line camelcase + sasl_plaintext: { + Type: 'sasl_plaintext', + Sasl: { Mechanism: 'PLAIN', Username: secrets.username, Password: secrets.password }, + }, + // eslint-disable-next-line camelcase + sasl_ssl: { + Type: 'sasl_ssl', + Sasl: { Mechanism: 'SCRAM-SHA-512', Username: secrets.username, Password: secrets.password }, + Tls: { CaCertificate: secrets.caCertificate }, + }, + mtls: { + Type: 'mtls', + Tls: { + CaCertificate: secrets.caCertificate, + ClientCertificate: secrets.clientCertificate, + ClientKey: secrets.clientKey, + ClientKeyPassword: secrets.clientKeyPassword, + }, + }, +}; + +function makeRecord(overrides = {}) { + return { + modelVersion: 1, + Name: 'orders', + AccountId: accountId, + Arn: `arn:scality:bucketnotif::${accountId}:orders`, + Type: 'kafka', + Connection: { + Brokers: ['broker1.example.com:9093', '[fd00::1]:9092'], + Topic: 'bucket-events', + RequiredAcks: -1, + CompressionType: 'zstd', + }, + Auth: auths.sasl_ssl, + CreatedAt: '2026-11-10T09:15:00Z', + UpdatedAt: '2026-11-10T10:30:00Z', + ...overrides, + }; +} + +describe('destination XML', () => { + it('should give every field of the record, in order, with no xmlns', () => { + assert.strictEqual( + destinationXml(makeRecord()), + '' + + 'orders' + + `arn:scality:bucketnotif::${accountId}:orders` + + `${accountId}` + + 'kafka' + + '' + + 'broker1.example.com:9093[fd00::1]:9092' + + 'bucket-events' + + '-1' + + 'zstd' + + '' + + 'sasl_ssl' + + '2026-11-10T09:15:00Z' + + '2026-11-10T10:30:00Z' + + '', + ); + }); + + it('should keep the brokers in their stored order', () => { + const brokers = ['c.example.com:9092', 'a.example.com:9092', 'b.example.com:9092']; + const xml = destinationXml(makeRecord({ Connection: { ...makeRecord().Connection, Brokers: brokers } })); + assert.match( + xml, + new RegExp(`${brokers.map(broker => `${broker}`).join('')}`), + ); + }); + + it('should give RequiredAcks 0 and 1 as numbers', () => { + [0, 1].forEach(requiredAcks => { + const xml = destinationXml( + makeRecord({ Connection: { ...makeRecord().Connection, RequiredAcks: requiredAcks } }), + ); + assert.match(xml, new RegExp(`${requiredAcks}`)); + }); + }); + + Object.keys(auths).forEach(authType => { + it(`should reduce an ${authType} Auth to its Type and leak no secret`, () => { + const xml = destinationXml(makeRecord({ Auth: auths[authType] })); + assert.match(xml, new RegExp(`${authType}`)); + assert.doesNotMatch(xml, /||||/); + Object.values(secrets).forEach(secret => assert.doesNotMatch(xml, new RegExp(secret))); + }); + }); + + it('should leave out a field that the record holds but the answer does not name', () => { + const xml = destinationXml(makeRecord({ Extra: 'sentinel-extra-5c1e' })); + assert.doesNotMatch(xml, /modelVersion|Extra|sentinel-extra-5c1e/); + }); + + ['Name', 'Arn', 'AccountId', 'Type', 'CreatedAt', 'UpdatedAt'].forEach(field => { + it(`should throw on a record with no ${field}, rather than answer undefined`, () => { + const record = makeRecord(); + delete record[field]; + assert.throws(() => destinationXml(record), new RegExp(field)); + }); + }); + + [ + ['Connection', 'Topic'], + ['Connection', 'RequiredAcks'], + ['Connection', 'CompressionType'], + ['Auth', 'Type'], + ].forEach(([block, field]) => { + it(`should throw on a record with no ${block}.${field}`, () => { + const record = makeRecord(); + // a copy, so that the shared blocks of makeRecord stay whole + record[block] = { ...record[block] }; + delete record[block][field]; + assert.throws(() => destinationXml(record), new RegExp(field)); + }); + }); + + it('should escape every value', () => { + const record = makeRecord({ + Name: 'a&bd"e\'f', + Arn: 'arn&<', + AccountId: '<1>', + Type: 'k&a', + Connection: { + Brokers: [':1', 'b&c:2'], + Topic: 't"<', + RequiredAcks: -1, + CompressionType: "c'>", + }, + Auth: { Type: 'n' }, + CreatedAt: '', + UpdatedAt: '&u', + }); + const xml = destinationXml(record); + assert.strictEqual( + xml, + '' + + 'a&b<c>d"e'f' + + 'arn&<' + + '<1>' + + 'k&a' + + '' + + '<b>:1b&c:2' + + 't"<' + + '-1' + + 'c'>' + + '' + + 'n<o>' + + '<c>' + + '&u' + + '', + ); + }); +}); diff --git a/tests/unit/api/notificationDestinationGet.js b/tests/unit/api/notificationDestinationGet.js new file mode 100644 index 0000000000..882b8cbd5d --- /dev/null +++ b/tests/unit/api/notificationDestinationGet.js @@ -0,0 +1,220 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const { errors, models } = require('@scality/arsenal'); +const AuthInfo = require('@scality/arsenal').auth.AuthInfo; +const { config } = require('../../../lib/Config'); + +const notificationDestinationCreate = require('../../../lib/api/notificationDestinationCreate'); +const notificationDestinationGet = require('../../../lib/api/notificationDestinationGet'); +const recordStore = require('../../../lib/api/apiUtils/notificationDestination/recordStore'); +const { cleanup, DummyRequestLogger, makeAuthInfo } = require('../helpers'); + +// the account of accessKey1 in makeAuthInfo +const accountId = '123456789098'; + +const secrets = ['sentinel-user-9d2b', 'sentinel-pass-9d2b', 'sentinel-ca-9d2b']; + +const saslSslBody = + '' + + 'kafka' + + '' + + 'broker1.example.com:9093broker2.example.com:9093' + + 'bucket-events' + + '1zstd' + + '' + + '' + + 'sasl_ssl' + + `SCRAM-SHA-512${secrets[0]}` + + `${secrets[1]}` + + `${secrets[2]}` + + '' + + ''; + +const accountAuthInfo = makeAuthInfo('accessKey1'); +// An IAM user whose short ID, the user ID, is not the account ID, as Vault +// gives it. makeAuthInfo gives a user the short ID of its account. +const userAuthInfo = new AuthInfo({ + canonicalID: accountAuthInfo.getCanonicalID(), + shortid: 'AIDAUSERID0000000000', + arn: `arn:aws:iam::${accountId}:user/alice`, +}); +const otherAuthInfo = makeAuthInfo('accessKey2'); + +function makeRequest(method, name, post) { + return { + method, + url: `/?notification-destination&name=${encodeURIComponent(name)}`, + query: { 'notification-destination': '', name }, + headers: { host: 's3.amazonaws.com' }, + post, + actionImplicitDenies: false, + }; +} + +// Resolve with the callback arguments, so that a failed assertion fails the +// test at once instead of in a metadata callback. +function call(handler, authInfo, request, log) { + return new Promise(resolve => handler(authInfo, request, log, (...args) => resolve(args))); +} + +const create = (authInfo, name, log) => + call(notificationDestinationCreate, authInfo, makeRequest('PUT', name, saslSslBody), log); +const get = (authInfo, name, log) => call(notificationDestinationGet, authInfo, makeRequest('GET', name), log); + +describe('notificationDestinationGet', () => { + let log; + let savedBackend; + + beforeEach(() => { + // The tests read the in-memory store, so the record store runs on + // mem, whatever S3BACKEND says. stop() drops its client after each + // test. + savedBackend = config.backends.metadata; + config.backends.metadata = 'mem'; + recordStore.stop(); + cleanup(); + log = new DummyRequestLogger(); + }); + + afterEach(() => { + recordStore.stop(); + config.backends.metadata = savedBackend; + sinon.restore(); + }); + + it('should answer the created destination as XML, with no secret', async () => { + const [createErr] = await create(accountAuthInfo, 'orders', log); + assert.ifError(createErr); + const args = await get(accountAuthInfo, 'orders', log); + assert.strictEqual(args.length, 2, 'Get answers callback(err, xml)'); + const [err, xml] = args; + assert.ifError(err); + const match = xml.match( + /^<\?xml version="1\.0" encoding="UTF-8"\?>orders<\/Name>.*(.*)<\/CreatedAt>(.*)<\/UpdatedAt><\/NotificationDestination>$/, + ); + assert(match, xml); + assert.match(xml, new RegExp(`arn:scality:bucketnotif::${accountId}:orders`)); + assert.match(xml, new RegExp(`${accountId}`)); + assert.match( + xml, + new RegExp( + 'kafkabroker1.example.com:9093' + + 'broker2.example.com:9093bucket-events' + + '1zstd' + + 'sasl_ssl', + ), + ); + assert.match(match[1], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + assert.strictEqual(match[2], match[1]); + secrets.forEach(secret => assert.doesNotMatch(xml, new RegExp(secret))); + }); + + it('should read the destination of the account of an IAM user', async () => { + const [createErr] = await create(accountAuthInfo, 'orders', log); + assert.ifError(createErr); + const [err, xml] = await get(userAuthInfo, 'orders', log); + assert.ifError(err); + assert.match(xml, new RegExp(`${accountId}`)); + }); + + it('should answer NoSuchDestination to an unknown name', async () => { + const args = await get(accountAuthInfo, 'unknown', log); + assert.strictEqual(args.length, 2); + const [err, xml] = args; + assert.strictEqual(err?.message, 'NoSuchDestination'); + assert.strictEqual(err.code, 404); + assert.strictEqual(xml, null); + }); + + it('should answer NoSuchDestination to the name of a global destination', async () => { + const saved = config.bucketNotificationDestinations; + config.bucketNotificationDestinations = [{ resource: 'target1', type: 'dummy', host: 'localhost:6000' }]; + try { + const [err, xml] = await get(accountAuthInfo, 'target1', log); + assert.strictEqual(err?.message, 'NoSuchDestination'); + assert.strictEqual(xml, null); + } finally { + config.bucketNotificationDestinations = saved; + } + }); + + it('should answer NoSuchDestination to the name of another account', async () => { + const [createErr] = await create(otherAuthInfo, 'orders', log); + assert.ifError(createErr); + const [err] = await get(accountAuthInfo, 'orders', log); + assert.strictEqual(err?.message, 'NoSuchDestination'); + }); + + ['bad/name', 'a'.repeat(models.NotificationDestination.nameMaxLength + 1), 'na:me', '*'].forEach(name => { + it(`should answer InvalidDestination to the name '${name.slice(0, 20)}' with no read`, async () => { + const read = sinon.spy(recordStore, 'getDestination'); + const [err, xml] = await get(accountAuthInfo, name, log); + assert.strictEqual(err?.message, 'InvalidDestination'); + assert.strictEqual(xml, null); + assert.strictEqual(read.called, false); + }); + }); + + it('should pass a record store read error through', async () => { + sinon.stub(recordStore, 'getDestination').callsArgWith(3, errors.InternalError); + const [err, xml] = await get(accountAuthInfo, 'orders', log); + assert.strictEqual(err?.message, 'InternalError'); + assert.strictEqual(xml, null); + }); + + // A whole record, as the store would give it, that a test may break. + const storedRecord = () => ({ + Name: 'orders', + Arn: `arn:scality:bucketnotif::${accountId}:orders`, + AccountId: accountId, + Type: 'kafka', + Connection: { + Brokers: ['broker1.example.com:9093'], + Topic: 't', + RequiredAcks: -1, + CompressionType: 'none', + }, + Auth: { Type: 'none' }, + CreatedAt: '2026-11-10T09:15:00Z', + UpdatedAt: '2026-11-10T09:15:00Z', + }); + + it('should answer a whole stored record', async () => { + sinon.stub(recordStore, 'getDestination').callsArgWith(3, null, storedRecord()); + const [err, xml] = await get(accountAuthInfo, 'orders', log); + assert.ifError(err); + assert.match(xml, /orders<\/Name>/); + assert.match(xml, /broker1\.example\.com:9093<\/Broker><\/Brokers>/); + assert.match(xml, /t<\/Topic>-1<\/RequiredAcks>none<\/CompressionType>/); + assert.match(xml, /none<\/Type><\/Auth>/); + assert.match(xml, /2026-11-10T09:15:00Z<\/UpdatedAt>/); + }); + + ['UpdatedAt', 'Connection', 'Auth'].forEach(field => { + it(`should answer InternalError to a record with no ${field}`, async () => { + const record = storedRecord(); + delete record[field]; + sinon.stub(recordStore, 'getDestination').callsArgWith(3, null, record); + const [err, xml] = await get(accountAuthInfo, 'orders', log); + assert.strictEqual(err?.message, 'InternalError'); + assert.strictEqual(xml, null); + }); + }); + + it('should never log the record', async () => { + const [createErr] = await create(accountAuthInfo, 'orders', log); + assert.ifError(createErr); + const spyLog = new DummyRequestLogger(); + ['trace', 'debug', 'info', 'warn', 'error'].forEach(level => sinon.spy(spyLog, level)); + const [getErr] = await get(accountAuthInfo, 'orders', spyLog); + assert.ifError(getErr); + const [unknownErr] = await get(accountAuthInfo, 'unknown', spyLog); + assert.strictEqual(unknownErr?.message, 'NoSuchDestination'); + const logged = JSON.stringify(['trace', 'debug', 'info', 'warn', 'error'].flatMap(level => spyLog[level].args)); + [...secrets, 'broker1', 'bucket-events'].forEach(value => { + assert.doesNotMatch(logged, new RegExp(value)); + }); + assert.match(logged, /orders/); + assert.match(logged, new RegExp(accountId)); + }); +}); From b78b066f7b425fef32f15c675a7ef0211e3c5dd1 Mon Sep 17 00:00:00 2001 From: Anurag Mittal <1321012+anurag4DSB@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:33:19 +0100 Subject: [PATCH 2/2] CLDSRV-1065: add destination Get - notificationDestinationGet checks the name and reads the record of the requester's account. It answers the NotificationDestination XML, with Auth reduced to its Type. - apiUtils/notificationDestination/xml.js builds the element, so that List reuses it for each entry. - The handler reports its call to the Prometheus metrics, as the bucket APIs do. --- lib/api/api.js | 2 + .../apiUtils/notificationDestination/xml.js | 57 ++++++++++++++++++ lib/api/notificationDestinationGet.js | 58 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 lib/api/apiUtils/notificationDestination/xml.js create mode 100644 lib/api/notificationDestinationGet.js diff --git a/lib/api/api.js b/lib/api/api.js index 32fbed4443..7a29281da3 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -52,6 +52,7 @@ const metadataSearch = require('./metadataSearch'); const { multiObjectDelete } = require('./multiObjectDelete'); const multipartDelete = require('./multipartDelete'); const notificationDestinationCreate = require('./notificationDestinationCreate'); +const notificationDestinationGet = require('./notificationDestinationGet'); const objectCopy = require('./objectCopy'); const { objectDelete } = require('./objectDelete'); const objectDeleteTagging = require('./objectDeleteTagging'); @@ -643,6 +644,7 @@ const api = { multiObjectDelete, multipartDelete, notificationDestinationCreate, + notificationDestinationGet, objectDelete, objectDeleteTagging, objectGet, diff --git a/lib/api/apiUtils/notificationDestination/xml.js b/lib/api/apiUtils/notificationDestination/xml.js new file mode 100644 index 0000000000..6566fbd816 --- /dev/null +++ b/lib/api/apiUtils/notificationDestination/xml.js @@ -0,0 +1,57 @@ +const { s3middleware } = require('@scality/arsenal'); + +const { escapeForXml } = s3middleware; + +// A record that lacks a value cannot be answered. Throw, rather than give +// the text undefined. A number, such as RequiredAcks, becomes its text +// before the escape, because the escape takes strings. +function element(name, value) { + if (value === undefined || value === null) { + throw new Error(`the destination record lacks ${name}`); + } + return `<${name}>${escapeForXml(String(value))}`; +} + +function connectionXml(connection) { + return [ + '', + '', + ...connection.Brokers.map(broker => element('Broker', broker)), + '', + element('Topic', connection.Topic), + element('RequiredAcks', connection.RequiredAcks), + element('CompressionType', connection.CompressionType), + '', + ].join(''); +} + +/** + * Return the NotificationDestination element of a stored destination + * record, as Get answers it and as List answers each entry. The element + * names each field that it gives. Thus a field that the record gains never + * leaks by default. This function reduces Auth to its Type, because the + * Sasl and Tls blocks hold secrets. These blocks never leave in an answer. + * @param {object} record - the destination record, as the record store + * gives it + * @return {string} - the element, with no XML declaration and no xmlns + */ +function destinationXml(record) { + return [ + '', + element('Name', record.Name), + element('Arn', record.Arn), + element('AccountId', record.AccountId), + element('Type', record.Type), + connectionXml(record.Connection), + '', + element('Type', record.Auth.Type), + '', + element('CreatedAt', record.CreatedAt), + element('UpdatedAt', record.UpdatedAt), + '', + ].join(''); +} + +module.exports = { + destinationXml, +}; diff --git a/lib/api/notificationDestinationGet.js b/lib/api/notificationDestinationGet.js new file mode 100644 index 0000000000..3607950ca7 --- /dev/null +++ b/lib/api/notificationDestinationGet.js @@ -0,0 +1,58 @@ +const { errors } = require('@scality/arsenal'); + +const recordStore = require('./apiUtils/notificationDestination/recordStore'); +const { nameError, requesterAccountId } = require('./apiUtils/notificationDestination/request'); +const { destinationXml } = require('./apiUtils/notificationDestination/xml'); +const monitoring = require('../utilities/monitoringHandler'); + +const METRICS_ACTION = 'getNotificationDestination'; + +/** + * notificationDestinationGet - get a bucket notification destination of the + * account of the requester, with its Auth reduced to its Type + * @param {AuthInfo} authInfo - Instance of AuthInfo class with requester's info + * @param {object} request - http request object + * @param {object} log - Werelogs logger + * @param {function} callback - callback(err, xml) + * @return {undefined} + */ +function notificationDestinationGet(authInfo, request, log, callback) { + log.debug('processing request', { method: 'notificationDestinationGet' }); + const name = request.query.name; + + const invalidName = nameError(name); + if (invalidName) { + log.debug('invalid destination name', { method: 'notificationDestinationGet' }); + monitoring.promMetrics('GET', request.bucketName, 400, METRICS_ACTION); + return process.nextTick(callback, invalidName, null); + } + const accountId = requesterAccountId(authInfo); + const logFields = { method: 'notificationDestinationGet', destinationName: name, accountId }; + + return recordStore.getDestination(accountId, name, log, (err, record) => { + if (err) { + if (err.is?.NoSuchDestination) { + log.debug('destination not found', logFields); + } else { + log.error('could not read the destination record', { ...logFields, error: err }); + } + monitoring.promMetrics('GET', request.bucketName, err.code, METRICS_ACTION); + return callback(err, null); + } + let xml; + try { + xml = ['', destinationXml(record)].join(''); + } catch (xmlErr) { + // The handler cannot answer a record that lacks a block. Never let + // the error escape the record store callback. + log.error('could not build the destination answer', { ...logFields, error: xmlErr.message }); + monitoring.promMetrics('GET', request.bucketName, errors.InternalError.code, METRICS_ACTION); + return callback(errors.InternalError, null); + } + log.debug('destination read', logFields); + monitoring.promMetrics('GET', request.bucketName, '200', METRICS_ACTION); + return callback(null, xml); + }); +} + +module.exports = notificationDestinationGet;