Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/api/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -645,6 +646,7 @@ const api = {
multipartDelete,
notificationDestinationCreate,
notificationDestinationGet,
notificationDestinationList,
objectDelete,
objectDeleteTagging,
objectGet,
Expand Down
101 changes: 101 additions & 0 deletions lib/api/notificationDestinationList.js
Original file line number Diff line number Diff line change
@@ -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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<ListNotificationDestinationsResult>',
`<IsTruncated>${isTruncated}</IsTruncated>`,
isTruncated && nextMarker !== undefined ? `<NextMarker>${escapeForXml(nextMarker)}</NextMarker>` : '',
...destinations.map(destination => destinationXml(destination.record)),
'</ListNotificationDestinationsResult>',
].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;
156 changes: 156 additions & 0 deletions tests/functional/raw-node/test/routes/notificationDestination.js
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,109 @@ describeOn('GetNotificationDestination', () => {
});
});

describeOn('ListNotificationDestinations', () => {
const listDestinations = (queryObj, credentials) => destinationRequest('GET', queryObj, undefined, credentials);
const listedNames = body =>
[...body.matchAll(/<NotificationDestination><Name>([^<]*)<\/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"\?><ListNotificationDestinationsResult><IsTruncated>true<\/IsTruncated><NextMarker>/,
);
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('<?xml version="1.0" encoding="UTF-8"?>', '');
assert.match(res.body, new RegExp(escapeRegExp(entry)));
assert.doesNotMatch(res.body, /<Sasl>|<Tls>|<Username>|<Password>|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,
'<?xml version="1.0" encoding="UTF-8"?>' +
'<ListNotificationDestinationsResult><IsTruncated>false</IsTruncated>' +
'</ListNotificationDestinationsResult>',
);
});

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');
Expand Down Expand Up @@ -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);
});
});
Loading
Loading