Skip to content

Instantly share code, notes, and snippets.

@ghidalgo3
Last active March 31, 2026 15:39
Show Gist options
  • Select an option

  • Save ghidalgo3/ce6cac6a1cd3a324e77b064e48da05ca to your computer and use it in GitHub Desktop.

Select an option

Save ghidalgo3/ce6cac6a1cd3a324e77b064e48da05ca to your computer and use it in GitHub Desktop.
GeoCatalog + APIM

GeoCatalogs + APIM

Today (Mar 2026) GeoCatalogs do not support:

  1. Anonymous access
  2. Collection-level RBAC

We can implement them by deploying some kind of HTTP proxy between users and the geocatalog. One such proxy is API Management.

Before

flowchart LR
    A["Anonymous Users"] -- "x" --x B["GeoCatalog"]
Loading

After

flowchart LR
    A["Anonymous Users"] --> B["APIM"]
    B -- "User-Assigned Managed Identity" --> C["GeoCatalog"]
Loading

Prerequisites

  1. Create/have a GeoCagalog
  2. Create/have a user-assigned managed identity
  3. Give the managed identity the GeoCatalog Reader role over the geocatalog.

Procedure

  1. Create an API Management instance
  2. Assign the resource the user assigned managed identity you have already created
  3. Define the following API policies:

API-level policy

The API has a single inbound policy: authentication-managed-identity. This policy acquires a token from the user-assigned managed identity and attaches it to every request forwarded to the GeoCatalog backend.

