diff --git a/lib/api/api.js b/lib/api/api.js index 7a29281da3..a14b0f9d93 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -53,6 +53,7 @@ const { multiObjectDelete } = require('./multiObjectDelete'); const multipartDelete = require('./multipartDelete'); const notificationDestinationCreate = require('./notificationDestinationCreate'); const notificationDestinationGet = require('./notificationDestinationGet'); +const notificationDestinationList = require('./notificationDestinationList'); const objectCopy = require('./objectCopy'); const { objectDelete } = require('./objectDelete'); const objectDeleteTagging = require('./objectDeleteTagging'); @@ -645,6 +646,7 @@ const api = { multipartDelete, notificationDestinationCreate, notificationDestinationGet, + notificationDestinationList, objectDelete, objectDeleteTagging, objectGet, diff --git a/lib/api/notificationDestinationList.js b/lib/api/notificationDestinationList.js new file mode 100644 index 0000000000..4996b05627 --- /dev/null +++ b/lib/api/notificationDestinationList.js @@ -0,0 +1,101 @@ +const { errors, errorInstances, s3middleware } = require('@scality/arsenal'); + +const { listingHardLimit } = require('../../constants'); +const recordStore = require('./apiUtils/notificationDestination/recordStore'); +const { requesterAccountId } = require('./apiUtils/notificationDestination/request'); +const { destinationXml } = require('./apiUtils/notificationDestination/xml'); +const monitoring = require('../utilities/monitoringHandler'); + +const { escapeForXml } = s3middleware; + +const METRICS_ACTION = 'listNotificationDestinations'; +const defaultMaxItems = 100; + +/** + * Return the number of names that a page holds. It is 100 when max-items is + * absent. Else it is max-items, which must be digits only and from 1 to the + * listing limit, 1000. + * @param {*} maxItems - the max-items query parameter + * @return {number|null} - the number of names, or null when max-items is + * not valid + */ +function pageSize(maxItems) { + if (maxItems === undefined) { + return defaultMaxItems; + } + // A repeated parameter is an array, which a pattern test would coerce. + if (typeof maxItems !== 'string' || !/^[0-9]+$/.test(maxItems)) { + return null; + } + const size = Number.parseInt(maxItems, 10); + return size >= 1 && size <= listingHardLimit ? size : null; +} + +/** + * notificationDestinationList - list the bucket notification destinations of + * the account of the requester, in name order, each 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 notificationDestinationList(authInfo, request, log, callback) { + log.debug('processing request', { method: 'notificationDestinationList' }); + const { query } = request; + + const maxKeys = pageSize(query['max-items']); + if (maxKeys === null) { + log.debug('invalid max-items', { method: 'notificationDestinationList' }); + const err = errorInstances.InvalidArgument.customizeDescription( + `max-items must be a whole number from 1 to ${listingHardLimit}.`, + ); + monitoring.promMetrics('GET', request.bucketName, err.code, METRICS_ACTION); + return process.nextTick(callback, err, null); + } + const { marker } = query; + if (marker !== undefined && typeof marker !== 'string') { + log.debug('invalid marker', { method: 'notificationDestinationList' }); + const err = errorInstances.InvalidArgument.customizeDescription('marker must be given once.'); + monitoring.promMetrics('GET', request.bucketName, err.code, METRICS_ACTION); + return process.nextTick(callback, err, null); + } + const accountId = requesterAccountId(authInfo); + const logFields = { method: 'notificationDestinationList', accountId, maxItems: maxKeys }; + const params = { maxKeys }; + if (marker !== undefined) { + params.marker = marker; + } + + return recordStore.listDestinations(accountId, params, log, (err, result) => { + if (err) { + log.error('could not list the destination records', { ...logFields, error: err }); + monitoring.promMetrics('GET', request.bucketName, err.code, METRICS_ACTION); + return callback(err, null); + } + const { destinations, isTruncated, nextMarker } = result; + let xml; + try { + xml = [ + '', + '', + `${isTruncated}`, + isTruncated && nextMarker !== undefined ? `${escapeForXml(nextMarker)}` : '', + ...destinations.map(destination => destinationXml(destination.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 list answer', { ...logFields, error: xmlErr.message }); + monitoring.promMetrics('GET', request.bucketName, errors.InternalError.code, METRICS_ACTION); + return callback(errors.InternalError, null); + } + log.debug('destinations listed', { ...logFields, count: destinations.length, isTruncated }); + monitoring.promMetrics('GET', request.bucketName, '200', METRICS_ACTION); + return callback(null, xml); + }); +} + +module.exports = notificationDestinationList; diff --git a/tests/functional/raw-node/test/routes/notificationDestination.js b/tests/functional/raw-node/test/routes/notificationDestination.js index fca88d4f2c..59793869f1 100644 --- a/tests/functional/raw-node/test/routes/notificationDestination.js +++ b/tests/functional/raw-node/test/routes/notificationDestination.js @@ -455,6 +455,109 @@ describeOn('GetNotificationDestination', () => { }); }); +describeOn('ListNotificationDestinations', () => { + const listDestinations = (queryObj, credentials) => destinationRequest('GET', queryObj, undefined, credentials); + const listedNames = body => + [...body.matchAll(/([^<]*)<\/Name>/g)].map(match => match[1]); + // The other tests of this file create names of their own in the same + // account and never delete them. Thus the names here share one prefix + // that no other test uses, and each page starts after that prefix. Each + // test gets a prefix and destinations of its own. + const lisa = getCredentials('lisa'); + const lisaCredentials = { accessKey: lisa.accessKeyId, secretKey: lisa.secretAccessKey }; + let prefix; + let names; + let lisaName; + + beforeEach(async () => { + prefix = testName('zz-list'); + names = ['a', 'b', 'c'].map(suffix => `${prefix}-${suffix}`); + lisaName = `${prefix}-lisa`; + assert.notStrictEqual(lisaCredentials.accessKey, authCredentials.accessKey, 'a second account is needed'); + for (const name of [names[2], names[0], names[1]]) { + assert.ifError((await createDestination(name, saslSslBody)).err); + } + assert.ifError((await createDestination(lisaName, saslSslBody, lisaCredentials)).err); + }); + + it('should page through three names with max-items 2, NextMarker then marker', async () => { + const first = await listDestinations({ 'max-items': '2', 'marker': `${prefix}-` }); + assert.ifError(first.err); + assert.strictEqual(first.res.statusCode, 200); + assert.match( + first.res.body, + /^<\?xml version="1\.0" encoding="UTF-8"\?>true<\/IsTruncated>/, + ); + assert.doesNotMatch(first.res.body, /xmlns/); + assert.deepStrictEqual(listedNames(first.res.body), names.slice(0, 2)); + assert.deepStrictEqual(elementValues(first.res.body, 'NextMarker'), [names[1]]); + + const second = await listDestinations({ 'max-items': '2', 'marker': names[1] }); + assert.ifError(second.err); + const listed = listedNames(second.res.body); + assert.strictEqual(listed[0], names[2]); + assert.deepStrictEqual( + listed.filter(name => name.startsWith(prefix)), + [names[2]], + ); + // A name that sorts after these may exist. NextMarker follows IsTruncated. + const [isTruncated] = elementValues(second.res.body, 'IsTruncated'); + assert.deepStrictEqual( + elementValues(second.res.body, 'NextMarker'), + isTruncated === 'true' ? [listed[listed.length - 1]] : [], + ); + }); + + it('should answer each entry as Get does, with no secret', async () => { + const { err, res } = await listDestinations({ 'max-items': '1', 'marker': `${prefix}-` }); + assert.ifError(err); + const got = await getDestination(names[0]); + assert.ifError(got.err); + const entry = got.res.body.replace('', ''); + assert.match(res.body, new RegExp(escapeRegExp(entry))); + assert.doesNotMatch(res.body, /||||secret/); + }); + + it('should answer IsTruncated false and no entry after the last name', async () => { + // No name of at most 128 characters sorts after 128 z. + const { err, res } = await listDestinations({ marker: 'z'.repeat(128) }); + assert.ifError(err); + assert.strictEqual( + res.body, + '' + + 'false' + + '', + ); + }); + + it('should never list the destinations of another account', async () => { + const mine = await listDestinations({ 'max-items': '1000', 'marker': `${prefix}-` }); + assert.ifError(mine.err); + const myNames = listedNames(mine.res.body); + assert(!myNames.includes(lisaName), mine.res.body); + assert(myNames.includes(names[0])); + + const theirs = await listDestinations({ 'max-items': '1000', 'marker': `${prefix}-` }, lisaCredentials); + assert.ifError(theirs.err); + assert.deepStrictEqual( + listedNames(theirs.res.body).filter(name => name.startsWith(prefix)), + [lisaName], + ); + const [lisaAccountId] = elementValues(theirs.res.body, 'AccountId'); + assert.notStrictEqual(lisaAccountId, elementValues(mine.res.body, 'AccountId')[0]); + }); + + ['0', '1001', 'abc', '2.5', '', '+5'].forEach(maxItems => { + it(`should answer InvalidArgument to max-items '${maxItems}'`, async () => { + assertError(await listDestinations({ 'max-items': maxItems }), 'InvalidArgument', 400); + }); + }); + + it('should answer AccessDenied to an anonymous request', async () => { + assertError(await listDestinations({}, null), 'AccessDenied', 403); + }); +}); + describeIam('CreateNotificationDestination by an IAM user', () => { const iamClient = makeIamClient(); const userName = uniqueName('destination-no-policy'); @@ -506,3 +609,56 @@ describeIam('GetNotificationDestination by an IAM user', () => { assertError(await getDestination(otherName, credentials), 'AccessDenied', 403); }); }); + +describeIam('ListNotificationDestinations by an IAM user', () => { + const iamClient = makeIamClient(); + // List is authorized on every destination of the account. A policy on + // the account wildcard allows it. A policy on one destination does not. + const userNames = { + wildcard: uniqueName('destination-list-all'), + one: uniqueName('destination-list-one'), + }; + let listedName; + let credentials; + + beforeEach(async () => { + listedName = testName('list-iam'); + const destinationArn = await createDestinationArn(listedName); + const resources = { + wildcard: destinationArn.replace(/[^:]*$/, '*'), + one: destinationArn, + }; + credentials = {}; + for (const [kind, userName] of Object.entries(userNames)) { + credentials[kind] = await createIamCaller(iamClient, userName, { + action: 'scality:ListBucketNotificationDestinations', + resource: resources[kind], + }); + } + }); + + afterEach(async () => { + for (const userName of Object.values(userNames)) { + await removeIamCaller(iamClient, userName); + } + }); + + const listAs = kind => + destinationRequest( + 'GET', + { 'max-items': '1', 'marker': listedName.slice(0, -1) }, + undefined, + credentials[kind], + ); + + it('should list for a user allowed on every destination of the account', async () => { + const { err, res } = await listAs('wildcard'); + assert.ifError(err); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(elementValues(res.body, 'Name'), [listedName]); + }); + + it('should answer AccessDenied to a user allowed on one destination only', async () => { + assertError(await listAs('one'), 'AccessDenied', 403); + }); +}); diff --git a/tests/unit/api/notificationDestinationList.js b/tests/unit/api/notificationDestinationList.js new file mode 100644 index 0000000000..ff0c79be4d --- /dev/null +++ b/tests/unit/api/notificationDestinationList.js @@ -0,0 +1,316 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const { errors } = require('@scality/arsenal'); +const AuthInfo = require('@scality/arsenal').auth.AuthInfo; + +const notificationDestinationCreate = require('../../../lib/api/notificationDestinationCreate'); +const notificationDestinationList = require('../../../lib/api/notificationDestinationList'); +const recordStore = require('../../../lib/api/apiUtils/notificationDestination/recordStore'); +const { config } = require('../../../lib/Config'); +const { listingHardLimit } = require('../../../constants'); +const { cleanup, DummyRequestLogger, makeAuthInfo } = require('../helpers'); + +// the accounts of accessKey1 and accessKey2 in makeAuthInfo +const accountId = '123456789098'; +const otherAccountId = '234567890987'; + +const secrets = ['sentinel-user-5c1a', 'sentinel-pass-5c1a', 'sentinel-ca-5c1a']; + +const saslSslBody = + '' + + 'kafka' + + '' + + 'broker1.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 createRequest(name) { + return { + method: 'PUT', + url: `/?notification-destination&name=${encodeURIComponent(name)}`, + query: { 'notification-destination': '', name }, + headers: { host: 's3.amazonaws.com' }, + post: saslSslBody, + actionImplicitDenies: false, + }; +} + +function listRequest(params = {}) { + return { + method: 'GET', + url: '/?notification-destination', + query: { 'notification-destination': '', ...params }, + headers: { host: 's3.amazonaws.com' }, + 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 list = (authInfo, params, log) => call(notificationDestinationList, authInfo, listRequest(params), log); + +async function createAll(authInfo, names, log) { + for (const name of names) { + const [err] = await call(notificationDestinationCreate, authInfo, createRequest(name), log); + assert.ifError(err); + } +} + +// 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]); +} + +const listedNames = xml => [...xml.matchAll(/([^<]*)<\/Name>/g)].map(match => match[1]); + +describe('notificationDestinationList', () => { + 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 IsTruncated false and no entry to an account with no destination', async () => { + const args = await list(accountAuthInfo, {}, log); + assert.strictEqual(args.length, 2, 'List answers callback(err, xml)'); + const [err, xml] = args; + assert.ifError(err); + assert.strictEqual( + xml, + '' + + 'false' + + '', + ); + }); + + it('should answer every destination in name order, as Get answers each, with no secret', async () => { + await createAll(accountAuthInfo, ['orders', 'audit', 'billing'], log); + const [err, xml] = await list(accountAuthInfo, {}, log); + assert.ifError(err); + assert.match( + xml, + /^<\?xml version="1\.0" encoding="UTF-8"\?>false<\/IsTruncated>.*<\/NotificationDestination><\/ListNotificationDestinationsResult>$/, + ); + assert.doesNotMatch(xml, /xmlns|/); + assert.deepStrictEqual(listedNames(xml), ['audit', 'billing', 'orders']); + assert.deepStrictEqual(elementValues(xml, 'AccountId'), [accountId, accountId, accountId]); + assert.match( + xml, + new RegExp( + `auditarn:scality:bucketnotif::${accountId}:audit` + + `${accountId}kafka` + + 'broker1.example.com:9093bucket-events' + + '1zstd' + + 'sasl_ssl[^<]+[^<]+' + + '', + ), + ); + secrets.forEach(secret => assert.doesNotMatch(xml, new RegExp(secret))); + assert.doesNotMatch(xml, /|||/); + }); + + it('should list the destinations of the account of an IAM user', async () => { + await createAll(accountAuthInfo, ['orders'], log); + const [err, xml] = await list(userAuthInfo, {}, log); + assert.ifError(err); + assert.deepStrictEqual(listedNames(xml), ['orders']); + }); + + it('should never list the destinations of another account', async () => { + await createAll(otherAuthInfo, ['other-a', 'other-b'], log); + await createAll(accountAuthInfo, ['mine'], log); + const [err, xml] = await list(accountAuthInfo, {}, log); + assert.ifError(err); + assert.deepStrictEqual(listedNames(xml), ['mine']); + const [otherErr, otherXml] = await list(otherAuthInfo, {}, log); + assert.ifError(otherErr); + assert.deepStrictEqual(listedNames(otherXml), ['other-a', 'other-b']); + assert.deepStrictEqual(elementValues(otherXml, 'AccountId'), [otherAccountId, otherAccountId]); + }); + + it('should page through three names with max-items 2, NextMarker then marker', async () => { + await createAll(accountAuthInfo, ['c-dest', 'a-dest', 'b-dest'], log); + const [err, xml] = await list(accountAuthInfo, { 'max-items': '2' }, log); + assert.ifError(err); + assert.match( + xml, + /true<\/IsTruncated>b-dest<\/NextMarker>/, + ); + assert.deepStrictEqual(listedNames(xml), ['a-dest', 'b-dest']); + const [nextMarker] = elementValues(xml, 'NextMarker'); + + const [nextErr, nextXml] = await list(accountAuthInfo, { 'max-items': '2', 'marker': nextMarker }, log); + assert.ifError(nextErr); + assert.match( + nextXml, + /false<\/IsTruncated>/, + ); + assert.doesNotMatch(nextXml, //); + assert.deepStrictEqual(listedNames(nextXml), ['c-dest']); + }); + + it('should list the names after a marker that is not a destination name', async () => { + await createAll(accountAuthInfo, ['a-dest', 'b-dest', 'c-dest'], log); + const [err, xml] = await list(accountAuthInfo, { marker: 'a-dest0' }, log); + assert.ifError(err); + assert.deepStrictEqual(listedNames(xml), ['b-dest', 'c-dest']); + }); + + it('should escape NextMarker', async () => { + // No valid name needs escaping, so the store gives one that does. + const record = { + Name: 'a', + Arn: `arn:scality:bucketnotif::${accountId}:a`, + AccountId: accountId, + Type: 'kafka', + Connection: { Brokers: ['b:1'], Topic: 't', RequiredAcks: 1, CompressionType: 'none' }, + Auth: { Type: 'none' }, + CreatedAt: '2026-11-10T09:15:00Z', + UpdatedAt: '2026-11-10T09:15:00Z', + }; + sinon + .stub(recordStore, 'listDestinations') + .callsArgWith(3, null, { destinations: [{ name: 'a', record }], isTruncated: true, nextMarker: 'a&' }); + const [err, xml] = await list(accountAuthInfo, { 'max-items': '1' }, log); + assert.ifError(err); + assert.match(xml, /true<\/IsTruncated>a&<b><\/NextMarker>/); + }); + + it('should ask the store for 100 names when max-items is absent', async () => { + const listing = sinon.spy(recordStore, 'listDestinations'); + const [err] = await list(accountAuthInfo, {}, log); + assert.ifError(err); + assert.strictEqual(listing.calledOnce, true); + assert.strictEqual(listing.firstCall.args[0], accountId); + assert.deepStrictEqual(listing.firstCall.args[1], { maxKeys: 100 }); + }); + + it('should answer 100 entries and NextMarker when max-items is absent', async () => { + const destinations = Array.from({ length: 101 }, (_, i) => `dest-${String(i).padStart(3, '0')}`); + await createAll(accountAuthInfo, destinations, log); + const [err, xml] = await list(accountAuthInfo, {}, log); + assert.ifError(err); + assert.deepStrictEqual(listedNames(xml), destinations.slice(0, 100)); + assert.deepStrictEqual(elementValues(xml, 'IsTruncated'), ['true']); + assert.deepStrictEqual(elementValues(xml, 'NextMarker'), ['dest-099']); + }); + + [ + ['1', 1], + [String(listingHardLimit), listingHardLimit], + ].forEach(([maxItems, maxKeys]) => { + it(`should ask the store for ${maxKeys} names with max-items ${maxItems}`, async () => { + const listing = sinon.spy(recordStore, 'listDestinations'); + const [err] = await list(accountAuthInfo, { 'max-items': maxItems, 'marker': 'm' }, log); + assert.ifError(err); + assert.deepStrictEqual(listing.firstCall.args[1], { maxKeys, marker: 'm' }); + }); + }); + + [ + ['0', '0'], + ['above the listing limit', String(listingHardLimit + 1)], + ['abc', 'abc'], + ['2.5', '2.5'], + ['empty', ''], + ['a leading plus', '+5'], + ['a minus', '-1'], + ['a space', ' 5'], + ['an exponent', '1e2'], + ['a repeated parameter', ['2', '3']], + ].forEach(([what, maxItems]) => { + it(`should answer InvalidArgument to max-items ${what}, with no listing`, async () => { + const listing = sinon.spy(recordStore, 'listDestinations'); + const args = await list(accountAuthInfo, { 'max-items': maxItems }, log); + assert.strictEqual(args.length, 2); + const [err, xml] = args; + assert.strictEqual(err?.message, 'InvalidArgument'); + assert.strictEqual(err.code, 400); + assert.strictEqual(xml, null); + assert.strictEqual(listing.called, false); + }); + }); + + it('should answer InvalidArgument to a repeated marker, with no listing', async () => { + const listing = sinon.spy(recordStore, 'listDestinations'); + const [err, xml] = await list(accountAuthInfo, { marker: ['a', 'b'] }, log); + assert.strictEqual(err?.message, 'InvalidArgument'); + assert.strictEqual(xml, null); + assert.strictEqual(listing.called, false); + }); + + it('should pass a record store listing error through', async () => { + sinon.stub(recordStore, 'listDestinations').callsArgWith(3, errors.InternalError); + const [err, xml] = await list(accountAuthInfo, {}, log); + assert.strictEqual(err?.message, 'InternalError'); + assert.strictEqual(xml, null); + }); + + it('should answer InternalError when a listed record cannot be answered', async () => { + const record = { + Name: 'orders', + Arn: `arn:scality:bucketnotif::${accountId}:orders`, + AccountId: accountId, + Type: 'kafka', + Auth: { Type: 'none' }, + CreatedAt: '2026-11-10T09:15:00Z', + UpdatedAt: '2026-11-10T09:15:00Z', + }; + sinon + .stub(recordStore, 'listDestinations') + .callsArgWith(3, null, { destinations: [{ name: 'orders', record }], isTruncated: false }); + const [err, xml] = await list(accountAuthInfo, {}, log); + assert.strictEqual(err?.message, 'InternalError'); + assert.strictEqual(xml, null); + }); + + it('should log the account and the counts, never a name or a record', async () => { + await createAll(accountAuthInfo, ['orders', 'audit'], log); + const spyLog = new DummyRequestLogger(); + const levels = ['trace', 'debug', 'info', 'warn', 'error']; + levels.forEach(level => sinon.spy(spyLog, level)); + const [err] = await list(accountAuthInfo, { 'max-items': '1', 'marker': 'a-marker' }, spyLog); + assert.ifError(err); + const logged = JSON.stringify(levels.flatMap(level => spyLog[level].args)); + [...secrets, 'broker1', 'bucket-events', 'orders', 'audit', 'a-marker'].forEach(value => { + assert.doesNotMatch(logged, new RegExp(value)); + }); + assert.match(logged, new RegExp(accountId)); + }); +});