Skip to content

Commit 39fa181

Browse files
authored
Ensure collection safety on /catalogs route (#562)
**Related Issue(s):** - None **Description:** This PR implements a **"Safety-First" deletion policy** for the Catalogs Extension. It fundamentally changes the behavior of `DELETE` operations to strictly separate **Organization** (Catalogs) from **Content** (Collections). Deleting a catalog now "disbands" the group but **never destroys the underlying data**, preventing accidental loss of large datasets. #### Key Changes: #### 1. Safety Architecture ("Unlink & Adopt") * **Safe Catalog Deletion**: `DELETE /catalogs/{id}` now removes the catalog container but strictly **unlinks** all child collections. If a collection becomes an orphan (no parents left), it is automatically **adopted by the Root**. * **Safe Collection Removal**: `DELETE /catalogs/{id}/collections/{id}` now unlinks the collection from that specific catalog context only. It **never deletes the collection data**, even if it was the only parent. #### 2. Removed Destructive Features * **Removed `cascade` parameter**: The `?cascade=true` option has been removed. It is no longer possible to trigger a recursive data delete via the catalogs route. Intentional data deletion must now be done via the core `/collections/{id}` endpoint. **PR Checklist:** - [x] Code is formatted and linted (run `pre-commit run --all-files`) - [x] Tests pass (run `make test`) - [x] Documentation has been updated to reflect changes, if applicable - [x] Changes are added to the changelog
1 parent 2b8cb54 commit 39fa181

File tree

13 files changed

+159
-167
lines changed

13 files changed

+159
-167
lines changed

CHANGELOG.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1717

1818
### Updated
1919

20+
## [v6.8.1] - 2025-12-15
21+
22+
### Changed
23+
24+
- Implemented a safety-first deletion policy for the catalogs endpoint to prevent accidental data loss. Collections are now never deleted through the catalogs route; they are only unlinked and automatically adopted by the root catalog if they become orphans. Collection data can only be permanently deleted via the explicit `/collections/{collection_id}` DELETE endpoint. This ensures a clear separation between container (catalog) deletion and content (collection/item) deletion, with data always being preserved through the catalogs API.
25+
26+
### Removed
27+
28+
- Removed `cascade` parameter from `DELETE /catalogs/{catalog_id}` endpoint. Collections are no longer deleted when a catalog is deleted; they are unlinked and adopted by root if orphaned.
29+
2030
## [v6.8.0] - 2025-12-15
2131

2232
### Added
2333

2434
- Environment variable `VALIDATE_QUERYABLES` to enable/disable validation of queryables in search/filter requests. When set to `true`, search requests will be validated against the defined queryables, returning an error for any unsupported fields. Defaults to `false` for backward compatibility.[#532](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/532)
2535
- Environment variable `QUERYABLES_CACHE_TTL` to configure the TTL (in seconds) for caching queryables. Default is `1800` seconds (30 minutes) to balance performance and freshness of queryables data. [#532](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/532)
26-
- Added optional `/catalogs` route support to enable federated hierarchical catalog browsing and navigation. [#547](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/547)
36+
- Added optional `/catalogs` route support to enable hierarchical catalog browsing and navigation. [#547](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/547)
2737
- Added DELETE `/catalogs/{catalog_id}/collections/{collection_id}` endpoint to support removing collections from catalogs. When a collection belongs to multiple catalogs, it removes only the specified catalog from the collection's parent_ids. When a collection belongs to only one catalog, the collection is deleted entirely. [#554](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/554)
2838
- Added `parent_ids` internal field to collections to support multi-catalog hierarchies. Collections can now belong to multiple catalogs, with parent catalog IDs stored in this field for efficient querying and management. [#554](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/554)
2939
- Added GET `/catalogs/{catalog_id}/children` endpoint implementing the STAC Children extension for efficient hierarchical catalog browsing. Supports type filtering (?type=Catalog|Collection), pagination, and returns numberReturned/numberMatched counts at the top level. [#558](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/558)
@@ -689,7 +699,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
689699
- Use genexp in execute_search and get_all_collections to return results.
690700
- Added db_to_stac serializer to item_collection method in core.py.
691701

692-
[Unreleased]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.8.0...main
702+
[Unreleased]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.8.1...main
703+
[v6.8.1]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.8.0...v6.8.1
693704
[v6.8.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.7.6...v6.8.0
694705
[v6.7.6]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.7.5...v6.7.6
695706
[v6.7.5]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.7.4...v6.7.5
@@ -730,3 +741,4 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
730741
[v0.3.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v0.2.0...v0.3.0
731742
[v0.2.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v0.1.0...v0.2.0
732743
[v0.1.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v0.1.0
744+

README.md

Lines changed: 50 additions & 20 deletions
Large diffs are not rendered by default.

stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py

Lines changed: 57 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def register(self, app: FastAPI, settings=None) -> None:
126126
response_class=self.response_class,
127127
status_code=204,
128128
summary="Delete Catalog",
129-
description="Delete a catalog. Optionally cascade delete all collections in the catalog.",
129+
description="Delete a catalog. All linked collections are unlinked and adopted by root if orphaned.",
130130
tags=["Catalogs"],
131131
)
132132

@@ -337,22 +337,21 @@ async def get_catalog(self, catalog_id: str, request: Request) -> Catalog:
337337
status_code=404, detail=f"Catalog {catalog_id} not found"
338338
)
339339

340-
async def delete_catalog(
341-
self,
342-
catalog_id: str,
343-
request: Request,
344-
cascade: bool = Query(
345-
False,
346-
description="If true, delete all collections linked to this catalog. If false, only delete the catalog.",
347-
),
348-
) -> None:
349-
"""Delete a catalog.
340+
async def delete_catalog(self, catalog_id: str, request: Request) -> None:
341+
"""Delete a catalog (The Container).
342+
343+
Deletes the Catalog document itself. All linked Collections are unlinked
344+
and adopted by Root if they become orphans. Collection data is NEVER deleted.
345+
346+
Logic:
347+
1. Finds all Collections linked to this Catalog.
348+
2. Unlinks them (removes catalog_id from their parent_ids).
349+
3. If a Collection becomes an orphan, it is adopted by Root.
350+
4. PERMANENTLY DELETES the Catalog document itself.
350351
351352
Args:
352353
catalog_id: The ID of the catalog to delete.
353354
request: Request object.
354-
cascade: If true, delete all collections linked to this catalog.
355-
If false, only delete the catalog.
356355
357356
Returns:
358357
None (204 No Content)
@@ -361,58 +360,42 @@ async def delete_catalog(
361360
HTTPException: If the catalog is not found.
362361
"""
363362
try:
364-
# Get the catalog to verify it exists
363+
# Verify the catalog exists
365364
await self.client.database.find_catalog(catalog_id)
366365

367-
# Use reverse lookup query to find all collections with this catalog in parent_ids.
368-
# This is more reliable than parsing links, as it captures all collections
369-
# regardless of pagination or link truncation.
366+
# Find all collections with this catalog in parent_ids
370367
query_body = {"query": {"term": {"parent_ids": catalog_id}}}
371368
search_result = await self.client.database.client.search(
372369
index=COLLECTIONS_INDEX, body=query_body, size=10000
373370
)
374371
children = [hit["_source"] for hit in search_result["hits"]["hits"]]
375372

376-
# Process each child collection
373+
# Safe Unlink: Remove catalog from all children's parent_ids
374+
# If a child becomes an orphan, adopt it to root
375+
root_id = self.settings.get("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi")
376+
377377
for child in children:
378378
child_id = child.get("id")
379379
try:
380-
if cascade:
381-
# DANGER ZONE: User explicitly requested cascade delete.
382-
# Delete the collection entirely, regardless of other parents.
383-
await self.client.database.delete_collection(child_id)
384-
logger.info(
385-
f"Deleted collection {child_id} as part of cascade delete for catalog {catalog_id}"
386-
)
387-
else:
388-
# SAFE ZONE: Smart Unlink - Remove only this catalog from parent_ids.
389-
# The collection survives and becomes a root-level collection if it has no other parents.
390-
parent_ids = child.get("parent_ids", [])
391-
if catalog_id in parent_ids:
392-
parent_ids.remove(catalog_id)
393-
child["parent_ids"] = parent_ids
394-
395-
# Update the collection in the database
396-
# Note: Catalog links are now dynamically generated, so no need to remove them
397-
await self.client.database.update_collection(
398-
collection_id=child_id,
399-
collection=child,
400-
refresh=False,
380+
parent_ids = child.get("parent_ids", [])
381+
if catalog_id in parent_ids:
382+
parent_ids.remove(catalog_id)
383+
384+
# If orphan, move to root
385+
if len(parent_ids) == 0:
386+
parent_ids.append(root_id)
387+
logger.info(
388+
f"Collection {child_id} adopted by root after catalog deletion."
401389
)
402-
403-
# Log the result
404-
if len(parent_ids) == 0:
405-
logger.info(
406-
f"Collection {child_id} is now a root-level orphan (no parent catalogs)"
407-
)
408-
else:
409-
logger.info(
410-
f"Removed catalog {catalog_id} from collection {child_id}; still belongs to {len(parent_ids)} other catalog(s)"
411-
)
412390
else:
413-
logger.debug(
414-
f"Catalog {catalog_id} not in parent_ids for collection {child_id}"
391+
logger.info(
392+
f"Removed catalog {catalog_id} from collection {child_id}; still belongs to {len(parent_ids)} other catalog(s)"
415393
)
394+
395+
child["parent_ids"] = parent_ids
396+
await self.client.database.update_collection(
397+
collection_id=child_id, collection=child, refresh=False
398+
)
416399
except Exception as e:
417400
error_msg = str(e)
418401
if "not found" in error_msg.lower():
@@ -929,11 +912,11 @@ async def get_catalog_children(
929912
async def delete_catalog_collection(
930913
self, catalog_id: str, collection_id: str, request: Request
931914
) -> None:
932-
"""Delete a collection from a catalog.
915+
"""Delete a collection from a catalog (Unlink only).
933916
934-
If the collection has multiple parent catalogs, only removes this catalog
935-
from the parent_ids. If this is the only parent catalog, deletes the
936-
collection entirely.
917+
Removes the catalog from the collection's parent_ids.
918+
If the collection becomes an orphan (no parents), it is adopted by the Root.
919+
It NEVER deletes the collection data.
937920
938921
Args:
939922
catalog_id: The ID of the catalog.
@@ -959,37 +942,39 @@ async def delete_catalog_collection(
959942
detail=f"Collection {collection_id} does not belong to catalog {catalog_id}",
960943
)
961944

962-
# If the collection has multiple parents, just remove this catalog from parent_ids
963-
if len(parent_ids) > 1:
964-
parent_ids.remove(catalog_id)
965-
collection_db["parent_ids"] = parent_ids
945+
# SAFE UNLINK LOGIC
946+
parent_ids.remove(catalog_id)
966947

967-
# Update the collection in the database
968-
# Note: Catalog links are now dynamically generated, so no need to remove them
969-
await self.client.database.update_collection(
970-
collection_id=collection_id, collection=collection_db, refresh=True
948+
# Check if it is now an orphan (empty list)
949+
if len(parent_ids) == 0:
950+
# Fallback to Root / Landing Page
951+
# You can hardcode 'root' or fetch the ID from settings
952+
root_id = self.settings.get(
953+
"STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"
971954
)
972-
955+
parent_ids.append(root_id)
973956
logger.info(
974-
f"Removed catalog {catalog_id} from collection {collection_id} parent_ids"
957+
f"Collection {collection_id} unlinked from {catalog_id}. Orphaned, so adopted by root ({root_id})."
975958
)
976959
else:
977-
# If this is the only parent, delete the collection entirely
978-
await self.client.database.delete_collection(
979-
collection_id, refresh=True
980-
)
981960
logger.info(
982-
f"Deleted collection {collection_id} (only parent was catalog {catalog_id})"
961+
f"Removed catalog {catalog_id} from collection {collection_id}; still belongs to {len(parent_ids)} other catalog(s)"
983962
)
984963

964+
# Update the collection in the database
965+
collection_db["parent_ids"] = parent_ids
966+
await self.client.database.update_collection(
967+
collection_id=collection_id, collection=collection_db, refresh=True
968+
)
969+
985970
except HTTPException:
986971
raise
987972
except Exception as e:
988973
logger.error(
989-
f"Error deleting collection {collection_id} from catalog {catalog_id}: {e}",
974+
f"Error removing collection {collection_id} from catalog {catalog_id}: {e}",
990975
exc_info=True,
991976
)
992977
raise HTTPException(
993978
status_code=500,
994-
detail=f"Failed to delete collection from catalog: {str(e)}",
979+
detail=f"Failed to remove collection from catalog: {str(e)}",
995980
)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
"""library version."""
2-
__version__ = "6.8.0"
2+
__version__ = "6.8.1"

stac_fastapi/elasticsearch/pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ keywords = [
2828
]
2929
dynamic = ["version"]
3030
dependencies = [
31-
"stac-fastapi-core==6.8.0",
32-
"sfeos-helpers==6.8.0",
31+
"stac-fastapi-core==6.8.1",
32+
"sfeos-helpers==6.8.1",
3333
"elasticsearch[async]~=8.19.1",
3434
"uvicorn~=0.23.0",
3535
"starlette>=0.35.0,<0.36.0",
@@ -48,7 +48,7 @@ dev = [
4848
"httpx>=0.24.0,<0.28.0",
4949
"redis~=6.4.0",
5050
"retry~=0.9.2",
51-
"stac-fastapi-core[redis]==6.8.0",
51+
"stac-fastapi-core[redis]==6.8.1",
5252
]
5353
docs = [
5454
"mkdocs~=1.4.0",
@@ -58,7 +58,7 @@ docs = [
5858
"retry~=0.9.2",
5959
]
6060
redis = [
61-
"stac-fastapi-core[redis]==6.8.0",
61+
"stac-fastapi-core[redis]==6.8.1",
6262
]
6363
server = [
6464
"uvicorn[standard]~=0.23.0",

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@
244244
app_config = {
245245
"title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-elasticsearch"),
246246
"description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-elasticsearch"),
247-
"api_version": os.getenv("STAC_FASTAPI_VERSION", "6.8.0"),
247+
"api_version": os.getenv("STAC_FASTAPI_VERSION", "6.8.1"),
248248
"settings": settings,
249249
"extensions": extensions,
250250
"client": CoreClient(
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
"""library version."""
2-
__version__ = "6.8.0"
2+
__version__ = "6.8.1"

stac_fastapi/opensearch/pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ keywords = [
2828
]
2929
dynamic = ["version"]
3030
dependencies = [
31-
"stac-fastapi-core==6.8.0",
32-
"sfeos-helpers==6.8.0",
31+
"stac-fastapi-core==6.8.1",
32+
"sfeos-helpers==6.8.1",
3333
"opensearch-py~=2.8.0",
3434
"opensearch-py[async]~=2.8.0",
3535
"uvicorn~=0.23.0",
@@ -49,15 +49,15 @@ dev = [
4949
"httpx>=0.24.0,<0.28.0",
5050
"redis~=6.4.0",
5151
"retry~=0.9.2",
52-
"stac-fastapi-core[redis]==6.8.0",
52+
"stac-fastapi-core[redis]==6.8.1",
5353
]
5454
docs = [
5555
"mkdocs~=1.4.0",
5656
"mkdocs-material~=9.0.0",
5757
"pdocs~=1.2.0",
5858
]
5959
redis = [
60-
"stac-fastapi-core[redis]==6.8.0",
60+
"stac-fastapi-core[redis]==6.8.1",
6161
]
6262
server = [
6363
"uvicorn[standard]~=0.23.0",

stac_fastapi/opensearch/stac_fastapi/opensearch/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@
243243
app_config = {
244244
"title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-opensearch"),
245245
"description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-opensearch"),
246-
"api_version": os.getenv("STAC_FASTAPI_VERSION", "6.8.0"),
246+
"api_version": os.getenv("STAC_FASTAPI_VERSION", "6.8.1"),
247247
"settings": settings,
248248
"extensions": extensions,
249249
"client": CoreClient(
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
"""library version."""
2-
__version__ = "6.8.0"
2+
__version__ = "6.8.1"

0 commit comments

Comments
 (0)