From 95522d2022e653fb126ef3ee1b759dba1ec9b29c Mon Sep 17 00:00:00 2001 From: hrodmn Date: Wed, 24 Jun 2026 11:57:30 -0500 Subject: [PATCH 1/3] feat: add multi-tenant catalogs extension to STAC runtime resolves #102 --- README.md | 59 +++- cdk/PgStacInfra.ts | 56 +++- cdk/app.ts | 3 + cdk/config.ts | 59 ++++ cdk/runtimes/eoapi/stac/README.md | 40 ++- cdk/runtimes/eoapi/stac/eoapi/stac/main.py | 150 +++++++-- .../eoapi/stac/eoapi/stac/transactions.py | 9 +- cdk/runtimes/eoapi/stac/pyproject.toml | 2 +- cdk/runtimes/eoapi/stac/tests/test_app.py | 161 +++++++++- cdk/runtimes/eoapi/stac/tests/test_auth.py | 100 +++++- cdk/runtimes/eoapi/stac/uv.lock | 71 ++++- docker-compose.yml | 14 +- scripts/load_demo_stac_catalogs.py | 296 ++++++++++++++++++ test/config.test.ts | 29 ++ test/pgstac-infra.test.ts | 84 ++++- 15 files changed, 1058 insertions(+), 75 deletions(-) create mode 100755 scripts/load_demo_stac_catalogs.py diff --git a/README.md b/README.md index 0ae3287..db695ca 100644 --- a/README.md +++ b/README.md @@ -12,35 +12,76 @@ This repository contains the AWS CDK code (written in typescript) used to deploy Deployment happens through a github workflow manually triggered and defined in `.github/workflows/deploy.yaml`. -## User STAC collection transactions +## User STAC catalogs and transactions -The internal `userSTAC` deployment can now opt into collection-only STAC transactions. The public-facing stack stays on the same MAAP-owned runtime, but remains read-only unless transaction support is explicitly enabled. +The MAAP-owned STAC runtime uses `stac-fastapi-pgstac[catalogs]` 6.3.0. Read-only multi-tenant catalog routes are enabled by default for deployed STAC APIs. Catalog write routes and collection write routes remain explicit opt-ins. -Enable them with: +User STAC catalog configuration: + +- `USER_STAC_CATALOGS_ENABLED=false` disables read-only `/catalogs` routes. +- `USER_STAC_CATALOGS_HIDE_ALTERNATE_PARENTS=true` hides alternate parent links in catalog responses. +- `USER_STAC_CATALOG_TRANSACTIONS_ENABLED=true` enables catalog write routes. This requires catalogs to stay enabled. +- `USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE=basic` selects the supported auth mode. +- `USER_STAC_CATALOG_TRANSACTIONS_AUTH_SECRET_ARN` can point at an existing auth secret. + +Collection-only STAC transactions can still be enabled with: - `USER_STAC_COLLECTION_TRANSACTIONS_ENABLED=true` - `USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE=basic` -When enabled, this CDK stack creates and manages the Secrets Manager secret used for STAC basic auth by default, grants the STAC Lambda read access to it, and publishes the secret ARN to SSM at: +When either collection or catalog transactions are enabled, this CDK stack creates and manages the Secrets Manager secret used for STAC basic auth by default, grants the STAC Lambda read access to it, and publishes the secret ARN to SSM at: - `/maap-eoapi//internal/stac-collection-transaction-auth-secret-arn` -You can still override the secret with `USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN` if you need to point at an existing secret instead. +You can still override the secret with `USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN` or `USER_STAC_CATALOG_TRANSACTIONS_AUTH_SECRET_ARN` if you need to point at an existing secret instead. If both write surfaces are enabled, they must use the same secret in this iteration. The transaction auth secret must be a JSON object with string `username` and `password` fields. +### Local demo data + +After starting the local pgSTAC database, you can load a small demo catalog hierarchy for sample users: + +```bash +docker compose up -d database +./scripts/load_demo_stac_catalogs.py +``` + +The script is standalone and uses an inline `uv` execution header, so it installs `pypgstac[psycopg]` on demand. By default it connects to the local compose database on `127.0.0.1:5439` and creates: + +- `DPS User Catalogs` as a root catalog, containing per-user catalogs for `hrodmn` and `jjfrench` +- `DPS Team Catalogs` as a root catalog, containing the shared `maap-demo-team` catalog +- two synthetic DPS-output collections per user + +Useful options: + +```bash +./scripts/load_demo_stac_catalogs.py --dry-run +./scripts/load_demo_stac_catalogs.py --reset # deletes all existing catalog and collection records first +./scripts/load_demo_stac_catalogs.py --user hrodmn --user jjfrench +./scripts/load_demo_stac_catalogs.py --database-url postgresql://username:password@database:5432/postgis +``` + +The `database` hostname form is useful when running the script from a container attached to the `maap-eoapi` Docker network. + ### What to verify after deployment +For a catalogs-enabled deployment, verify: + +- OpenAPI includes read-only catalog routes such as `GET /catalogs`, `GET /catalogs/{catalog_id}`, and catalog-scoped collection/item reads. +- `GET /` includes `rel="child"` links for listed catalogs so STAC Browser can discover catalog roots. +- catalog write routes are absent unless `USER_STAC_CATALOG_TRANSACTIONS_ENABLED=true`. + For a transaction-enabled internal deployment, verify: -- `GET /conformance` includes `https://api.stacspec.org/v1.0.0/collections/extensions/transaction` -- OpenAPI advertises collection write routes only: +- `GET /conformance` includes `https://api.stacspec.org/v1.0.0/collections/extensions/transaction` when collection transactions are enabled. +- OpenAPI advertises collection write routes only for collection transactions: - `POST /collections` - `PUT /collections/{collection_id}` - `PATCH /collections/{collection_id}` - `DELETE /collections/{collection_id}` -- unauthenticated collection writes return `401` -- authenticated collection writes succeed +- OpenAPI advertises catalog write routes only for catalog transactions, including `POST /catalogs` and `PUT`/`DELETE /catalogs/{catalog_id}`. +- unauthenticated writes return `401` +- authenticated writes succeed - item write routes are absent from the contract and return `404` or `405` rather than exposing item transaction behavior diff --git a/cdk/PgStacInfra.ts b/cdk/PgStacInfra.ts index 94afb19..4053cef 100644 --- a/cdk/PgStacInfra.ts +++ b/cdk/PgStacInfra.ts @@ -91,18 +91,40 @@ export class PgStacInfra extends Stack { }; const transactionsConfig = stacApiConfig.transactions; + const catalogsConfig = stacApiConfig.catalogs ?? { enabled: true }; + const catalogsEnabled = catalogsConfig.enabled !== false; + const catalogTransactionsConfig = catalogsConfig.transactions; + if (catalogTransactionsConfig && !catalogsEnabled) { + throw new Error("STAC catalog transactions require catalogs to be enabled"); + } + if (transactionsConfig && transactionsConfig.authMode !== "basic") { throw new Error( `Unsupported STAC collection transaction auth mode: ${transactionsConfig.authMode}`, ); } + if (catalogTransactionsConfig && catalogTransactionsConfig.authMode !== "basic") { + throw new Error( + `Unsupported STAC catalog transaction auth mode: ${catalogTransactionsConfig.authMode}`, + ); + } + if ( + transactionsConfig && + catalogTransactionsConfig && + transactionsConfig.authSecretArn !== catalogTransactionsConfig.authSecretArn + ) { + throw new Error( + "STAC collection and catalog transactions must use the same auth secret ARN", + ); + } - const transactionAuthSecret = transactionsConfig - ? transactionsConfig.authSecretArn + const writeTransactionsConfig = transactionsConfig ?? catalogTransactionsConfig; + const transactionAuthSecret = writeTransactionsConfig + ? writeTransactionsConfig.authSecretArn ? secretsmanager.Secret.fromSecretCompleteArn( this, "stac-collection-transaction-auth-secret", - transactionsConfig.authSecretArn, + writeTransactionsConfig.authSecretArn, ) : new secretsmanager.Secret( this, @@ -129,7 +151,9 @@ export class PgStacInfra extends Stack { "free_text", "pagination", "collection_search", + ...(catalogsEnabled ? ["catalogs"] : []), ...(transactionsConfig ? ["collection_transaction"] : []), + ...(catalogTransactionsConfig ? ["catalog_transaction"] : []), ]; const stacApiEnv: Record = { @@ -138,11 +162,12 @@ export class PgStacInfra extends Stack { STAC_FASTAPI_DESCRIPTION: `The ${type} STAC API for the [MAAP project](https://maap-project.org)`, STAC_FASTAPI_VERSION: version, ENABLED_EXTENSIONS: stacEnabledExtensions.join(","), - ...(transactionsConfig + ENABLE_CATALOGS_EXTENSION: catalogsEnabled ? "true" : "false", + HIDE_ALTERNATE_PARENTS: catalogsConfig.hideAlternateParents ? "true" : "false", + ...(writeTransactionsConfig ? { - MAAP_TRANSACTION_AUTH_MODE: transactionsConfig.authMode, - MAAP_TRANSACTION_AUTH_SECRET_ARN: - transactionAuthSecret!.secretArn, + MAAP_TRANSACTION_AUTH_MODE: writeTransactionsConfig.authMode, + MAAP_TRANSACTION_AUTH_SECRET_ARN: transactionAuthSecret!.secretArn, } : {}), }; @@ -197,7 +222,7 @@ export class PgStacInfra extends Stack { new ssm.StringParameter(this, "stac-collection-transaction-auth-secret-param", { parameterName: `/maap-eoapi/${stage}/${type}/stac-collection-transaction-auth-secret-arn`, stringValue: transactionAuthSecret.secretArn, - description: `Secrets Manager ARN for MAAP ${type} STAC collection transaction auth (${stage})`, + description: `Secrets Manager ARN for MAAP ${type} STAC transaction auth (${stage})`, }); } @@ -692,12 +717,25 @@ export interface Props extends StackProps { /** * Optional collection transaction support for the STAC API. - * When omitted, the API stays read-only. + * When omitted, collection write routes stay disabled. */ transactions?: { authMode: "basic" | "jwt"; authSecretArn?: string; }; + + /** + * Optional multi-tenant catalog support for the STAC API. + * When omitted, read-only catalog routes are enabled. + */ + catalogs?: { + enabled: boolean; + hideAlternateParents?: boolean; + transactions?: { + authMode: "basic" | "jwt"; + authSecretArn?: string; + }; + }; }; /** diff --git a/cdk/app.ts b/cdk/app.ts index 49ea236..91a1c3a 100644 --- a/cdk/app.ts +++ b/cdk/app.ts @@ -30,6 +30,7 @@ const { userStacCollectionIdRegistry, userStacInboundTopicArns, userStacItemGenRoleArn, + userStacCatalogs, userStacCollectionTransactions, userStacStacApiCustomDomainName, userStacTitilerPgStacApiCustomDomainName, @@ -70,6 +71,7 @@ const coreInfrastructure = new PgStacInfra(app, buildStackName("pgSTAC"), { stacApiConfig: { customDomainName: stacApiCustomDomainName, integrationApiArn: stacApiIntegrationApiArn, + catalogs: { enabled: true }, }, titilerPgstacConfig: { mosaicHost, @@ -109,6 +111,7 @@ const userInfrastructure = new PgStacInfra(app, buildStackName("userSTAC"), { }, stacApiConfig: { customDomainName: userStacStacApiCustomDomainName, + catalogs: userStacCatalogs, transactions: userStacCollectionTransactions, }, titilerPgstacConfig: { diff --git a/cdk/config.ts b/cdk/config.ts index 7c278bc..706ba53 100644 --- a/cdk/config.ts +++ b/cdk/config.ts @@ -5,6 +5,12 @@ export interface CollectionTransactionsConfig { authSecretArn?: string; } +export interface StacCatalogsConfig { + enabled: boolean; + hideAlternateParents?: boolean; + transactions?: CollectionTransactionsConfig; +} + function parseOptionalBooleanEnv(name: string): boolean | undefined { const value = process.env[name]; if (value === undefined || value === "") { @@ -51,6 +57,7 @@ export class Config { readonly userStacCollectionTransactions: | CollectionTransactionsConfig | undefined; + readonly userStacCatalogs: StacCatalogsConfig; constructor() { const requiredVariables = [ @@ -148,6 +155,7 @@ export class Config { process.env.USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME; this.userStacCollectionTransactions = this.parseUserStacCollectionTransactions(); + this.userStacCatalogs = this.parseUserStacCatalogs(); if (process.env.USER_STAC_INBOUND_TOPIC_ARNS) { try { @@ -224,4 +232,55 @@ export class Config { authMode, }; } + + private parseUserStacCatalogs(): StacCatalogsConfig { + const enabled = parseOptionalBooleanEnv("USER_STAC_CATALOGS_ENABLED") ?? true; + const hideAlternateParents = parseOptionalBooleanEnv( + "USER_STAC_CATALOGS_HIDE_ALTERNATE_PARENTS", + ); + const transactionsEnabled = parseOptionalBooleanEnv( + "USER_STAC_CATALOG_TRANSACTIONS_ENABLED", + ); + + if (transactionsEnabled === true && !enabled) { + throw new Error( + "USER_STAC_CATALOG_TRANSACTIONS_ENABLED=true requires USER_STAC_CATALOGS_ENABLED=true", + ); + } + + if (transactionsEnabled !== true) { + return { + enabled, + ...(hideAlternateParents !== undefined && { hideAlternateParents }), + }; + } + + const authMode = process.env.USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE; + if (!authMode) { + throw new Error( + "Must provide USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE when USER_STAC_CATALOG_TRANSACTIONS_ENABLED=true", + ); + } + + if (authMode !== "basic") { + throw new Error( + `Unsupported USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE: ${authMode}. Expected \"basic\".`, + ); + } + + const authSecretArn = process.env.USER_STAC_CATALOG_TRANSACTIONS_AUTH_SECRET_ARN; + + return { + enabled, + ...(hideAlternateParents !== undefined && { hideAlternateParents }), + transactions: authSecretArn + ? { + authMode, + authSecretArn, + } + : { + authMode, + }, + }; + } } diff --git a/cdk/runtimes/eoapi/stac/README.md b/cdk/runtimes/eoapi/stac/README.md index 4677793..5fc7cb3 100644 --- a/cdk/runtimes/eoapi/stac/README.md +++ b/cdk/runtimes/eoapi/stac/README.md @@ -14,7 +14,15 @@ docker compose up --build stac raster database The local compose setup bind-mounts `cdk/runtimes/eoapi/stac/` into the container and runs `uvicorn --reload`, so changes under `cdk/runtimes/eoapi/stac/eoapi/stac/` are picked up without rebuilding the image. Add or override environment variables with `.stac.env`, `.raster.env`, or `.env` as needed. -When you enable collection transactions, the runtime now fails closed unless these env vars are present: +Read-only multi-tenant catalog routes are enabled through `ENABLED_EXTENSIONS=catalogs` and the upstream-compatible `ENABLE_CATALOGS_EXTENSION=true` setting. The local compose default includes read-only catalogs. To try catalog transactions locally, set `STAC_ENABLED_EXTENSIONS` to include `catalog_transaction` as well, for example: + +```bash +STAC_ENABLED_EXTENSIONS=query,sort,fields,filter,free_text,pagination,collection_search,catalogs,collection_transaction,catalog_transaction docker compose up --build stac database +``` + +Catalog transaction routes are separate from collection transaction routes. Enabling `catalogs` alone adds read routes such as `GET /catalogs`, `GET /catalogs/{catalog_id}`, and catalog-scoped collection/item reads. It does not add write routes. + +When you enable collection or catalog transactions, the runtime fails closed unless these env vars are present: - `MAAP_TRANSACTION_AUTH_MODE=basic` - one of: @@ -25,6 +33,17 @@ The secret form is intended for Lambda deployments. The username/password env-va The secret must be a JSON object with `username` and `password` string fields. +### Loading local demo data + +From the repository root, load a small catalogs-extension demo into the local pgSTAC database with: + +```bash +docker compose up -d database +./scripts/load_demo_stac_catalogs.py +``` + +The script uses `pypgstac[psycopg]` to load DPS user/team root catalogs, per-user catalogs for `hrodmn` and `jjfrench`, a shared team catalog, and synthetic DPS-output collections linked into those catalogs. Use `--dry-run` to inspect the records first or `--reset` to delete all existing catalog and collection records before reloading the demo records. + ### Running tests From this directory, run: @@ -48,6 +67,8 @@ The local STAC service uses the same pgSTAC-style environment variables already - `DB_MIN_CONN_SIZE` - `DB_MAX_CONN_SIZE` - `ENABLED_EXTENSIONS` +- `ENABLE_CATALOGS_EXTENSION` +- `HIDE_ALTERNATE_PARENTS` - `TITILER_ENDPOINT` - `MAAP_TRANSACTION_AUTH_MODE` - `MAAP_TRANSACTION_AUTH_USERNAME` @@ -67,15 +88,24 @@ The local raster service also expects mosaic settings, so the compose file provi - Lambda builds should continue using the default `lambda` target without `uvicorn`. - The local compose stack runs the MAAP app via `uvicorn eoapi.stac.main:app --reload --reload-dir /workspace/eoapi/stac`. - The Lambda runtime entrypoint is `eoapi.stac.handler.handler` and preserves the upstream SnapStart-aware connection lifecycle. -- Collection write-route auth is attached with FastAPI security dependencies on `POST /collections` plus `PUT`, `PATCH`, and `DELETE /collections/{collection_id}`. +- Collection write-route auth is attached through the transaction extension `route_dependencies` hook on `POST /collections` plus `PUT`, `PATCH`, and `DELETE /collections/{collection_id}`. +- Catalog write-route auth is attached by a narrow local adapter around the upstream `CatalogsTransactionExtension` because version 0.4.0 does not expose a `route_dependencies` constructor hook. - Those dependencies are declared as HTTP Basic auth in OpenAPI, so Swagger UI shows the protected routes with the built-in auth flow instead of relying only on the browser challenge popup. ### Post-deploy smoke checks +For a catalogs-enabled deployment, verify: + +- OpenAPI includes read routes such as `GET /catalogs`, `GET /catalogs/{catalog_id}`, `GET /catalogs/{catalog_id}/collections`, and `GET /catalogs/{catalog_id}/collections/{collection_id}/items`. +- `GET /` includes a `rel="catalogs"` link and `rel="child"` links for listed catalogs so STAC Browser can discover catalog roots. +- `GET /catalogs/{catalog_id}/conformance` advertises catalog conformance classes. +- Catalog write routes are absent unless `catalog_transaction` is enabled. + For a transaction-enabled deployment, verify: -- `GET /conformance` advertises only the collection transaction conformance class. +- `GET /conformance` advertises only the collection transaction conformance class when collection transactions are enabled. - OpenAPI includes collection write routes and does not advertise item transaction write routes. -- `POST /collections` without auth returns `401` with `WWW-Authenticate: Basic`. -- Authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests against `/collections` succeed when the backing pgSTAC deployment is healthy. +- OpenAPI includes catalog write routes only when `catalog_transaction` is enabled. +- `POST /collections` and `POST /catalogs` without auth return `401` with `WWW-Authenticate: Basic` when their transaction extensions are enabled. +- Authenticated write requests succeed when the backing pgSTAC deployment is healthy. - Item write routes such as `POST /collections/{collection_id}/items` remain unavailable. diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/main.py b/cdk/runtimes/eoapi/stac/eoapi/stac/main.py index 5f34f72..99edd86 100644 --- a/cdk/runtimes/eoapi/stac/eoapi/stac/main.py +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/main.py @@ -4,12 +4,15 @@ import os from contextlib import asynccontextmanager -from typing import cast +from typing import Any, cast +from urllib.parse import urljoin +import attr from brotli_asgi import BrotliMiddleware from eoapi.stac.auth import build_transaction_route_dependencies from eoapi.stac.transactions import CollectionTransactionExtension from fastapi import APIRouter, FastAPI +from fastapi.params import Depends from stac_fastapi.api.app import StacApi from stac_fastapi.api.middleware import ProxyHeaderMiddleware from stac_fastapi.api.models import ( @@ -20,8 +23,7 @@ create_post_request_model, create_request_model, ) -from stac_fastapi.api.routes import Scope -from stac_fastapi.extensions.core import ( +from stac_fastapi.extensions import ( CollectionSearchExtension, CollectionSearchFilterExtension, FieldsExtension, @@ -31,25 +33,37 @@ SortExtension, TokenPaginationExtension, ) -from stac_fastapi.extensions.core.fields import FieldsConformanceClasses -from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses -from stac_fastapi.extensions.core.query import QueryConformanceClasses -from stac_fastapi.extensions.core.sort import SortConformanceClasses +from stac_fastapi.extensions.fields import FieldsConformanceClasses +from stac_fastapi.extensions.free_text import FreeTextConformanceClasses +from stac_fastapi.extensions.query import QueryConformanceClasses +from stac_fastapi.extensions.sort import SortConformanceClasses from stac_fastapi.pgstac.config import Settings from stac_fastapi.pgstac.core import CoreCrudClient, health_check from stac_fastapi.pgstac.db import close_db_connection, connect_to_db -from stac_fastapi.pgstac.extensions import FreeTextExtension, QueryExtension +from stac_fastapi.pgstac.extensions import ( + CatalogsDatabaseLogic, + FreeTextExtension, + QueryExtension, +) +from stac_fastapi.pgstac.extensions.catalogs.catalogs_client import CatalogsClient from stac_fastapi.pgstac.extensions.filter import FiltersClient from stac_fastapi.pgstac.transactions import TransactionsClient from stac_fastapi.pgstac.types.search import PgstacSearch from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.requests import get_base_url from stac_fastapi.types.search import APIRequest +from stac_fastapi_catalogs_extension import ( + CatalogsExtension, + CatalogsTransactionExtension, +) from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware settings = Settings() COLLECTION_TRANSACTION_EXTENSION = "collection_transaction" +CATALOGS_EXTENSION = "catalogs" +CATALOG_TRANSACTION_EXTENSION = "catalog_transaction" SEARCH_EXTENSIONS_MAP: dict[str, ApiExtension] = { "query": QueryExtension(), @@ -85,19 +99,73 @@ *COLLECTION_SEARCH_EXTENSIONS_MAP.keys(), *ITEM_COLLECTION_EXTENSIONS_MAP.keys(), "collection_search", + CATALOGS_EXTENSION, } KNOWN_EXTENSIONS: set[str] = { *DEFAULT_ENABLED_EXTENSIONS, COLLECTION_TRANSACTION_EXTENSION, + CATALOG_TRANSACTION_EXTENSION, } -TRANSACTION_ROUTE_SCOPES: list[Scope] = [ - {"path": "/collections", "method": "POST"}, - {"path": "/collections/{collection_id}", "method": "PUT"}, - {"path": "/collections/{collection_id}", "method": "PATCH"}, - {"path": "/collections/{collection_id}", "method": "DELETE"}, -] + +@attr.s +class MaapCoreCrudClient(CoreCrudClient): + """MAAP core client with catalog-aware landing page links.""" + + catalogs_client: CatalogsClient | None = attr.ib(default=None, kw_only=True) + + async def landing_page(self, **kwargs: Any) -> Any: + """Return the STAC landing page with catalogs exposed as children.""" + landing_page = await super().landing_page(**kwargs) + + if not self.extension_is_enabled("CatalogsExtension"): + return landing_page + + if self.catalogs_client is None: + return landing_page + + request = kwargs["request"] + base_url = get_base_url(request) + for link in landing_page["links"]: + if link.get("rel") == "catalogs": + link["href"] = urljoin(base_url, "catalogs") + + catalogs, _, _ = await self.catalogs_client.database.get_all_catalogs( + token=None, + limit=1000, + request=request, + ) + + for catalog in catalogs: + catalog_id = catalog.get("id") + if not catalog_id: + continue + if catalog.get("parent_ids"): + continue + + landing_page["links"].append( + { + "rel": "child", + "type": "application/json", + "title": catalog.get("title", catalog_id), + "href": urljoin(base_url, f"catalogs/{catalog_id}"), + } + ) + + return landing_page + + +@attr.s +class AuthenticatedCatalogsTransactionExtension(CatalogsTransactionExtension): + """Catalog transaction extension adapter with route-level dependencies.""" + + route_dependencies: list[Depends] = attr.ib(factory=list, kw_only=True) + + def register(self, app: FastAPI) -> None: + """Register catalog write routes with auth dependencies on each route.""" + self.router.dependencies = list(self.route_dependencies) + super().register(app) def parse_enabled_extensions(raw_value: str | None) -> set[str]: @@ -136,14 +204,14 @@ def _build_middlewares() -> list[Middleware]: ] -def _build_lifespan(with_collection_transactions: bool): +def _build_lifespan(with_write_transactions: bool): """Build the FastAPI lifespan for local app execution.""" @asynccontextmanager async def lifespan(app: FastAPI): await connect_to_db( app, - add_write_connection_pool=with_collection_transactions, + add_write_connection_pool=with_write_transactions, ) yield await close_db_connection(app) @@ -156,7 +224,7 @@ def create_app( enabled_extensions: set[str] | None = None, connect_to_database: bool = True, ) -> FastAPI: - """Create the MAAP STAC app with optional collection transactions.""" + """Create the MAAP STAC app with optional catalog and collection transactions.""" resolved_extensions = ( enabled_extensions if enabled_extensions is not None @@ -166,15 +234,27 @@ def create_app( with_collection_transactions = ( COLLECTION_TRANSACTION_EXTENSION in resolved_extensions ) - transaction_route_dependencies = [] + with_catalogs = ( + CATALOGS_EXTENSION in resolved_extensions or settings.enable_catalogs_extension + ) + with_catalog_transactions = CATALOG_TRANSACTION_EXTENSION in resolved_extensions + with_write_transactions = with_collection_transactions or with_catalog_transactions + transaction_route_dependencies: list[Depends] = [] + catalogs_client: CatalogsClient | None = None - if with_collection_transactions: + if with_catalog_transactions and not with_catalogs: + raise ValueError("catalog_transaction requires catalogs in ENABLED_EXTENSIONS") + + if with_write_transactions: transaction_route_dependencies = build_transaction_route_dependencies() + + if with_collection_transactions: application_extensions.append( CollectionTransactionExtension( client=TransactionsClient(), settings=settings, response_class=JSONResponse, + route_dependencies=transaction_route_dependencies, ) ) @@ -221,6 +301,26 @@ def create_app( collections_get_request_model = collection_search_extension.GET application_extensions.append(collection_search_extension) + if with_catalogs: + catalogs_client = CatalogsClient(database=CatalogsDatabaseLogic()) + application_extensions.append( + CatalogsExtension( + client=catalogs_client, + settings={"enable_response_models": settings.enable_response_models}, + hide_alternate_parents=settings.hide_alternate_parents, + ) + ) + if with_catalog_transactions: + application_extensions.append( + AuthenticatedCatalogsTransactionExtension( + client=catalogs_client, + settings={ + "enable_response_models": settings.enable_response_models + }, + route_dependencies=transaction_route_dependencies, + ) + ) + api = StacApi( app=FastAPI( openapi_url=settings.openapi_url, @@ -231,7 +331,7 @@ def create_app( version=settings.stac_fastapi_version, description=settings.stac_fastapi_description, lifespan=( - _build_lifespan(with_collection_transactions) + _build_lifespan(with_write_transactions) if connect_to_database else None ), @@ -239,7 +339,10 @@ def create_app( router=APIRouter(prefix=settings.prefix_path), settings=settings, extensions=application_extensions, - client=CoreCrudClient(pgstac_search_model=post_request_model), # type: ignore[arg-type] + client=MaapCoreCrudClient( + pgstac_search_model=post_request_model, + catalogs_client=catalogs_client, + ), # type: ignore[arg-type] response_class=JSONResponse, items_get_request_model=items_get_request_model, search_get_request_model=get_request_model, @@ -248,11 +351,6 @@ def create_app( middlewares=_build_middlewares(), health_check=health_check, # type: ignore[arg-type] ) - if transaction_route_dependencies: - api.add_route_dependencies( - scopes=TRANSACTION_ROUTE_SCOPES, - dependencies=transaction_route_dependencies, - ) return api.app diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py b/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py index 2290ab5..31892d0 100644 --- a/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py @@ -1,13 +1,15 @@ """Collection-only transaction extension for the MAAP STAC runtime.""" -from typing import Any +from collections.abc import Sequence + +from fastapi.params import Depends import attr from fastapi import APIRouter, FastAPI from starlette.responses import Response from stac_fastapi.api.models import JSONResponse -from stac_fastapi.extensions.core.transaction import ( +from stac_fastapi.extensions.transaction import ( AsyncBaseTransactionsClient, TransactionConformanceClasses, TransactionExtension, @@ -27,12 +29,11 @@ class CollectionTransactionExtension(TransactionExtension): schema_href: str | None = attr.ib(default=None) router: APIRouter = attr.ib(factory=APIRouter) response_class: type[Response] = attr.ib(default=JSONResponse) - route_dependencies: list[Any] = attr.ib(factory=list) + route_dependencies: Sequence[Depends] | None = attr.ib(default=None) def register(self, app: FastAPI) -> None: """Register collection transaction routes with the target app.""" self.router.prefix = app.state.router_prefix - self.router.dependencies = list(self.route_dependencies) self.register_create_collection() self.register_update_collection() self.register_patch_collection() diff --git a/cdk/runtimes/eoapi/stac/pyproject.toml b/cdk/runtimes/eoapi/stac/pyproject.toml index f3da87b..5d47772 100644 --- a/cdk/runtimes/eoapi/stac/pyproject.toml +++ b/cdk/runtimes/eoapi/stac/pyproject.toml @@ -18,7 +18,7 @@ dynamic = ["version"] dependencies = [ "mangum==0.19", "pydantic-settings>=2,<3", - "stac-fastapi-pgstac>=6.2,<6.3", + "stac-fastapi-pgstac[catalogs]==6.3.0", "starlette-cramjam>=0.4,<0.5", ] diff --git a/cdk/runtimes/eoapi/stac/tests/test_app.py b/cdk/runtimes/eoapi/stac/tests/test_app.py index d278b50..7f2b5bf 100644 --- a/cdk/runtimes/eoapi/stac/tests/test_app.py +++ b/cdk/runtimes/eoapi/stac/tests/test_app.py @@ -6,7 +6,13 @@ from fastapi.testclient import TestClient from eoapi.stac import auth -from eoapi.stac.main import COLLECTION_TRANSACTION_EXTENSION, create_app, parse_enabled_extensions +from eoapi.stac.main import ( + CATALOG_TRANSACTION_EXTENSION, + CATALOGS_EXTENSION, + COLLECTION_TRANSACTION_EXTENSION, + create_app, + parse_enabled_extensions, +) @pytest.fixture(autouse=True) @@ -26,7 +32,12 @@ def collection_transaction_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Test monkeypatch.setenv("MAAP_TRANSACTION_AUTH_PASSWORD", "builder") auth.reset_transaction_auth_state() app = create_app( - enabled_extensions={"query", "sort", "collection_search", COLLECTION_TRANSACTION_EXTENSION}, + enabled_extensions={ + "query", + "sort", + "collection_search", + COLLECTION_TRANSACTION_EXTENSION, + }, connect_to_database=False, ) with TestClient(app) as client: @@ -34,7 +45,7 @@ def collection_transaction_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Test def test_read_only_app_omits_collection_transaction_routes() -> None: - """The default app should stay read-only when collection transactions are disabled.""" + """The app should stay read-only when collection transactions are disabled.""" app = create_app( enabled_extensions={"query", "sort", "collection_search"}, connect_to_database=False, @@ -52,6 +63,150 @@ def test_read_only_app_omits_collection_transaction_routes() -> None: assert set(openapi["paths"]["/collections/{collection_id}/items/{item_id}"].keys()) == {"get"} +def test_catalog_routes_are_enabled_by_default() -> None: + """Default extension parsing should include read-only catalog routes.""" + app = create_app(connect_to_database=False) + openapi = app.openapi() + + assert "/catalogs" in openapi["paths"] + assert set(openapi["paths"]["/catalogs"].keys()) == {"get"} + assert "/catalogs/{catalog_id}" in openapi["paths"] + assert set(openapi["paths"]["/catalogs/{catalog_id}"].keys()) == {"get"} + assert "/catalogs/{catalog_id}/collections/{collection_id}/items" in openapi["paths"] + assert "post" not in openapi["paths"]["/catalogs"] + + +def test_landing_page_lists_catalogs_as_child_links( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Catalogs should be exposed as child links for STAC Browser discovery.""" + + async def fake_get_all_catalogs(self, token, limit, request, sort=None): + return ( + [ + {"id": "maap-demo", "title": "MAAP Demo Catalog"}, + {"id": "user-hrodmn", "title": "hrodmn DPS Outputs"}, + ], + 2, + None, + ) + + monkeypatch.setattr( + "stac_fastapi.pgstac.extensions.catalogs.catalogs_database_logic." + "CatalogsDatabaseLogic.get_all_catalogs", + fake_get_all_catalogs, + ) + app = create_app(enabled_extensions={CATALOGS_EXTENSION}, connect_to_database=False) + + with TestClient(app) as client: + response = client.get("/") + + assert response.status_code == 200 + links = response.json()["links"] + assert next(link for link in links if link["rel"] == "catalogs")["href"] == ( + "http://testserver/catalogs" + ) + assert { + (link["rel"], link.get("title"), link["href"]) + for link in links + if link["rel"] == "child" + } == { + ("child", "MAAP Demo Catalog", "http://testserver/catalogs/maap-demo"), + ("child", "hrodmn DPS Outputs", "http://testserver/catalogs/user-hrodmn"), + } + + +def test_catalog_routes_can_be_disabled() -> None: + """Explicit extension configuration should be able to omit catalogs.""" + app = create_app( + enabled_extensions={"query", "sort", "collection_search"}, + connect_to_database=False, + ) + openapi = app.openapi() + + assert all(not path.startswith("/catalogs") for path in openapi["paths"]) + + +def test_catalog_transactions_are_opt_in() -> None: + """Read-only catalogs should not expose catalog write routes.""" + app = create_app( + enabled_extensions={CATALOGS_EXTENSION}, + connect_to_database=False, + ) + openapi = app.openapi() + + assert set(openapi["paths"]["/catalogs"].keys()) == {"get"} + assert set(openapi["paths"]["/catalogs/{catalog_id}"].keys()) == {"get"} + assert set(openapi["paths"]["/catalogs/{catalog_id}/collections"].keys()) == { + "get" + } + + +def test_catalog_transaction_routes_require_catalogs() -> None: + """Catalog write routes should fail closed when catalogs are disabled.""" + with pytest.raises(ValueError, match="catalog_transaction requires catalogs"): + create_app( + enabled_extensions={CATALOG_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + + +def test_catalog_transaction_app_registers_catalog_write_routes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Catalog transactions should register the documented catalog write routes.""" + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "basic") + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", raising=False) + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_USERNAME", "bob") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_PASSWORD", "builder") + auth.reset_transaction_auth_state() + app = create_app( + enabled_extensions={CATALOGS_EXTENSION, CATALOG_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + openapi = app.openapi() + + assert set(openapi["paths"]["/catalogs"].keys()) == {"get", "post"} + assert set(openapi["paths"]["/catalogs/{catalog_id}"].keys()) == { + "get", + "put", + "delete", + } + assert set(openapi["paths"]["/catalogs/{catalog_id}/collections"].keys()) == { + "get", + "post", + } + assert set( + openapi["paths"]["/catalogs/{catalog_id}/collections/{collection_id}"].keys() + ) == {"get", "put", "delete"} + assert set(openapi["paths"]["/catalogs/{catalog_id}/catalogs"].keys()) == { + "get", + "post", + } + assert set( + openapi["paths"]["/catalogs/{catalog_id}/catalogs/{sub_catalog_id}"].keys() + ) == {"delete"} + assert openapi["paths"]["/catalogs"]["post"]["security"] == [ + {"HTTPBasic": []} + ] + assert "security" not in openapi["paths"]["/catalogs"]["get"] + assert any( + "transaction" in item for item in app.state.catalogs_conformance_classes + ) + + +def test_catalog_conformance_is_read_only_without_catalog_transactions() -> None: + """Catalog transaction conformance should be absent in read-only mode.""" + app = create_app( + enabled_extensions={CATALOGS_EXTENSION}, + connect_to_database=False, + ) + + conformance_classes = app.state.catalogs_conformance_classes + assert conformance_classes + assert all("transaction" not in item for item in conformance_classes) + + def test_collection_transaction_app_registers_collection_only_routes( collection_transaction_app: TestClient, ) -> None: diff --git a/cdk/runtimes/eoapi/stac/tests/test_auth.py b/cdk/runtimes/eoapi/stac/tests/test_auth.py index 7b316d8..93e0547 100644 --- a/cdk/runtimes/eoapi/stac/tests/test_auth.py +++ b/cdk/runtimes/eoapi/stac/tests/test_auth.py @@ -11,7 +11,12 @@ from pydantic import ValidationError from eoapi.stac import auth -from eoapi.stac.main import COLLECTION_TRANSACTION_EXTENSION, create_app +from eoapi.stac.main import ( + CATALOGS_EXTENSION, + CATALOG_TRANSACTION_EXTENSION, + COLLECTION_TRANSACTION_EXTENSION, + create_app, +) @pytest.fixture(autouse=True) @@ -128,6 +133,99 @@ def test_collection_write_routes_receive_transaction_auth_dependency( assert route.dependencies[0].dependency == auth.require_transaction_auth +@pytest.fixture +def catalog_transaction_app( + monkeypatch: pytest.MonkeyPatch, + basic_auth_env_credentials: None, +) -> Iterator[TestClient]: + """Build a catalog transaction-enabled app using env-provided credentials.""" + app = create_app( + enabled_extensions={CATALOGS_EXTENSION, CATALOG_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + with TestClient(app) as client: + yield client + + +def test_catalog_transaction_routes_require_auth( + catalog_transaction_app: TestClient, +) -> None: + """Catalog transaction routes should challenge unauthenticated requests.""" + response = catalog_transaction_app.post("/catalogs", json={}) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Basic" + + +def test_catalog_transaction_routes_reject_invalid_auth( + catalog_transaction_app: TestClient, +) -> None: + """Catalog transaction routes should reject invalid credentials.""" + response = catalog_transaction_app.post( + "/catalogs", + json={}, + auth=("alice", "wonderland"), + ) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Basic" + + +def test_catalog_read_routes_do_not_require_transaction_auth( + catalog_transaction_app: TestClient, +) -> None: + """Catalog read routes should not inherit transaction auth dependencies.""" + catalogs_get_route = next( + route + for route in catalog_transaction_app.app.routes + if getattr(route, "path", None) == "/catalogs" + and "GET" in getattr(route, "methods", set()) + ) + + assert catalogs_get_route.dependencies == [] + + +def test_catalog_write_routes_receive_transaction_auth_dependency( + catalog_transaction_app: TestClient, +) -> None: + """Catalog write routes should receive the auth dependency.""" + write_methods_by_path = { + "/catalogs": {"POST"}, + "/catalogs/{catalog_id}": {"PUT", "DELETE"}, + "/catalogs/{catalog_id}/collections": {"POST"}, + "/catalogs/{catalog_id}/collections/{collection_id}": {"PUT", "DELETE"}, + "/catalogs/{catalog_id}/catalogs": {"POST"}, + "/catalogs/{catalog_id}/catalogs/{sub_catalog_id}": {"DELETE"}, + } + protected_routes = [ + route + for route in catalog_transaction_app.app.routes + if getattr(route, "path", None) in write_methods_by_path + and getattr(route, "methods", set()) + & write_methods_by_path[getattr(route, "path")] + ] + + assert len(protected_routes) == 8 + for route in protected_routes: + assert len(route.dependencies) == 1 + assert route.dependencies[0].dependency == auth.require_transaction_auth + + +def test_catalog_write_openapi_security_matches_collection_writes( + catalog_transaction_app: TestClient, +) -> None: + """Catalog write operations should advertise HTTP Basic auth in OpenAPI.""" + openapi = catalog_transaction_app.app.openapi() + + assert openapi["paths"]["/catalogs"]["post"]["security"] == [ + {"HTTPBasic": []} + ] + assert openapi["paths"]["/catalogs/{catalog_id}"]["put"]["security"] == [ + {"HTTPBasic": []} + ] + assert "security" not in openapi["paths"]["/catalogs"]["get"] + + def test_transaction_enabled_app_accepts_secret_manager_credentials( monkeypatch: pytest.MonkeyPatch, basic_auth_secret_env: None, diff --git a/cdk/runtimes/eoapi/stac/uv.lock b/cdk/runtimes/eoapi/stac/uv.lock index 4d96eb5..746e957 100644 --- a/cdk/runtimes/eoapi/stac/uv.lock +++ b/cdk/runtimes/eoapi/stac/uv.lock @@ -236,7 +236,7 @@ source = { editable = "." } dependencies = [ { name = "mangum" }, { name = "pydantic-settings" }, - { name = "stac-fastapi-pgstac" }, + { name = "stac-fastapi-pgstac", extra = ["catalogs"] }, { name = "starlette-cramjam" }, ] @@ -250,7 +250,7 @@ dev = [ requires-dist = [ { name = "mangum", specifier = "==0.19" }, { name = "pydantic-settings", specifier = ">=2,<3" }, - { name = "stac-fastapi-pgstac", specifier = ">=6.2,<6.3" }, + { name = "stac-fastapi-pgstac", extras = ["catalogs"], specifier = "==6.3.0" }, { name = "starlette-cramjam", specifier = ">=0.4,<0.5" }, ] @@ -365,11 +365,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/cc/87033e41f13fd4bdc003248a6fe65e880c406581605de7d2234c15a0317b/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:0249bcfbdfca7bd41103bb162c3f51af200d430ff3203f9dced35c62ec5ecc72", size = 511151, upload-time = "2025-10-27T19:31:44.772Z" }, { url = "https://files.pythonhosted.org/packages/ca/27/5c9bcebf946842d8289f1591407b3a1387541eb220cf27c9b32378037f03/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3a96e9b0a3be6ec2064f8556406fc4f225237fcaed1c118837a0ac9be2e62692", size = 437101, upload-time = "2025-10-27T19:31:55.632Z" }, { url = "https://files.pythonhosted.org/packages/ce/53/558519cd9b2888ef2859bef96d7338930377f3f629ec0f3b4df242083ec7/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e4add7c611aae45e17eebda71f5d5d918dbb5515f4cbf33a3e8635a915f9b94", size = 408715, upload-time = "2025-10-27T19:32:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/72d8280bc2adc937162283ddde61286438f87d993b047db575100c0b9a60/hydraters-0.1.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4d58ce6e5daa32f031306d7e141bf966702b590fb51fdfc70e7a0b69fac4f5de", size = 215054, upload-time = "2026-05-27T15:31:32.497Z" }, { url = "https://files.pythonhosted.org/packages/60/18/f5df44b3883295c342f8ba8bb7478ce36bd8de0513bc5c9d2aafa517dd88/hydraters-0.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:015770cba7cee9899f15fafc9486b77293c65dfd091da167d529546c8f2a4eee", size = 212845, upload-time = "2025-10-27T19:31:24.147Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d49ce1ec0e6bbd45f6978c6208a53a9f02c0f2a82a76f3dba47717e507b7/hydraters-0.1.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dac8cf51e7bbadbfd94b87a023ad2f70e65c502bc82a8229aadaae1d08319589", size = 240388, upload-time = "2026-05-27T15:31:15.351Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/e663b2ead344e1b91c15acb9eea71f57cb891fb23110acf355d1598ce082/hydraters-0.1.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6319f37972bf3fb63741b3a1bf3fddcaf42496f0249bce8692fc8f5da0dba304", size = 245641, upload-time = "2026-05-27T15:31:19.103Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f4/055365c2730c4adf68b0e7d15645a64a4a0448fae56a8ed957b1da2be159/hydraters-0.1.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95367bad9fd2d3805412186d6512a5f5c9bb84c83ee208e5771e56dd82a6fea", size = 355334, upload-time = "2026-05-27T15:31:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3c/8e3a87e6e6bd500188fd23acbc4f2cc1a7f5582ed47473de39b7c607e10c/hydraters-0.1.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9893aa55e25ef1dc6bf13cd0ad7286e8a40138f67d61aee3f101e88f1e036083", size = 261409, upload-time = "2026-05-27T15:31:25.391Z" }, { url = "https://files.pythonhosted.org/packages/4a/65/6f775dd489fb85d5c2dbb9f50f7570130753b6a66b0263e9bc5dc0e42246/hydraters-0.1.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab670257e094d42e7cee1886109ea5af8ccb40eb4a3e8fc574550cad864276c2", size = 246841, upload-time = "2025-10-27T19:31:15.101Z" }, { url = "https://files.pythonhosted.org/packages/73/c1/0f2adcb91f0066b2ddcb59922adb2423a6b54d99d8c9169fea3f246e3082/hydraters-0.1.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0ed6a9760a7c5dd9323b354d26fc93b7819257f072300897efd37b518babbb9b", size = 257948, upload-time = "2025-10-27T19:31:05.641Z" }, + { url = "https://files.pythonhosted.org/packages/b6/dd/af7af983f1b7e6b77b0d340bb984d585b0d86c85eee03ac21cc4ea031190/hydraters-0.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:87d6b59b19a659d05385a80b141e55d06250786f5211f4d05580511dcfb99f5f", size = 416697, upload-time = "2026-05-27T15:31:34.176Z" }, + { url = "https://files.pythonhosted.org/packages/c9/22/1287974d201262202ea82940f3523b2bc4d507db7c3a6f97f83b7fb7e3db/hydraters-0.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d27f9f37c6ab7f1fbd286f7cc136c292c285b0109a12a1a9acbe25fbdd444e6e", size = 520655, upload-time = "2026-05-27T15:31:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/af/21/9fce9f979452bcaf27153611616b510505d3dc7057db8d0a057395da74b2/hydraters-0.1.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5122acd52d97282a974d6fbef64bf8c8823082b39a26f7102bfdcbad9dcdfa3f", size = 477859, upload-time = "2026-05-27T15:31:40.335Z" }, + { url = "https://files.pythonhosted.org/packages/08/77/4c5dd49906f4212a6878424d575056566fcb27f017b79295a7f2254c6d3b/hydraters-0.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5edfe693599efa0bea4501534a34af5bcc572b6f791641f366d934be4d8511ef", size = 446221, upload-time = "2026-05-27T15:31:43.304Z" }, { url = "https://files.pythonhosted.org/packages/9e/37/c894a1e3cc2c19ac92d401957d703a620f726d2bab32c376c05c5ce3c75a/hydraters-0.1.3-cp314-cp314-win32.whl", hash = "sha256:be4492c6ed1d96cfa2cbc76b8e63163b878c51165c53712033906fb4539fd63a", size = 104209, upload-time = "2025-10-27T19:32:20.289Z" }, { url = "https://files.pythonhosted.org/packages/08/78/0ab4d10a51f512fa607501b67b1db53389465457b65cd275891537aebb7c/hydraters-0.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:0475e847adad810228f544da7b5e3ae9ab5c49e91f6fe37d39e47ac5e847a426", size = 109215, upload-time = "2025-10-27T19:32:17.724Z" }, + { url = "https://files.pythonhosted.org/packages/66/36/73e47d9df99e748aa5c102d372dd980ac01acaaf2bb2caa236031eb37c3d/hydraters-0.1.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebfc1c62578cddd4e3a4885c9decdc8e1c4be939c912bbb3c3f076894dc2a077", size = 238978, upload-time = "2026-05-27T15:31:17.02Z" }, + { url = "https://files.pythonhosted.org/packages/80/89/f0d24892ccfcb7995b3822b0c7fd4b3c00c23eb432da233b944d3ff0a920/hydraters-0.1.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81bae487b7d9e0d22ec9786121a157cf0bd2b548ac4133bf60c56fc79b71db37", size = 244014, upload-time = "2026-05-27T15:31:20.07Z" }, + { url = "https://files.pythonhosted.org/packages/16/13/9448010fbf3b04a3c36e8cac75c154635d9620b45301c0e8359cf5453bb5/hydraters-0.1.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29e08aad3563fb67f04c37f6d5c659cc19f08b1f1e78078714536df86a0db777", size = 357545, upload-time = "2026-05-27T15:31:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/51/da/919eb7244d3c50a419c9d7144dc16c32381ff6bf81091c2f687d47b0a45f/hydraters-0.1.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1f4cc518aa0f4ba61a1a7e8d89f51634dd3eafc2bbbb97f42b720693b79c0f6", size = 259992, upload-time = "2026-05-27T15:31:26.484Z" }, + { url = "https://files.pythonhosted.org/packages/5f/0e/7e5e4698c92b01117c04b08c886d48854445211b719d6c9e48c2bf4aec52/hydraters-0.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e881f653a171c70ed399634f18df009ff577a5bf53fb0842076aeea6a1f93533", size = 414898, upload-time = "2026-05-27T15:31:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/6857e0a70c0d84078856f1c30aceece4ed435de270a260ca19f19d8e01b6/hydraters-0.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9fc1959a5c4c0d99f86512c474386f6a730dff71f82568298119444338805c09", size = 518992, upload-time = "2026-05-27T15:31:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/60/da/1c29c6c14dd57a82cd32a4d60474a90d770ec5c5962d70c8ea0fbf4d03a1/hydraters-0.1.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e1b616ec2435ff954b74d71ff5a0fd57631bdbb7b92373c6bc2c52e58b5940a9", size = 477171, upload-time = "2026-05-27T15:31:41.425Z" }, + { url = "https://files.pythonhosted.org/packages/76/e7/1eb86026cb43d1d9b2298c0420c8ab1fac710bd277388305807dadd2f854/hydraters-0.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:df5e7ffb1182cc474fd3e5fd6b92b847994310d7e58a9e93e058599ee75d1130", size = 445497, upload-time = "2026-05-27T15:31:44.473Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/fd12aee9f1a74df3fee23fe3d7aeb6a23be3050dd5b190c8165485d1d4ba/hydraters-0.1.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b11ab1a68d33e2dfb9679578253807aec4db4b85f6db35d10e4f10be6f64fc3", size = 243674, upload-time = "2026-05-27T15:31:30.412Z" }, + { url = "https://files.pythonhosted.org/packages/13/ed/a89eef1bb516aec2f3c0623da684559d5ce1721382a3e8ff8ab0834f196e/hydraters-0.1.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3c35323ba8e1e15615aa06ca76ec96035ba4717d4f0b0e15628ed01c37e10c1a", size = 257357, upload-time = "2026-05-27T15:31:28.539Z" }, ] [[package]] @@ -652,33 +671,52 @@ wheels = [ [[package]] name = "stac-fastapi-api" -version = "6.2.1" +version = "6.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "brotli-asgi" }, { name = "stac-fastapi-types" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/13/5cb1ecd4ccec6f4dd9e1bf8ffe7dda257d31da73603e43e4c2805c33f631/stac_fastapi_api-6.2.1.tar.gz", hash = "sha256:049b52530d56c6f1ab4da6249a112f0031b3cf19ee95af9c212a011f137b9e17", size = 11816, upload-time = "2026-02-10T15:34:46.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/c3/f1a90eb385cf9cc98b03894a86bc93b3fee3abcbd52aa3f068c9cf165c11/stac_fastapi_api-6.3.0.tar.gz", hash = "sha256:d4d093dae4b13da8228eadae11ab2d9f438a22078b0559c5c39533ce1195926a", size = 11648, upload-time = "2026-06-22T12:24:31.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/7a/89ed5747787473c2904ad1177fcaff049ee3bd796b788e492d54c4ff69da/stac_fastapi_api-6.2.1-py3-none-any.whl", hash = "sha256:c2b9ea71d0f0eb97510eb00a2fb6fdb9b03838c6fee940d8ea5602923e664a63", size = 14153, upload-time = "2026-02-10T15:34:46.803Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/9ef9e8365d70123da82cccfa85271dfa6038abecc1b70894634ffa177b3d/stac_fastapi_api-6.3.0-py3-none-any.whl", hash = "sha256:767b281f606f13d242b3834e6a0ed50cc2c7d0022184613ab7613750dd2a4d3a", size = 13981, upload-time = "2026-06-22T12:24:32.281Z" }, +] + +[[package]] +name = "stac-fastapi-catalogs-extension" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "fastapi" }, + { name = "pydantic" }, + { name = "stac-fastapi-api" }, + { name = "stac-fastapi-types" }, + { name = "stac-pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/be/cf32efcd012713c2ed76c5bac085cbeb86f2f2ae0a7fc02f83d26d31666f/stac_fastapi_catalogs_extension-0.4.0.tar.gz", hash = "sha256:4a70f39d21ade1f5091e08e9eeafe81f2803c8bd7ff954e656c774c7c2bf6af6", size = 19237, upload-time = "2026-06-09T05:11:14.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/df/4143692edd68113a135c35694d5b87701dba9b857592157bc9215f10aa0a/stac_fastapi_catalogs_extension-0.4.0-py3-none-any.whl", hash = "sha256:ea2b0e35d12f99550f61e57587d5c37e9b4783ab2c01b8431e41933999ac4f75", size = 14043, upload-time = "2026-06-09T05:11:13.716Z" }, ] [[package]] name = "stac-fastapi-extensions" -version = "6.2.1" +version = "6.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "stac-fastapi-api" }, { name = "stac-fastapi-types" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/62/c71166b1f959ada3ea72e12ed5270e6b59b4e18f1b2f1825f4f8e58896c4/stac_fastapi_extensions-6.2.1.tar.gz", hash = "sha256:355c0c5f0d2c9b87d0238fff031963b2878d66a67faec93ff6f47d530aa370c0", size = 16673, upload-time = "2026-02-10T15:34:43.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/cd/3e70da182a38ead9e59604dffa286d3440fbd8810f8fef2dd841a02b177c/stac_fastapi_extensions-6.3.0.tar.gz", hash = "sha256:79f941bb7aa7f6a4e3c19da8deacda1dee42f19c596360f5ce9a9f9ecde214d8", size = 17508, upload-time = "2026-06-22T12:24:30.101Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/0e/f6306c9a984934eb17ff27f61b57c37942adda86302833f3dd9ec64eede8/stac_fastapi_extensions-6.2.1-py3-none-any.whl", hash = "sha256:d72f5c9323df3f54a9731f3813f9b036b81235e8728a17a6c99ca202751e4308", size = 34011, upload-time = "2026-02-10T15:34:44.239Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f1/7db9d54208a64c013727de149a4cc33da0f9c9fd47ac7170ade63bf6e12c/stac_fastapi_extensions-6.3.0-py3-none-any.whl", hash = "sha256:d4c541dff1e2a2b7997c01b676fce0c4cd1c82faa88630efe3da3f976a738b24", size = 34854, upload-time = "2026-06-22T12:24:28.972Z" }, ] [[package]] name = "stac-fastapi-pgstac" -version = "6.2.2" +version = "6.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asyncpg" }, @@ -696,14 +734,19 @@ dependencies = [ { name = "stac-fastapi-extensions" }, { name = "stac-fastapi-types" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/4d/8c2c19d7516c877d0ea511b666dcfaccdd0a29e3dc16eae05d6b5919eba8/stac_fastapi_pgstac-6.2.2.tar.gz", hash = "sha256:bdefccbcadb5c1247c545ac92095bae6c06fc6a307b73849c7848eaa368cae69", size = 22300, upload-time = "2026-02-10T11:46:10.747Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/00/52b7fdb8447d89a3c643f09050b60f02d1261c047667123202657e17e369/stac_fastapi_pgstac-6.3.0.tar.gz", hash = "sha256:26f9a820f9bab5a4a2445f0353986239f6b2664e2f7ccd7fbccfce65d17ed85d", size = 35835, upload-time = "2026-06-23T07:46:09.379Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/83/aedffbd2ee320e2fe4c24c60b2d5cc5071aba29c27c8253b2990e7a783f2/stac_fastapi_pgstac-6.2.2-py3-none-any.whl", hash = "sha256:1f2d044beefe6b2fd8882607c50951c57400d8f77e7d0fbb37118bffcad08feb", size = 26723, upload-time = "2026-02-10T11:46:12.145Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5a/a5271de262d39dddf501fb3a7cb9fbde664bb614b83d49ca5352776821f5/stac_fastapi_pgstac-6.3.0-py3-none-any.whl", hash = "sha256:4891f56e6490c4d32e0dc801875180dc5a7598d2875ce226300208d40d163fce", size = 43124, upload-time = "2026-06-23T07:46:10.66Z" }, +] + +[package.optional-dependencies] +catalogs = [ + { name = "stac-fastapi-catalogs-extension" }, ] [[package]] name = "stac-fastapi-types" -version = "6.2.1" +version = "6.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -712,9 +755,9 @@ dependencies = [ { name = "pydantic-settings" }, { name = "stac-pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/c6/34035e9db29f441d461c72df7d97b2f2ff322cd39fcac4057c5c8d070f9e/stac_fastapi_types-6.2.1.tar.gz", hash = "sha256:08e0a2f5304afcc65820861946b21f77b4511810d5e877f41736cdc51a471489", size = 10620, upload-time = "2026-02-10T15:34:41.991Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/da/cb950b5b49160991fd96d1874d8106616d92cbacb6dca11dfdc4add44373/stac_fastapi_types-6.3.0.tar.gz", hash = "sha256:7a084f9660f1e61f160f4a4f21a52f8355be4af983c48ed5a77d7f5e024b3509", size = 10617, upload-time = "2026-06-22T12:24:27.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/5c/e8826215add172cdea041390be03eb58eeb8da3bf37eec2ad1589ebbeca2/stac_fastapi_types-6.2.1-py3-none-any.whl", hash = "sha256:0cd0b697431c13a335df5ff99b6d84303187f1612979021912d9a952fa38e3bc", size = 13555, upload-time = "2026-02-10T15:34:40.699Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/676d73b899a7e3cc639a7a94273e3c2ed3e248aaceba435d91407294ac73/stac_fastapi_types-6.3.0-py3-none-any.whl", hash = "sha256:fcbaa7b3128917bd4f761296d93c13d54696c08f0e7d561c91b12244d2a1047a", size = 13552, upload-time = "2026-06-22T12:24:26.613Z" }, ] [[package]] diff --git a/docker-compose.yml b/docker-compose.yml index 0c49ac1..2b7da4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,9 @@ services: POSTGRES_PORT: "5432" DB_MIN_CONN_SIZE: "1" DB_MAX_CONN_SIZE: "1" - ENABLED_EXTENSIONS: query,sort,fields,filter,free_text,pagination,collection_search,collection_transaction + ENABLED_EXTENSIONS: ${STAC_ENABLED_EXTENSIONS:-query,sort,fields,filter,free_text,pagination,collection_search,catalogs,collection_transaction,catalog_transaction} + ENABLE_CATALOGS_EXTENSION: ${ENABLE_CATALOGS_EXTENSION:-true} + HIDE_ALTERNATE_PARENTS: ${HIDE_ALTERNATE_PARENTS:-false} TITILER_ENDPOINT: http://raster:8082 STAC_FASTAPI_TITLE: MAAP Local STAC API STAC_FASTAPI_LANDING_ID: maap-stac-api-local @@ -111,6 +113,16 @@ services: volumes: - ./.pgdata:/var/lib/postgresql/data + stac-browser: + image: ghcr.io/radiantearth/stac-browser:latest + ports: + - "${MY_DOCKER_IP:-127.0.0.1}:8080:8080" + environment: + SB_catalogUrl: "http://${MY_DOCKER_IP:-127.0.0.1}:8081" + # SB_apiCatalogPriority: "childs" + depends_on: + - stac + networks: default: name: maap-eoapi diff --git a/scripts/load_demo_stac_catalogs.py b/scripts/load_demo_stac_catalogs.py new file mode 100755 index 0000000..e117ef6 --- /dev/null +++ b/scripts/load_demo_stac_catalogs.py @@ -0,0 +1,296 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "pypgstac[psycopg]>=0.9,<0.10", +# ] +# /// +"""Load demo STAC catalogs and collections into the local pgSTAC database. + +The script is intentionally standalone. Run it from the repository root after +starting the local database with `docker compose up database`, or point it at a +pgSTAC database with `--database-url`. +""" + +from __future__ import annotations + +import argparse +import logging +import os +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from psycopg.errors import UndefinedFunction, UndefinedTable +from pypgstac.db import PgstacDB +from pypgstac.load import Loader, Methods + +LOGGER = logging.getLogger(__name__) + +DEFAULT_USERS = ("hrodmn", "jjfrench") +DEFAULT_DATABASE_URL = "postgresql://username:password@127.0.0.1:5439/postgis" +DEMO_GROUP_ID = "maap-demo-team" +DEMO_TEAM_CATALOGS_ID = "maap-demo-dps-team-catalogs" +DEMO_USER_CATALOGS_ID = "maap-demo-dps-user-catalogs" + + +@dataclass(frozen=True) +class DemoCollection: + """Configuration for a demo collection.""" + + id: str + title: str + description: str + owner: str + parent_ids: tuple[str, ...] + keywords: tuple[str, ...] + + +def utc_now() -> str: + """Return the current UTC time formatted for STAC metadata.""" + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def catalog( + catalog_id: str, + title: str, + description: str, + parent_ids: tuple[str, ...], +) -> dict[str, Any]: + """Build a minimal STAC Catalog record compatible with the Catalogs Extension.""" + return { + "type": "Catalog", + "stac_version": "1.0.0", + "id": catalog_id, + "title": title, + "description": description, + "parent_ids": list(parent_ids), + "links": [], + } + + +def collection(config: DemoCollection) -> dict[str, Any]: + """Build a minimal STAC Collection record linked to one or more catalogs.""" + now = utc_now() + return { + "type": "Collection", + "stac_version": "1.0.0", + "id": config.id, + "title": config.title, + "description": config.description, + "license": "proprietary", + "keywords": list(config.keywords), + "providers": [ + { + "name": "MAAP Demo DPS", + "roles": ["producer", "processor"], + "url": "https://maap-project.org/", + } + ], + "extent": { + "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]}, + "temporal": {"interval": [["2020-01-01T00:00:00Z", None]]}, + }, + "summaries": { + "platform": ["MAAP DPS"], + "instruments": ["demo"], + "maap:owner": [config.owner], + }, + "created": now, + "updated": now, + "parent_ids": list(config.parent_ids), + "links": [], + } + + +def build_demo_records(users: tuple[str, ...]) -> list[dict[str, Any]]: + """Build the catalog and collection records for the sample MAAP users.""" + records: list[dict[str, Any]] = [ + catalog( + DEMO_USER_CATALOGS_ID, + "DPS User Catalogs", + "Container for demo per-user DPS output catalogs.", + (), + ), + catalog( + DEMO_TEAM_CATALOGS_ID, + "DPS Team Catalogs", + "Container for demo shared team DPS output catalogs.", + (), + ), + catalog( + DEMO_GROUP_ID, + "MAAP Demo Team", + "Shared catalog showing how user collections can also appear in a group view.", + (DEMO_TEAM_CATALOGS_ID,), + ), + ] + + for username in users: + user_catalog_id = f"user-{username}" + records.append( + catalog( + user_catalog_id, + f"{username} DPS Outputs", + f"Demo per-user catalog for DPS outputs owned by {username}.", + (DEMO_USER_CATALOGS_ID,), + ) + ) + records.extend( + [ + collection( + DemoCollection( + id=f"{username}-canopy-height-demo", + title=f"{username} Canopy Height Demo", + description=( + "Synthetic DPS output collection for exploring STAC collection " + "management, per-user catalogs, and scoped catalog browsing." + ), + owner=username, + parent_ids=(user_catalog_id, DEMO_GROUP_ID), + keywords=("maap", "dps", "canopy-height", username), + ) + ), + collection( + DemoCollection( + id=f"{username}-biomass-demo", + title=f"{username} Biomass Demo", + description=( + "Synthetic biomass DPS output collection used as local demo data " + "for transaction-backed collection and catalog workflows." + ), + owner=username, + parent_ids=(user_catalog_id,), + keywords=("maap", "dps", "biomass", username), + ) + ), + ] + ) + + return records + + +def load_records(db: PgstacDB, records: list[dict[str, Any]]) -> None: + """Upsert catalog and collection records with pypgstac.""" + loader = Loader(db) + loader.load_collections(iter(records), insert_mode=Methods.upsert) + + +def collection_parent_ids(db: PgstacDB) -> dict[str, tuple[str, ...]]: + """Return all catalog and collection ids keyed to their catalog parents.""" + rows = db.query( + """ + SELECT id, COALESCE(content->'parent_ids', '[]'::jsonb) + FROM collections + """, + ) + return {record_id: tuple(parent_ids or []) for record_id, parent_ids in rows} + + +def deletion_order(parent_ids_by_record: dict[str, tuple[str, ...]]) -> list[str]: + """Return collection ids ordered so children are deleted before parents.""" + + def depth(record_id: str, visited: frozenset[str] = frozenset()) -> int: + if record_id in visited: + return 0 + parent_ids = parent_ids_by_record.get(record_id, ()) + parent_depths = [ + depth(parent_id, visited | {record_id}) + for parent_id in parent_ids + if parent_id in parent_ids_by_record + ] + return 1 + max(parent_depths, default=0) + + return sorted(parent_ids_by_record, key=depth, reverse=True) + + +def delete_all_records(db: PgstacDB) -> None: + """Delete all catalog and collection records in child-first order.""" + for record_id in deletion_order(collection_parent_ids(db)): + LOGGER.info("Deleting %s", record_id) + list(db.func("delete_collection", record_id)) + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--database-url", + default=os.environ.get("DATABASE_URL", DEFAULT_DATABASE_URL), + help=( + "PostgreSQL connection URL. Defaults to DATABASE_URL, then the local " + "docker-compose database on 127.0.0.1:5439. Use " + "postgresql://username:password@database:5432/postgis from inside the " + "maap-eoapi Docker network." + ), + ) + parser.add_argument( + "--user", + dest="users", + action="append", + help="Sample username to include. Can be passed multiple times.", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Delete all existing catalog and collection records before loading demo records.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Log the records that would be loaded without touching the database.", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Enable debug logging.", + ) + return parser.parse_args() + + +def main() -> None: + """Load demo STAC catalog records into pgSTAC.""" + args = parse_args() + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)s %(message)s", + ) + + users = tuple(args.users) if args.users else DEFAULT_USERS + records = build_demo_records(users) + + if args.dry_run: + for record in records: + LOGGER.info( + "Would load %s %s with parents %s", + record["type"], + record["id"], + record.get("parent_ids", []), + ) + return + + db = PgstacDB(dsn=args.database_url) + try: + if args.reset: + delete_all_records(db) + + load_records(db, records) + for record in records: + LOGGER.info("upsert %s %s", record["type"], record["id"]) + except (UndefinedFunction, UndefinedTable) as exc: + raise SystemExit( + "The target database does not look like a pgSTAC database. " + "Start the local stack with `docker compose up database` and retry." + ) from exc + finally: + db.close() + + LOGGER.info( + "Loaded %d demo STAC catalog records for users: %s", + len(records), + ", ".join(users), + ) + + +if __name__ == "__main__": + main() diff --git a/test/config.test.ts b/test/config.test.ts index 171ac2a..a31ba24 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -70,6 +70,7 @@ describe("Config", () => { "arn:aws:wafv2:us-east-1:123456789012:global/webacl/test-acl", ); expect(config.userStacCollectionTransactions).toBeUndefined(); + expect(config.userStacCatalogs).toEqual({ enabled: true }); // Test number properties expect(config.dbAllocatedStorage).toBe(20); @@ -149,6 +150,34 @@ describe("Config", () => { ); }); + test("configures user STAC catalogs and catalog transactions", () => { + process.env.USER_STAC_CATALOGS_HIDE_ALTERNATE_PARENTS = "true"; + process.env.USER_STAC_CATALOG_TRANSACTIONS_ENABLED = "true"; + process.env.USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE = "basic"; + process.env.USER_STAC_CATALOG_TRANSACTIONS_AUTH_SECRET_ARN = + "arn:aws:secretsmanager:us-west-2:123456789012:secret:user-stac-catalog-auth"; + + const config = new Config(); + + expect(config.userStacCatalogs).toEqual({ + enabled: true, + hideAlternateParents: true, + transactions: { + authMode: "basic", + authSecretArn: + "arn:aws:secretsmanager:us-west-2:123456789012:secret:user-stac-catalog-auth", + }, + }); + }); + + test("rejects catalog transactions when user STAC catalogs are disabled", () => { + process.env.USER_STAC_CATALOGS_ENABLED = "false"; + process.env.USER_STAC_CATALOG_TRANSACTIONS_ENABLED = "true"; + process.env.USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE = "basic"; + + expect(() => new Config()).toThrow(/requires USER_STAC_CATALOGS_ENABLED/); + }); + test("buildStackName formats properly", () => { const config = new Config(); diff --git a/test/pgstac-infra.test.ts b/test/pgstac-infra.test.ts index fd3a751..62ee6e4 100644 --- a/test/pgstac-infra.test.ts +++ b/test/pgstac-infra.test.ts @@ -83,7 +83,9 @@ describe("PgStacInfra STAC runtime wiring", () => { STAC_FASTAPI_TITLE: "MAAP public STAC API (test)", STAC_FASTAPI_LANDING_ID: "maap-public-stac-api-test", ENABLED_EXTENSIONS: - "query,sort,fields,filter,free_text,pagination,collection_search", + "query,sort,fields,filter,free_text,pagination,collection_search,catalogs", + ENABLE_CATALOGS_EXTENSION: "true", + HIDE_ALTERNATE_PARENTS: "false", }), }, }); @@ -126,7 +128,7 @@ describe("PgStacInfra STAC runtime wiring", () => { Environment: { Variables: Match.objectLike({ ENABLED_EXTENSIONS: - "query,sort,fields,filter,free_text,pagination,collection_search,collection_transaction", + "query,sort,fields,filter,free_text,pagination,collection_search,catalogs,collection_transaction", MAAP_TRANSACTION_AUTH_MODE: "basic", MAAP_TRANSACTION_AUTH_SECRET_ARN: { Ref: Match.stringLikeRegexp( @@ -143,6 +145,84 @@ describe("PgStacInfra STAC runtime wiring", () => { }); }); + test("enables catalog transactions with a stack-managed secret", () => { + const template = buildTemplate({ + stacApiConfig: { + customDomainName: "internal-stac.example.com", + catalogs: { + enabled: true, + hideAlternateParents: true, + transactions: { + authMode: "basic", + }, + }, + }, + }); + + template.hasResourceProperties("AWS::Lambda::Function", { + Handler: "eoapi.stac.handler.handler", + Environment: { + Variables: Match.objectLike({ + ENABLED_EXTENSIONS: + "query,sort,fields,filter,free_text,pagination,collection_search,catalogs,catalog_transaction", + ENABLE_CATALOGS_EXTENSION: "true", + HIDE_ALTERNATE_PARENTS: "true", + MAAP_TRANSACTION_AUTH_MODE: "basic", + MAAP_TRANSACTION_AUTH_SECRET_ARN: { + Ref: Match.stringLikeRegexp( + "staccollectiontransactionauthsecret", + ), + }, + }), + }, + }); + }); + + test("supports catalog transactions without collection transactions", () => { + const template = buildTemplate({ + stacApiConfig: { + customDomainName: "internal-stac.example.com", + catalogs: { + enabled: true, + transactions: { + authMode: "basic", + }, + }, + }, + }); + + template.hasResourceProperties("AWS::Lambda::Function", { + Handler: "eoapi.stac.handler.handler", + Environment: { + Variables: Match.objectLike({ + ENABLED_EXTENSIONS: + "query,sort,fields,filter,free_text,pagination,collection_search,catalogs,catalog_transaction", + MAAP_TRANSACTION_AUTH_MODE: "basic", + }), + }, + }); + template.hasResourceProperties("AWS::SSM::Parameter", { + Name: + "/maap-eoapi/test/internal/stac-collection-transaction-auth-secret-arn", + }); + }); + + test("rejects catalog transactions when catalogs are disabled", () => { + expect(() => + buildTemplate({ + stacApiConfig: { + customDomainName: "internal-stac.example.com", + catalogs: { + enabled: false, + transactions: { + authMode: "basic", + }, + }, + }, + }), + ).toThrow(/catalog transactions require catalogs/); + }); + test("uses an explicit transaction auth secret ARN override when provided", () => { const template = buildTemplate({ stacApiConfig: { From b92187438fcae44707c3cd0973c12498e719e9b1 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 26 Jun 2026 10:02:04 -0500 Subject: [PATCH 2/3] chore: add new env vars to deploy.yml --- .github/workflows/deploy.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5728538..8c6363a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -49,6 +49,11 @@ jobs: USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE: ${{ vars.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE }} USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN: ${{ vars.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN }} USER_STAC_COLLECTION_TRANSACTIONS_ENABLED: ${{ vars.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED }} + USER_STAC_CATALOGS_ENABLED: ${{ vars.USER_STAC_CATALOGS_ENABLED }} + USER_STAC_CATALOGS_HIDE_ALTERNATE_PARENTS: ${{ vars.USER_STAC_CATALOGS_HIDE_ALTERNATE_PARENTS }} + USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE: ${{ vars.USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE }} + USER_STAC_CATALOG_TRANSACTIONS_AUTH_SECRET_ARN: ${{ vars.USER_STAC_CATALOG_TRANSACTIONS_AUTH_SECRET_ARN }} + USER_STAC_CATALOG_TRANSACTIONS_ENABLED: ${{ vars.USER_STAC_CATALOG_TRANSACTIONS_ENABLED }} USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME }} USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME }} WEB_ACL_ARN: ${{ vars.WEB_ACL_ARN }} From a08dd8b41c2ee48560ba927222fd62aa0e27a5e4 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 26 Jun 2026 10:10:51 -0500 Subject: [PATCH 3/3] docs: update readme --- cdk/runtimes/eoapi/stac/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cdk/runtimes/eoapi/stac/README.md b/cdk/runtimes/eoapi/stac/README.md index 5fc7cb3..019e7a0 100644 --- a/cdk/runtimes/eoapi/stac/README.md +++ b/cdk/runtimes/eoapi/stac/README.md @@ -14,10 +14,12 @@ docker compose up --build stac raster database The local compose setup bind-mounts `cdk/runtimes/eoapi/stac/` into the container and runs `uvicorn --reload`, so changes under `cdk/runtimes/eoapi/stac/eoapi/stac/` are picked up without rebuilding the image. Add or override environment variables with `.stac.env`, `.raster.env`, or `.env` as needed. -Read-only multi-tenant catalog routes are enabled through `ENABLED_EXTENSIONS=catalogs` and the upstream-compatible `ENABLE_CATALOGS_EXTENSION=true` setting. The local compose default includes read-only catalogs. To try catalog transactions locally, set `STAC_ENABLED_EXTENSIONS` to include `catalog_transaction` as well, for example: +Multi-tenant catalog routes are enabled through `ENABLED_EXTENSIONS=catalogs` and the upstream-compatible `ENABLE_CATALOGS_EXTENSION=true` setting. The local compose default includes both read-only catalog routes and catalog transaction routes by setting `STAC_ENABLED_EXTENSIONS` to include `catalogs`, `collection_transaction`, and `catalog_transaction`. + +To run the local API with read-only catalog routes only, override `STAC_ENABLED_EXTENSIONS` without `catalog_transaction`, for example: ```bash -STAC_ENABLED_EXTENSIONS=query,sort,fields,filter,free_text,pagination,collection_search,catalogs,collection_transaction,catalog_transaction docker compose up --build stac database +STAC_ENABLED_EXTENSIONS=query,sort,fields,filter,free_text,pagination,collection_search,catalogs docker compose up --build stac database ``` Catalog transaction routes are separate from collection transaction routes. Enabling `catalogs` alone adds read routes such as `GET /catalogs`, `GET /catalogs/{catalog_id}`, and catalog-scoped collection/item reads. It does not add write routes.