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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
59 changes: 50 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<stage>/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


Expand Down
56 changes: 47 additions & 9 deletions cdk/PgStacInfra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string> = {
Expand All @@ -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,
}
: {}),
};
Expand Down Expand Up @@ -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})`,
});
}

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

/**
Expand Down
3 changes: 3 additions & 0 deletions cdk/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const {
userStacCollectionIdRegistry,
userStacInboundTopicArns,
userStacItemGenRoleArn,
userStacCatalogs,
userStacCollectionTransactions,
userStacStacApiCustomDomainName,
userStacTitilerPgStacApiCustomDomainName,
Expand Down Expand Up @@ -70,6 +71,7 @@ const coreInfrastructure = new PgStacInfra(app, buildStackName("pgSTAC"), {
stacApiConfig: {
customDomainName: stacApiCustomDomainName,
integrationApiArn: stacApiIntegrationApiArn,
catalogs: { enabled: true },
},
titilerPgstacConfig: {
mosaicHost,
Expand Down Expand Up @@ -109,6 +111,7 @@ const userInfrastructure = new PgStacInfra(app, buildStackName("userSTAC"), {
},
stacApiConfig: {
customDomainName: userStacStacApiCustomDomainName,
catalogs: userStacCatalogs,
transactions: userStacCollectionTransactions,
},
titilerPgstacConfig: {
Expand Down
59 changes: 59 additions & 0 deletions cdk/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === "") {
Expand Down Expand Up @@ -51,6 +57,7 @@ export class Config {
readonly userStacCollectionTransactions:
| CollectionTransactionsConfig
| undefined;
readonly userStacCatalogs: StacCatalogsConfig;

constructor() {
const requiredVariables = [
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
},
};
}
}
42 changes: 37 additions & 5 deletions cdk/runtimes/eoapi/stac/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@ 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:
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 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:
Expand All @@ -25,6 +35,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:
Expand All @@ -48,6 +69,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`
Expand All @@ -67,15 +90,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.
Loading