Skip to content

Commit f8dbacc

Browse files
authored
add support for hidden collections parameter for applying auth (#874)
* add support for hidden collections parameter for applying auth * add ENABLE_COLLECTIONS_AUTHX
1 parent 310826d commit f8dbacc

18 files changed

+549
-76
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1818

1919
- Use Node 22 by default.
2020

21+
## Added
22+
23+
- To all endpoints that depend on collections, add support for a query parameter (GET)
24+
or body field (POST) `_collections` that will filter to only those collections, but
25+
will not reveal that in link contents. This is controlled by the "ENABLE_COLLECTIONS_AUTHX"
26+
2127
## [3.11.0] - 2025-03-27
2228

2329
### Added

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- [4.0.0](#400)
1313
- [Context Extension disabled by default](#context-extension-disabled-by-default)
1414
- [Node 22 update](#node-22-update)
15+
- [Hidden collections filter](#hidden-collections-filter)
1516
- [3.10.0](#3100)
1617
- [Node 20 update](#node-20-update)
1718
- [3.1.0](#310)
@@ -50,6 +51,7 @@
5051
- [Filter Extension](#filter-extension)
5152
- [Query Extension](#query-extension)
5253
- [Aggregation](#aggregation)
54+
- [Hidden collections filter for authorization](#hidden-collections-filter-for-authorization)
5355
- [Ingesting Data](#ingesting-data)
5456
- [Ingesting large items](#ingesting-large-items)
5557
- [Subscribing to SNS Topics](#subscribing-to-sns-topics)
@@ -183,6 +185,13 @@ The default Lambda deployment environment is now Node 22.
183185
To update the deployment to use Node 22, modify the serverless config file value
184186
`provider.runtime` to be `nodejs22.x` and the application re-deployed.
185187

188+
#### Hidden collections filter
189+
190+
To all endpoints that depend on collections, there is now support for a query parameter
191+
(GET) or body field (POST) `_collections` that will filter to only those collections, but
192+
will not reveal that in link contents. This is useful for the application of permissions
193+
to only certain collections.
194+
186195
### 3.10.0
187196

188197
#### Node 20 update
@@ -574,7 +583,7 @@ There are some settings that should be reviewed and updated as needeed in the se
574583
| REQUEST_LOGGING_FORMAT | Express request logging format to use. Any of the [Morgan predefined formats](https://github.com/expressjs/morgan#predefined-formats). | tiny |
575584
| STAC_API_URL | The root endpoint of this API | Inferred from request |
576585
| ENABLE_TRANSACTIONS_EXTENSION | Boolean specifying if the [Transaction Extension](https://github.com/radiantearth/stac-api-spec/tree/master/ogcapi-features/extensions/transaction) should be activated | false |
577-
| ENABLE_CONTEXT_EXTENSION | Boolean specifying if the [Context Extension](https://github.com/stac-api-extensions/context) should be activated | false |
586+
| ENABLE_CONTEXT_EXTENSION | Boolean specifying if the [Context Extension](https://github.com/stac-api-extensions/context) should be activated | false |
578587
| STAC_API_ROOTPATH | The path to append to URLs if this is not deployed at the server root. For example, if the server is deployed without a custom domain name, it will have the stage name (e.g., dev) in the path. | "" |
579588
| PRE_HOOK | The name of a Lambda function to be called as the pre-hook. | none |
580589
| POST_HOOK | The name of a Lambda function to be called as the post-hook. | none |
@@ -589,6 +598,7 @@ There are some settings that should be reviewed and updated as needeed in the se
589598
| CORS_CREDENTIALS | Configure whether or not to send the `Access-Control-Allow-Credentials` CORS header. Header will be sent if set to `true`. | none |
590599
| CORS_METHODS | Configure whether or not to send the `Access-Control-Allow-Methods` CORS header. Expects a comma-delimited string, e.g., `GET,PUT,POST`. | `GET,HEAD,PUT,PATCH,POST,DELETE` |
591600
| CORS_HEADERS | Configure whether or not to send the `Access-Control-Allow-Headers` CORS header. Expects a comma-delimited string, e.g., `Content-Type,Authorization`. If not specified, defaults to reflecting the headers specified in the request’s `Access-Control-Request-Headers` header. | none |
601+
| ENABLE_COLLECTIONS_AUTHX | Enables support for hidden `_collections` query parameter / field when set to `true`. | none |
592602

593603
Additionally, the credential for OpenSearch must be configured, as decribed in the
594604
section [Populating and accessing credentials](#populating-and-accessing-credentials).
@@ -1093,6 +1103,32 @@ Available aggregations are:
10931103
- geometry_geohash_grid_frequency ([geohash grid](https://opensearch.org/docs/latest/aggregations/bucket/geohash-grid/) on Item.geometry)
10941104
- geometry_geotile_grid_frequency ([geotile grid](https://opensearch.org/docs/latest/aggregations/bucket/geotile-grid/) on Item.geometry)
10951105

1106+
## Hidden collections filter for authorization
1107+
1108+
All endpoints that involve the use of Collections support the use of a "hidden" query
1109+
parameter named (for GET requests) or body JSON field (for POST requests) named
1110+
`_collections` that can be used by an authorization proxy (e.g., a pre-hook Lambda)
1111+
to filter the collections a user has access to. This parameter/field will be excluded
1112+
from pagination links, so it does not need to be removed on egress.
1113+
1114+
This feature must be enabled with the `ENABLE_COLLECTIONS_AUTHX` configuration.
1115+
1116+
The endpoints this applies to are:
1117+
1118+
- /collections
1119+
- /collections/:collectionId
1120+
- /collections/:collectionId/queryables
1121+
- /collections/:collectionId/aggregations
1122+
- /collections/:collectionId/aggregate
1123+
- /collections/:collectionId/items
1124+
- /collections/:collectionId/items/:itemId
1125+
- /collections/:collectionId/items/:itemId/thumbnail
1126+
- /search
1127+
- /aggregate
1128+
1129+
The five endpoints of the Transaction Extension do not use this parameter, as there are
1130+
other authorization considerations for these, that are left as future work.
1131+
10961132
## Ingesting Data
10971133

10981134
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`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"deploy": "sls deploy",
2626
"sls-remove": "sls remove",
2727
"package": "sls package",
28-
"serve": "REQUEST_LOGGING_FORMAT=dev LOG_LEVEL=debug STAC_API_URL=http://localhost:3000 ENABLE_TRANSACTIONS_EXTENSION=true nodemon --esm ./src/lambdas/api/local.ts",
28+
"serve": "REQUEST_LOGGING_FORMAT=dev LOG_LEVEL=debug STAC_API_URL=http://localhost:3000 ENABLE_TRANSACTIONS_EXTENSION=true nodemon --exec node --loader ts-node/esm ./src/lambdas/api/local.ts",
2929
"build-api-docs": "npx @redocly/cli build-docs src/lambdas/api/openapi.yaml -o ./docs/index.html",
3030
"prepare": "husky"
3131
},

src/lambdas/api/app.js

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import path from 'path'
66
import { fileURLToPath } from 'url'
77
import database from '../../lib/database.js'
88
import api from '../../lib/api.js'
9-
import { ValidationError } from '../../lib/errors.js'
9+
import { NotFoundError, ValidationError } from '../../lib/errors.js'
1010
import { readFile } from '../../lib/fs.js'
1111
import addEndpoint from './middleware/add-endpoint.js'
1212
import logger from '../../lib/logger.js'
@@ -153,7 +153,7 @@ app.get('/aggregations', async (req, res, next) => {
153153

154154
app.get('/collections', async (req, res, next) => {
155155
try {
156-
const response = await api.getCollections(database, req.endpoint)
156+
const response = await api.getCollections(database, req.endpoint, req.query)
157157
if (response instanceof Error) next(createError(500, response.message))
158158
else res.json(response)
159159
} catch (error) {
@@ -185,7 +185,7 @@ app.post('/collections', async (req, res, next) => {
185185
app.get('/collections/:collectionId', async (req, res, next) => {
186186
const { collectionId } = req.params
187187
try {
188-
const response = await api.getCollection(collectionId, database, req.endpoint)
188+
const response = await api.getCollection(collectionId, database, req.endpoint, req.query)
189189
if (response instanceof Error) next(createError(404))
190190
else res.json(response)
191191
} catch (error) {
@@ -196,7 +196,9 @@ app.get('/collections/:collectionId', async (req, res, next) => {
196196
app.get('/collections/:collectionId/queryables', async (req, res, next) => {
197197
const { collectionId } = req.params
198198
try {
199-
const queryables = await api.getCollectionQueryables(collectionId, database, req.endpoint)
199+
const queryables = await api.getCollectionQueryables(
200+
collectionId, database, req.endpoint, req.query
201+
)
200202

201203
if (queryables instanceof Error) next(createError(404))
202204
else {
@@ -215,7 +217,9 @@ app.get('/collections/:collectionId/queryables', async (req, res, next) => {
215217
app.get('/collections/:collectionId/aggregations', async (req, res, next) => {
216218
const { collectionId } = req.params
217219
try {
218-
const aggs = await api.getCollectionAggregations(collectionId, database, req.endpoint)
220+
const aggs = await api.getCollectionAggregations(
221+
collectionId, database, req.endpoint, req.query
222+
)
219223
if (aggs instanceof Error) next(createError(404))
220224
else res.json(aggs)
221225
} catch (error) {
@@ -231,7 +235,9 @@ app.get('/collections/:collectionId/aggregate',
231235
async (req, res, next) => {
232236
const { collectionId } = req.params
233237
try {
234-
const response = await api.getCollection(collectionId, database, req.endpoint)
238+
const response = await api.getCollection(
239+
collectionId, database, req.endpoint, req.query
240+
)
235241

236242
if (response instanceof Error) next(createError(404))
237243
else {
@@ -249,20 +255,22 @@ app.get('/collections/:collectionId/aggregate',
249255
app.get('/collections/:collectionId/items', async (req, res, next) => {
250256
const { collectionId } = req.params
251257
try {
252-
const response = await api.getCollection(collectionId, database, req.endpoint)
258+
if ((await api.getCollection(
259+
collectionId, database, req.endpoint, req.query
260+
)) instanceof Error) {
261+
next(createError(404))
262+
}
253263

254-
if (response instanceof Error) next(createError(404))
255-
else {
256-
const items = await api.searchItems(
264+
res.type('application/geo+json')
265+
res.json(
266+
await api.searchItems(
257267
collectionId,
258268
req.query,
259269
database,
260270
req.endpoint,
261271
'GET'
262272
)
263-
res.type('application/geo+json')
264-
res.json(items)
265-
}
273+
)
266274
} catch (error) {
267275
if (error instanceof ValidationError) {
268276
next(createError(400, error.message))
@@ -280,7 +288,7 @@ app.post('/collections/:collectionId/items', async (req, res, next) => {
280288
if (req.body.collection && req.body.collection !== collectionId) {
281289
next(createError(400, 'Collection resource URI must match collection in body'))
282290
} else {
283-
const collectionRes = await api.getCollection(collectionId, database, req.endpoint)
291+
const collectionRes = await api.getCollection(collectionId, database, req.endpoint, req.query)
284292
if (collectionRes instanceof Error) next(createError(404))
285293
else {
286294
try {
@@ -312,15 +320,14 @@ app.get('/collections/:collectionId/items/:itemId', async (req, res, next) => {
312320
collectionId,
313321
itemId,
314322
database,
315-
req.endpoint
323+
req.endpoint,
324+
req.query
316325
)
317326

318-
if (response instanceof Error) {
319-
if (response.message === 'Item not found') {
320-
next(createError(404))
321-
} else {
322-
next(createError(500))
323-
}
327+
if (response instanceof NotFoundError) {
328+
next(createError(404))
329+
} else if (response instanceof Error) {
330+
next(createError(500))
324331
} else {
325332
res.type('application/geo+json')
326333
res.json(response)
@@ -339,7 +346,7 @@ app.put('/collections/:collectionId/items/:itemId', async (req, res, next) => {
339346
} else if (req.body.id && req.body.id !== itemId) {
340347
next(createError(400, 'Item ID in resource URI must match id in body'))
341348
} else {
342-
const itemRes = await api.getItem(collectionId, itemId, database, req.endpoint)
349+
const itemRes = await api.getItem(collectionId, itemId, database, req.endpoint, req.query)
343350
if (itemRes instanceof Error) next(createError(404))
344351
else {
345352
req.body.collection = collectionId
@@ -372,7 +379,7 @@ app.patch('/collections/:collectionId/items/:itemId', async (req, res, next) =>
372379
} else if (req.body.id && req.body.id !== itemId) {
373380
next(createError(400, 'Item ID in resource URI must match id in body'))
374381
} else {
375-
const itemRes = await api.getItem(collectionId, itemId, database, req.endpoint)
382+
const itemRes = await api.getItem(collectionId, itemId, database, req.endpoint, req.query)
376383
if (itemRes instanceof Error) next(createError(404))
377384
else {
378385
try {
@@ -418,15 +425,13 @@ app.get('/collections/:collectionId/items/:itemId/thumbnail', async (req, res, n
418425
collectionId,
419426
itemId,
420427
database,
428+
req.query
421429
)
422430

423-
if (response instanceof Error) {
424-
if (response.message === 'Item not found'
425-
|| response.message === 'Thumbnail not found') {
426-
next(createError(404))
427-
} else {
428-
next(createError(500))
429-
}
431+
if (response instanceof NotFoundError) {
432+
next(createError(404))
433+
} else if (response instanceof Error) {
434+
next(createError(500))
430435
} else {
431436
res.redirect(response.location)
432437
}

0 commit comments

Comments
 (0)