<policies>
    <inbound>
        <base />
        <authentication-managed-identity
            resource="https://geocatalog.spatio.azure.com"
            client-id="<managed-identity-client-id>" />
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
        <find-and-replace
            from="https://<name>.<id>.<region>.geocatalog.spatio.azure.com"
            to="https://<apim-name>.azure-api.net" />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>
  • resource — the audience of the token. All GeoCatalogs share the same audience (https://geocatalog.spatio.azure.com).
  • client-id — the client ID of the user-assigned managed identity that APIM uses to authenticate.
  • find-and-replace — rewrites the GeoCatalog backend URL in response bodies to the APIM gateway URL. Without this, STAC links (self, root, parent, etc.) would expose the backend URL to callers.

The global (service-level) policy is the default: it simply forwards the request to the backend with no additional processing.

API backend configuration

When creating the API in APIM, configure it as follows:

Setting Value
Display name A descriptive name (e.g. GeoCatalog API)
Web service URL Your GeoCatalog endpoint, e.g. https://<name>.<id>.<region>.geocatalog.spatio.azure.com
URL scheme HTTPS
API URL suffix Leave empty (root path)
Subscription required No (callers do not need to present an APIM subscription key)

Then define the following operations to proxy traffic to the GeoCatalog:

Operation name Method URL template
GET GET /*
Get collection items GET /stac/collections/{collection_id}/items
Get single collection GET /stac/collections/{collection_id}
Get collection sub-resources GET /stac/collections/{collection_id}/*
POST POST /*

The wildcard (/*) operations forward all matching requests to the GeoCatalog backend. The explicit collection operations allow applying collection-specific RBAC policies to prevent unauthorized access to individual collection metadata, items, and sub-resources.

Collection-level RBAC

By default a GeoCatalog exposes all its collections to any authenticated caller. To restrict which collections are visible through APIM, apply operation-level policies that block broad STAC discovery and enforce an allow-list.

Disable the landing page and /collections

Block the routes that would reveal every collection in the catalog. Add the following operations and attach a policy that returns 404 immediately:

Operation name Method URL template
Block root GET /
Block collections GET /stac/collections

Apply this operation-level policy to both:

<policies>
    <inbound>
        <base />
        <return-response>
            <set-status code="404" reason="Not Found" />
        </return-response>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

Enforce allowed collections on search

The STAC /stac/search endpoint accepts a collections parameter — as a query string on GET or in the JSON body on POST. Without guardrails a caller could search across every collection in the catalog. The policies below validate that only collections from an allowed set are requested.

Define a named value in APIM called allowed-collections containing a comma-separated list of permitted collection IDs (e.g. sentinel-2-l2a,landsat-8-c2-l2).

Add two operations:

Operation name Method URL template
GET search GET /stac/search
POST search POST /stac/search

GET /stac/search policy

Validates the collections query parameter. Each comma-separated value must be in the allowed set.

Note

APIM policy expressions run in a restricted C# environment. Use condition='@{...}' (single-quoted attribute) so double-quotes work inside the expression. Avoid generic type parameters (e.g. GetValueOrDefault<string>) and LINQ lambdas — use explicit casts and foreach loops instead.

<policies>
    <inbound>
        <base />
        <set-variable name="allowedCsv"
            value="{{allowed-collections}}" />
        <choose>
            <when condition='@{
                var allowed = ((string)context
                    .Variables["allowedCsv"])
                    .Trim().ToLower();
                var raw = context.Request.Url.Query
                    .GetValueOrDefault("collections", "");
                if (string.IsNullOrWhiteSpace(raw)) {
                    return true;
                }
                foreach (var c in raw.ToLower().Split(
                    new [] { "," },
                    StringSplitOptions.RemoveEmptyEntries))
                {
                    if (!c.Trim().Equals(allowed)) {
                        return true;
                    }
                }
                return false;
            }'>
                <return-response>
                    <set-status code="403"
                        reason="Forbidden" />
                    <set-body>
                        Collection not allowed.
                    </set-body>
                </return-response>
            </when>
        </choose>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

POST /stac/search policy

Parses the JSON body and validates the collections array.

<policies>
    <inbound>
        <base />
        <set-variable name="allowedCsv"
            value="{{allowed-collections}}" />
        <set-variable name="requestBody"
            value="@(context.Request.Body
                .As&lt;string&gt;(
                    preserveContent: true))" />
        <choose>
            <when condition='@{
                var allowed = ((string)context
                    .Variables["allowedCsv"])
                    .Trim().ToLower();
                var body = (string)context
                    .Variables["requestBody"];
                var json = Newtonsoft.Json.Linq
                    .JObject.Parse(body);
                var arr = json["collections"]
                    as Newtonsoft.Json.Linq.JArray;
                if (arr == null || arr.Count == 0) {
                    return true;
                }
                foreach (var token in arr) {
                    if (!token.ToString().Trim()
                        .ToLower().Equals(allowed))
                    {
                        return true;
                    }
                }
                return false;
            }'>
                <return-response>
                    <set-status code="403"
                        reason="Forbidden" />
                    <set-body>
                        Collection not allowed.
                    </set-body>
                </return-response>
            </when>
        </choose>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

How do I update the allowed collections list?

Edit the allowed-collections named value in the APIM instance. No policy changes are needed.

What happens if a caller omits the collections parameter?

The request is rejected with 403 Forbidden. Callers must always specify which collections they want to search.

Enforce allowed collections on SAS

The GeoCatalog SAS API lets callers generate storage tokens and sign asset HREFs. Without restrictions a caller could obtain tokens for any collection. The policies below ensure that only allowed collections can be accessed.

Add the following operations:

Operation name Method URL template
GET SAS token GET /sas/token/{collection_id}
Block SAS sign GET /sas/sign

The /sas/sign endpoint is blocked entirely. Callers should use /sas/token/{collection_id} to obtain collection-level SAS tokens instead.

Apply the 404 blocking policy (same as the root/collections block above) to the Block SAS sign operation.

GET /sas/token/{collection_id} policy

Validates the collection_id path parameter against the allowed set.

<policies>
    <inbound>
        <base />
        <set-variable name="allowedCsv"
            value="{{allowed-collections}}" />
        <choose>
            <when condition='@{
                var allowed = ((string)context
                    .Variables["allowedCsv"])
                    .Trim().ToLower();
                var collectionId = (string)context
                    .Request.MatchedParameters[
                        "collection_id"];
                return !collectionId.Trim()
                    .ToLower().Equals(allowed);
            }'>
                <return-response>
                    <set-status code="403"
                        reason="Forbidden" />
                    <set-body>
                        Collection not allowed.
                    </set-body>
                </return-response>
            </when>
        </choose>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

Enforce allowed collections on data routes

The /data/mosaic/ endpoints provide tile rendering, bounding-box crops, and search registration. Two policy groups are needed:

  1. Register search — validate the collections array in the JSON body.
  2. All other collection routes — validate the collectionId path parameter.

Add the following operations:

Operation name Method URL template
POST register search POST /data/mosaic/register
GET data collection GET /data/mosaic/collections/{collectionId}/*

POST /data/mosaic/register policy

Validates the collections array in the JSON body against the allowed set. Requests without a collections parameter are rejected.

<policies>
    <inbound>
        <base />
        <set-variable name="allowedCsv"
            value="{{allowed-collections}}" />
        <set-variable name="requestBody"
            value="@(context.Request.Body
                .As&lt;string&gt;(
                    preserveContent: true))" />
        <choose>
            <when condition='@{
                var allowed = ((string)context
                    .Variables["allowedCsv"])
                    .Trim().ToLower();
                var body = (string)context
                    .Variables["requestBody"];
                var json = Newtonsoft.Json.Linq
                    .JObject.Parse(body);
                var arr = json["collections"]
                    as Newtonsoft.Json.Linq.JArray;
                if (arr == null || arr.Count == 0) {
                    return true;
                }
                foreach (var token in arr) {
                    if (!token.ToString().Trim()
                        .ToLower().Equals(allowed))
                    {
                        return true;
                    }
                }
                return false;
            }'>
                <return-response>
                    <set-status code="403"
                        reason="Forbidden" />
                    <set-body>
                        Collection not allowed.
                    </set-body>
                </return-response>
            </when>
        </choose>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

GET/POST /data/mosaic/collections/{collectionId}/* policy

Validates the collectionId path parameter against the allowed set. Apply this policy to both the GET and POST operations.

<policies>
    <inbound>
        <base />
        <set-variable name="allowedCsv"
            value="{{allowed-collections}}" />
        <choose>
            <when condition='@{
                var allowed = ((string)context
                    .Variables["allowedCsv"])
                    .Trim().ToLower();
                var collectionId = (string)context
                    .Request.MatchedParameters[
                        "collectionId"];
                return !collectionId.Trim()
                    .ToLower().Equals(allowed);
            }'>
                <return-response>
                    <set-status code="403"
                        reason="Forbidden" />
                    <set-body>
                        Collection not allowed.
                    </set-body>
                </return-response>
            </when>
        </choose>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

Enforce allowed collections on collection endpoints

Without explicit operations, requests like GET /stac/collections/sentinel-2-l2a or GET /stac/collections/sentinel-2-l2a/items fall through to the GET /* wildcard and reach the backend without any collection-level check. Add the following operations to enforce the allow-list on individual collection endpoints:

Operation name Method URL template
Get single collection GET /stac/collections/{collection_id}
Get collection sub-resources GET /stac/collections/{collection_id}/*
Get collection items GET /stac/collections/{collection_id}/items

Apply the same path-parameter ACL policy used for SAS tokens (validates collection_id against {{allowed-collections}}) to all three operations.

Sample requests

Replace <apim-gateway> with your APIM gateway URL (e.g. https://XXX.azure-api.net).

List all collections

curl "https://<apim-gateway>/stac/collections"

Run a STAC search

curl -X POST "https://<apim-gateway>/stac/search" \
  -H "Content-Type: application/json" \
  -d '{
    "collections": ["sentinel-2-l2a"],
    "bbox": [-122.5, 37.5, -122.0, 38.0],
    "datetime": "2024-01-01T00:00:00Z/2024-02-01T00:00:00Z",
    "limit": 5
  }'

Get a SAS token

curl "https://<apim-gateway>/sas/token/sentinel-2-l2a"

Preview a STAC item

curl -o preview.png \
  "https://<apim-gateway>/data/collections/sentinel-2-l2a/items/<item-id>/preview.png?api-version=2025-04-30-preview"

Subscription-based collection ACLs

The collection-level RBAC above uses a single allowed-collections named value — every caller sees the same set. To give different users different collection access (e.g. basic vs. premium tiers), use APIM Products with per-product policies.

Concept

flowchart LR
    B["Basic subscriber"] -- "subscription key" --> PB["Basic product"]
    P["Premium subscriber"] -- "subscription key" --> PP["Premium product"]
    PB --> API["GeoCatalog API"]
    PP --> API
    API --> GC["GeoCatalog"]
Loading
  • A Product groups one or more APIs and issues its own subscription keys.
  • Each product has its own policy scope, so the allowed-collections value can differ per product.
  • When APIM receives a subscription key it automatically resolves the associated product and applies that product's policies.

Setup

1. Create two products

Product Subscription required Collections
Basic Yes sentinel-2-l2a
Premium Yes sentinel-2-l2a,landsat-8-c2-l2,sentinel-1-grd

Add the GeoCatalog API to both products.

2. Create product-scoped named values

Create one named value per product:

Named value Value
basic-allowed-collections sentinel-2-l2a
premium-allowed-collections sentinel-2-l2a,landsat-8-c2-l2,sentinel-1-grd

3. Set product-level policies

On each product, add an inbound policy that sets the allowedCsv variable for downstream operation policies to consume.

Basic product policy:

<policies>
    <inbound>
        <base />
        <set-variable name="allowedCsv"
            value="{{basic-allowed-collections}}" />
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

Premium product policy:

<policies>
    <inbound>
        <base />
        <set-variable name="allowedCsv"
            value="{{premium-allowed-collections}}" />
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

4. Simplify operation policies

The operation-level policies (search, SAS, data routes, collection endpoints) no longer need to set allowedCsv themselves — it is already set by the product policy. Remove the <set-variable name="allowedCsv" .../> line from each operation policy; the rest of the validation logic stays the same.

5. Create subscriptions

Create a subscription under each product for each user or application. The subscription key determines which product (and therefore which allowed-collections set) applies.

Usage

Callers include their subscription key in requests:

# Basic subscriber — only sentinel-2-l2a is allowed
curl -H "Ocp-Apim-Subscription-Key: <basic-key>" \
  "https://<apim-gateway>/stac/search?collections=sentinel-2-l2a"

# Premium subscriber — all three collections are allowed
curl -H "Ocp-Apim-Subscription-Key: <premium-key>" \
  -X POST "https://<apim-gateway>/stac/search" \
  -H "Content-Type: application/json" \
  -d '{
    "collections": [
      "sentinel-2-l2a",
      "landsat-8-c2-l2",
      "sentinel-1-grd"
    ]
  }'

A basic subscriber requesting a premium-only collection receives 403 Forbidden.

Adding a new tier

  1. Create a new named value (e.g. enterprise-allowed-collections).
  2. Create a new product with a product-level policy that sets allowedCsv to {{enterprise-allowed-collections}}.
  3. Add the GeoCatalog API to the product.
  4. Issue subscriptions under the new product.

No operation-level policy changes are needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment