Skip to content

Commit 3c0f725

Browse files
authored
change semantics of ENABLE_COLLECTIONS_AUTHX to fail closed and allow * for all collections (#888)
1 parent 3381556 commit 3c0f725

13 files changed

+388
-134
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
66
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
77

8+
## [4.1.0] - 2024-04-21
9+
10+
### Changed
11+
12+
- **breaking** Collections Auth features (ENABLE_COLLECTIONS_AUTHX) now fails closed
13+
rather than open. If `_collections` is not specified or is empty, caller has no
14+
access to collections. Also, adds a special collection name `*` that means access
15+
to all collections is granted.
16+
817
## [4.0.0] - 2025-04-07
918

1019
### Removed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,11 @@ The endpoints this applies to are:
11291129
The five endpoints of the Transaction Extension do not use this parameter, as there are
11301130
other authorization considerations for these, that are left as future work.
11311131

1132+
If this behavior is enabled and a `_collections` parameter is not passed or is passed
1133+
with an empty string or empty list, the caller will not have access to any collections.
1134+
When `*` is included in the list of collections (ideally as the only value), the caller
1135+
will have access to all collections.
1136+
11321137
## Ingesting Data
11331138

11341139
STAC Collections and Items are ingested by the `ingest` Lambda function, however this Lambda is not invoked directly by a user, it consumes records from the `stac-server-<stage>-queue` SQS. To add STAC Items or Collections to the queue, publish them to the SNS Topic `stac-server-<stage>-ingest`.

src/lib/api.js

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,11 @@ const extractFields = function (params) {
315315
return fieldRules
316316
}
317317

318+
/**
319+
* Parse a string or array of IDs into an array of strings or undefined.
320+
* @param {string | string[] | undefined} ids - The IDs parameter to parse
321+
* @returns {string[] | undefined} Parsed array of ID strings or undefined
322+
*/
318323
const parseIds = function (ids) {
319324
let idsRules
320325
if (ids) {
@@ -337,14 +342,28 @@ const extractIds = function (params) {
337342

338343
const extractAllowedCollectionIds = function (params) {
339344
return process.env['ENABLE_COLLECTIONS_AUTHX'] === 'true'
340-
? parseIds(params._collections)
345+
? parseIds(params._collections) || []
341346
: undefined
342347
}
343348

344349
const extractCollectionIds = function (params) {
345350
return parseIds(params.collections)
346351
}
347352

353+
const filterAllowedCollectionIds = function (allowedCollectionIds, specifiedCollectionIds) {
354+
return (
355+
Array.isArray(allowedCollectionIds) && !allowedCollectionIds.includes('*')
356+
) ? allowedCollectionIds.filter(
357+
(x) => !specifiedCollectionIds || specifiedCollectionIds.includes(x)
358+
) : specifiedCollectionIds
359+
}
360+
361+
const isCollectionIdAllowed = function (allowedCollectionIds, collectionId) {
362+
return !Array.isArray(allowedCollectionIds)
363+
|| allowedCollectionIds.includes(collectionId)
364+
|| allowedCollectionIds.includes('*')
365+
}
366+
348367
export const parsePath = function (inpath) {
349368
const searchFilters = {
350369
root: false,
@@ -577,9 +596,7 @@ const searchItems = async function (collectionId, queryParameters, backend, endp
577596
const ids = extractIds(queryParameters)
578597
const allowedCollectionIds = extractAllowedCollectionIds(queryParameters)
579598
const specifiedCollectionIds = extractCollectionIds(queryParameters)
580-
const collections = allowedCollectionIds ? allowedCollectionIds.filter(
581-
(x) => !specifiedCollectionIds || specifiedCollectionIds.includes(x)
582-
) : specifiedCollectionIds
599+
const collections = filterAllowedCollectionIds(allowedCollectionIds, specifiedCollectionIds)
583600
const limit = extractLimit(queryParameters) || 10
584601
const page = extractPage(queryParameters)
585602

@@ -715,9 +732,28 @@ const aggregate = async function (
715732
const ids = extractIds(queryParameters)
716733
const allowedCollectionIds = extractAllowedCollectionIds(queryParameters)
717734
const specifiedCollectionIds = extractCollectionIds(queryParameters)
718-
const collections = allowedCollectionIds ? allowedCollectionIds.filter(
719-
(x) => !specifiedCollectionIds || specifiedCollectionIds.includes(x)
720-
) : specifiedCollectionIds
735+
const collections = filterAllowedCollectionIds(allowedCollectionIds, specifiedCollectionIds)
736+
737+
if (Array.isArray(collections) && !collections.length) {
738+
if (collectionId) {
739+
return new NotFoundError()
740+
}
741+
742+
return {
743+
aggregations: [],
744+
links: [
745+
{
746+
rel: 'self',
747+
type: 'application/json',
748+
href: `${endpoint}/aggregate`
749+
},
750+
{
751+
rel: 'root',
752+
type: 'application/json',
753+
href: `${endpoint}`
754+
}]
755+
}
756+
}
721757

722758
const searchParams = pickBy({
723759
datetime,
@@ -991,7 +1027,7 @@ const validateAdditionalProperties = (queryables) => {
9911027

9921028
const getCollectionQueryables = async (collectionId, backend, endpoint, queryParameters) => {
9931029
const allowedCollectionIds = extractAllowedCollectionIds(queryParameters)
994-
if (allowedCollectionIds && !allowedCollectionIds.includes(collectionId)) {
1030+
if (!isCollectionIdAllowed(allowedCollectionIds, collectionId)) {
9951031
return new NotFoundError()
9961032
}
9971033

@@ -1008,8 +1044,7 @@ const getCollectionQueryables = async (collectionId, backend, endpoint, queryPar
10081044
}
10091045

10101046
const getCollectionAggregations = async (collectionId, backend, endpoint, queryParameters) => {
1011-
const allowedCollectionIds = extractAllowedCollectionIds(queryParameters)
1012-
if (allowedCollectionIds && !allowedCollectionIds.includes(collectionId)) {
1047+
if (!isCollectionIdAllowed(extractAllowedCollectionIds(queryParameters), collectionId)) {
10131048
return new NotFoundError()
10141049
}
10151050

@@ -1155,7 +1190,7 @@ const getCollections = async function (backend, endpoint, queryParameters) {
11551190

11561191
const allowedCollectionIds = extractAllowedCollectionIds(queryParameters)
11571192
const collections = collectionsOrError.filter(
1158-
(c) => !allowedCollectionIds || allowedCollectionIds.includes(c.id)
1193+
(c) => isCollectionIdAllowed(allowedCollectionIds, c.id)
11591194
)
11601195

11611196
for (const collection of collections) {
@@ -1194,8 +1229,7 @@ const getCollections = async function (backend, endpoint, queryParameters) {
11941229
}
11951230

11961231
const getCollection = async function (collectionId, backend, endpoint, queryParameters) {
1197-
const allowedCollectionIds = extractAllowedCollectionIds(queryParameters)
1198-
if (allowedCollectionIds && !allowedCollectionIds.includes(collectionId)) {
1232+
if (!isCollectionIdAllowed(extractAllowedCollectionIds(queryParameters), collectionId)) {
11991233
return new NotFoundError()
12001234
}
12011235

@@ -1224,8 +1258,7 @@ const createCollection = async function (collection, backend) {
12241258
}
12251259

12261260
const getItem = async function (collectionId, itemId, backend, endpoint, queryParameters) {
1227-
const allowedCollectionIds = extractAllowedCollectionIds(queryParameters)
1228-
if (allowedCollectionIds && !allowedCollectionIds.includes(collectionId)) {
1261+
if (!isCollectionIdAllowed(extractAllowedCollectionIds(queryParameters), collectionId)) {
12291262
return new NotFoundError()
12301263
}
12311264

@@ -1279,8 +1312,7 @@ const deleteItem = async function (collectionId, itemId, backend) {
12791312
}
12801313

12811314
const getItemThumbnail = async function (collectionId, itemId, backend, queryParameters) {
1282-
const allowedCollectionIds = extractAllowedCollectionIds(queryParameters)
1283-
if (allowedCollectionIds && !allowedCollectionIds.includes(collectionId)) {
1315+
if (!isCollectionIdAllowed(extractAllowedCollectionIds(queryParameters), collectionId)) {
12841316
return new NotFoundError()
12851317
}
12861318

tests/system/test-api-collection-items-get.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ test.before(async (t) => {
5858
})
5959
})
6060

61+
test.beforeEach(async (_) => {
62+
delete process.env['ENABLE_COLLECTIONS_AUTHX']
63+
})
64+
6165
test.after.always(async (t) => {
6266
if (t.context.api) await t.context.api.close()
6367
})
@@ -98,8 +102,22 @@ test('GET /collections/:collectionId/items with restriction returns filtered col
98102

99103
const path = `collections/${collectionId}/items`
100104

105+
// _collections undefined
106+
t.is((await t.context.api.client.get(path,
107+
{ resolveBodyOnly: false, throwHttpErrors: false })).statusCode, 404)
108+
109+
// _collections empty
110+
t.is((await t.context.api.client.get(path,
111+
{ resolveBodyOnly: false,
112+
throwHttpErrors: false,
113+
searchParams: { _collections: '' },
114+
})).statusCode, 404)
115+
101116
t.is((await t.context.api.client.get(path,
102-
{ resolveBodyOnly: false })).statusCode, 200)
117+
{
118+
resolveBodyOnly: false,
119+
searchParams: { _collections: '*' }
120+
})).statusCode, 200)
103121

104122
t.is((await t.context.api.client.get(path,
105123
{

tests/system/test-api-get-aggregate.js

Lines changed: 86 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ test('GET /aggregate with no aggregations param', async (t) => {
4343
])
4444
})
4545

46+
test.beforeEach(async (_) => {
47+
delete process.env['ENABLE_COLLECTIONS_AUTHX']
48+
})
49+
4650
test('GET /aggregate with aggregations param', async (t) => {
4751
const fixtureFiles = [
4852
'collection.json',
@@ -515,78 +519,89 @@ test('GET /aggregate with restriction returns filtered collections', async (t) =
515519

516520
const collectionId = 'landsat-8-l1'
517521

518-
let response = null
519-
520-
// validate how many items we have total without restricting collections
521-
response = await t.context.api.client.get(
522-
'aggregate',
523-
{
524-
searchParams: new URLSearchParams({
525-
aggregations: ['total_count']
526-
}),
527-
resolveBodyOnly: false,
528-
}
529-
)
530-
531-
t.is(response.statusCode, 200)
532-
t.deepEqual(response.body.aggregations, [{
533-
name: 'total_count',
534-
data_type: 'integer',
535-
value: 4
536-
}])
537-
538-
// get the counts for collectionId without restrictions
539-
response = await t.context.api.client.get(
540-
'aggregate',
541-
{
542-
searchParams: new URLSearchParams({
543-
aggregations: ['total_count'],
544-
collections: [collectionId]
545-
}),
546-
resolveBodyOnly: false,
547-
}
548-
)
549-
550-
t.is(response.statusCode, 200)
551-
t.deepEqual(response.body.aggregations, [{
552-
name: 'total_count',
553-
data_type: 'integer',
554-
value: 2
555-
}])
556-
557-
// restrict collections to include the one we just got 2 results for
558-
559-
const response2 = await t.context.api.client.get(
560-
'aggregate',
561-
{
562-
searchParams: new URLSearchParams({
563-
aggregations: ['total_count'],
564-
_collections: [collectionId, 'foo', 'bar']
565-
}),
566-
resolveBodyOnly: false,
567-
}
568-
)
522+
{ // _collections not included
523+
const r = await t.context.api.client.get(
524+
'aggregate',
525+
{
526+
searchParams: new URLSearchParams({
527+
aggregations: ['total_count']
528+
}),
529+
resolveBodyOnly: false,
530+
}
531+
)
569532

570-
t.is(response.statusCode, 200)
571-
t.deepEqual(response2.body.aggregations, response.body.aggregations)
533+
t.is(r.statusCode, 200)
534+
t.deepEqual(r.body.aggregations, [])
535+
}
572536

573-
// restrict collections to a non-existent one with no items, so should be 0 results
574-
response = await t.context.api.client.get(
575-
'aggregate',
576-
{
577-
searchParams: new URLSearchParams({
578-
aggregations: ['total_count'],
579-
collections: [collectionId],
580-
_collections: ['not-a-collection']
581-
}),
582-
resolveBodyOnly: false,
583-
}
584-
)
537+
{ // _collections is empty
538+
const r = await t.context.api.client.get(
539+
'aggregate',
540+
{
541+
searchParams: new URLSearchParams({
542+
aggregations: ['total_count'],
543+
collections: []
544+
}),
545+
resolveBodyOnly: false,
546+
}
547+
)
548+
t.is(r.statusCode, 200)
549+
t.deepEqual(r.body.aggregations, [])
550+
}
551+
552+
{ // _collections is * for admin -- get the counts for without restrictions
553+
const r = await t.context.api.client.get(
554+
'aggregate',
555+
{
556+
searchParams: new URLSearchParams({
557+
aggregations: ['total_count'],
558+
_collections: ['*']
559+
}),
560+
resolveBodyOnly: false,
561+
}
562+
)
563+
t.is(r.statusCode, 200)
564+
t.deepEqual(r.body.aggregations, [{
565+
name: 'total_count',
566+
data_type: 'integer',
567+
value: 4
568+
}])
569+
}
570+
571+
{ // restrict collections to include the one we just got 2 results for
572+
const r = await t.context.api.client.get(
573+
'aggregate',
574+
{
575+
searchParams: new URLSearchParams({
576+
aggregations: ['total_count'],
577+
_collections: [collectionId, 'foo', 'bar']
578+
}),
579+
resolveBodyOnly: false,
580+
}
581+
)
582+
583+
t.is(r.statusCode, 200)
584+
t.deepEqual(r.body.aggregations, [{
585+
name: 'total_count',
586+
data_type: 'integer',
587+
value: 2
588+
}])
589+
}
590+
591+
{ // restrict collections to a non-existent one with no items, so should be 0 results
592+
const r = await t.context.api.client.get(
593+
'aggregate',
594+
{
595+
searchParams: new URLSearchParams({
596+
aggregations: ['total_count'],
597+
collections: [collectionId],
598+
_collections: ['not-a-collection']
599+
}),
600+
resolveBodyOnly: false,
601+
}
602+
)
585603

586-
t.is(response.statusCode, 200)
587-
t.deepEqual(response.body.aggregations, [{
588-
name: 'total_count',
589-
data_type: 'integer',
590-
value: 0
591-
}])
604+
t.is(r.statusCode, 200)
605+
t.deepEqual(r.body.aggregations, [])
606+
}
592607
})

0 commit comments

Comments
 (0)