diff --git a/charts/eoapi/data/stac-auth-proxy/custom_filters.py b/charts/eoapi/data/stac-auth-proxy/custom_filters.py new file mode 100644 index 00000000..efb18aeb --- /dev/null +++ b/charts/eoapi/data/stac-auth-proxy/custom_filters.py @@ -0,0 +1,41 @@ +""" +Sample custom filters for STAC Auth Proxy. +This file demonstrates the structure needed for custom collection and item filters. +""" + +import dataclasses +from typing import Any + + +@dataclasses.dataclass +class CollectionsFilter: + """Returns CQL2 filter for /collections endpoint.""" + + async def __call__(self, context: dict[str, Any]) -> str | dict[str, Any]: + """ + Return format: + - CQL2-text string: "1=1" or "private = false" + - CQL2-JSON dict: {"op": "=", "args": [{"property": "owner"}, "user123"]} + + Examples: + - Allow all: return "1=1" + - User-specific: return f"owner = '{context['token']['sub']}'" + - Public only: return "private = false" if not context["token"] else "1=1" + - Complex: return {"op": "in", "args": [{"property": "id"}, ["col1", "col2"]]} + """ + return "1=1" + + +@dataclasses.dataclass +class ItemsFilter: + """Returns CQL2 filter for /search and /collections/{id}/items endpoints.""" + + async def __call__(self, context: dict[str, Any]) -> str | dict[str, Any]: + """ + Examples: + - Allow all: return "1=1" + - Collection-based: return f"collection = '{context['collection_id']}'" + - User-specific: return f"properties.owner = '{context['token']['sub']}'" + - Complex: return {"op": "in", "args": [{"property": "collection"}, approved_list]} + """ + return "1=1" diff --git a/charts/eoapi/profiles/experimental.yaml b/charts/eoapi/profiles/experimental.yaml index 0bce5d68..c9e2a9a4 100644 --- a/charts/eoapi/profiles/experimental.yaml +++ b/charts/eoapi/profiles/experimental.yaml @@ -369,8 +369,25 @@ ingress: stac-auth-proxy: enabled: true # For testing this will be set dynamically; for production, point to your OIDC server - # env: - # OIDC_DISCOVERY_URL: "http://eoapi-mock-oidc-server.eoapi.svc.cluster.local:8080/.well-known/openid-configuration" + env: + # OIDC_DISCOVERY_URL: "http://eoapi-mock-oidc-server.eoapi.svc.cluster.local:8080/.well-known/openid-configuration" + # Custom filter classes + COLLECTIONS_FILTER_CLS: "stac_auth_proxy.custom_filters:CollectionsFilter" + ITEMS_FILTER_CLS: "stac_auth_proxy.custom_filters:ItemsFilter" + + # Custom filters configuration + customFiltersFile: "data/stac-auth-proxy/custom_filters.py" + + extraVolumes: + - name: filters + configMap: + name: eoapi-stac-auth-proxy-custom-filters + + extraVolumeMounts: + - name: filters + mountPath: /app/src/stac_auth_proxy/custom_filters.py + subPath: custom_filters.py + readOnly: true ###################### # MOCK OIDC SERVER diff --git a/charts/eoapi/templates/core/stac-auth-proxy-filters-configmap.yaml b/charts/eoapi/templates/core/stac-auth-proxy-filters-configmap.yaml new file mode 100644 index 00000000..fbf26f1b --- /dev/null +++ b/charts/eoapi/templates/core/stac-auth-proxy-filters-configmap.yaml @@ -0,0 +1,16 @@ +{{- if index .Values "stac-auth-proxy" "enabled" }} +{{- $stacAuthProxy := index .Values "stac-auth-proxy" }} +{{- if and (hasKey $stacAuthProxy "extraVolumes") $stacAuthProxy.extraVolumes }} +{{- $filterFile := $stacAuthProxy.customFiltersFile | default "data/stac-auth-proxy/custom_filters.py" }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: eoapi-stac-auth-proxy-custom-filters + labels: + {{- include "eoapi.labels" . | nindent 4 }} + app.kubernetes.io/component: stac-auth-proxy +data: + custom_filters.py: | +{{ .Files.Get $filterFile | indent 4 }} +{{- end }} +{{- end }} diff --git a/charts/eoapi/tests/stac-auth-proxy-filters_test.yaml b/charts/eoapi/tests/stac-auth-proxy-filters_test.yaml new file mode 100644 index 00000000..229049a1 --- /dev/null +++ b/charts/eoapi/tests/stac-auth-proxy-filters_test.yaml @@ -0,0 +1,77 @@ +suite: test stac-auth-proxy custom filters ConfigMap +templates: + - templates/_helpers/core.tpl + - templates/core/stac-auth-proxy-filters-configmap.yaml + +tests: + - it: should create ConfigMap when stac-auth-proxy is enabled and extraVolumes is defined + set: + stac-auth-proxy.enabled: true + stac-auth-proxy.extraVolumes: + - name: filters + configMap: + name: test-filters + template: templates/core/stac-auth-proxy-filters-configmap.yaml + asserts: + - isKind: + of: ConfigMap + - equal: + path: metadata.name + value: eoapi-stac-auth-proxy-custom-filters + - isNotEmpty: + path: data + + - it: should not create ConfigMap when stac-auth-proxy is disabled + set: + stac-auth-proxy.enabled: false + stac-auth-proxy.extraVolumes: + - name: filters + configMap: + name: test-filters + asserts: + - hasDocuments: + count: 0 + + - it: should not create ConfigMap when extraVolumes is not defined + set: + stac-auth-proxy.enabled: true + asserts: + - hasDocuments: + count: 0 + + - it: should have correct labels + set: + stac-auth-proxy.enabled: true + stac-auth-proxy.extraVolumes: + - name: filters + configMap: + name: test-filters + template: templates/core/stac-auth-proxy-filters-configmap.yaml + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: stac-auth-proxy + - exists: + path: metadata.labels["app.kubernetes.io/name"] + - exists: + path: metadata.labels["app.kubernetes.io/instance"] + - exists: + path: metadata.labels["helm.sh/chart"] + + - it: should use custom file path when customFiltersFile is specified + set: + stac-auth-proxy.enabled: true + stac-auth-proxy.customFiltersFile: "data/eoepca_filters.py" + stac-auth-proxy.extraVolumes: + - name: filters + configMap: + name: test-filters + template: templates/core/stac-auth-proxy-filters-configmap.yaml + asserts: + - isKind: + of: ConfigMap + - equal: + path: metadata.name + value: eoapi-stac-auth-proxy-custom-filters + - isNotEmpty: + path: data diff --git a/charts/eoapi/values.yaml b/charts/eoapi/values.yaml index 75203fb0..b1501b60 100644 --- a/charts/eoapi/values.yaml +++ b/charts/eoapi/values.yaml @@ -417,17 +417,48 @@ stac-auth-proxy: enabled: false image: tag: "v0.11.1" - env: - ROOT_PATH: "/stac" - OVERRIDE_HOST: "false" - DEFAULT_PUBLIC: "true" - # UPSTREAM_URL will be set dynamically in template to point to stac service - # OIDC_DISCOVERY_URL must be configured when enabling auth ingress: enabled: false # Handled by main eoapi ingress service: port: 8080 resources: {} + env: + # OIDC_DISCOVERY_URL must be configured when enabling auth (required) + # UPSTREAM_URL must be set to "http://-stac:8080" (required) + ROOT_PATH: "/stac" + OVERRIDE_HOST: "false" + # + # Authentication filters settings: + DEFAULT_PUBLIC: "true" # This enables standard profile for authentication filters + # + # To use custom filters, ALL THREE of the following are required: + # 1. Set filter class environment variables (uncomment lines below) + # 2. Set customFiltersFile path (see below) + # 3. Configure extraVolumes and extraVolumeMounts (see below) + # + # COLLECTIONS_FILTER_CLS: stac_auth_proxy.custom_filters:CollectionsFilter + # ITEMS_FILTER_CLS: stac_auth_proxy.custom_filters:ItemsFilter + + # Path to custom filters file (relative to chart root) + # Creates a ConfigMap from this file - required for custom filters + # customFiltersFile: "data/stac-auth-proxy/custom_filters.py" + + # Volume referencing the ConfigMap - required for custom filters + extraVolumes: [] + # Example (required for custom filters): + # extraVolumes: + # - name: filters + # configMap: + # name: stac-auth-proxy-filters + + # Volume mount making filters available to container - required for custom filters + extraVolumeMounts: [] + # Example (required for custom filters): + # extraVolumeMounts: + # - name: filters + # mountPath: /app/src/stac_auth_proxy/custom_filters.py + # subPath: custom_filters.py + # readOnly: true vector: enabled: true diff --git a/docs/stac-auth-proxy.md b/docs/stac-auth-proxy.md index 180210b3..0ed68e1e 100644 --- a/docs/stac-auth-proxy.md +++ b/docs/stac-auth-proxy.md @@ -10,191 +10,108 @@ external_links: # STAC Auth Proxy -## Solution Overview +## Overview -We have implemented support for STAC Auth Proxy integration with eoAPI-K8S through service-specific ingress control. This feature allows the STAC service to be accessible only internally while other services remain externally available. +STAC Auth Proxy integration allows the STAC service to be accessible only through an authenticated proxy while other eoAPI services remain externally available. -## Implementation Details - -### 1. Service-Specific Ingress Control - -Each service can now independently control its ingress settings via the values.yaml configuration: - -```yaml -stac: - enabled: true - ingress: - enabled: false # Disable external ingress for STAC only - -# Other services remain externally accessible -raster: - enabled: true - ingress: - enabled: true -``` - -## Deployment Guide +## Deployment ### 1. Configure eoAPI-K8S +Disable external STAC ingress and configure root path: + ```yaml # values.yaml for eoapi-k8s stac: enabled: true + overrideRootPath: "" # No --root-path argument (proxy handles prefix) ingress: - enabled: false # No external ingress for STAC + enabled: false # Required: prevents unauthenticated direct access # Other services remain externally accessible raster: enabled: true vector: enabled: true -multidim: - enabled: true ``` ### 2. Deploy STAC Auth Proxy -Deploy the stac-auth-proxy Helm chart in the same namespace: +Configure stac-auth-proxy subchart to point to the STAC service: ```yaml -# values.yaml for stac-auth-proxy -backend: - service: stac # Internal K8s service name - port: 8080 # Service port - -auth: - # Configure authentication settings - provider: oauth2 - # ... other auth settings -``` - -### 3. Network Flow - -```mermaid -graph LR - A[External Request] --> B[STAC Auth Proxy] - B -->|Authentication| C[Internal STAC Service] - D[External Request] -->|Direct Access| E[Raster/Vector/Other Services] -``` - -## Testing - -Verify the configuration: - -```bash -# Check that STAC paths are excluded -helm template eoapi --set stac.ingress.enabled=false,stac.enabled=true -f values.yaml - -# Verify other services remain accessible -kubectl get ingress -kubectl get services -``` - -Expected behavior: -- STAC service accessible only within the cluster -- Other services (raster, vector, etc.) accessible via their ingress paths -- Auth proxy successfully routing authenticated requests to STAC - -## Troubleshooting - -1. **STAC Service Not Accessible Internally** - - Verify service is running: `kubectl get services` - - Check service port matches auth proxy configuration - - Verify network policies allow proxy-to-STAC communication - -2. **Other Services Affected** - - Confirm ingress configuration for other services - - Check ingress controller logs - - Verify service-specific settings in values.yaml - -## Root Path Configuration for Direct Service Access - -### Understanding usage of overrideRootPath with stac-auth-proxy - -When deploying the eoAPI-K8S with the STAC service behind a stac-auth-proxy, you may want to configure the `stac.overrideRootPath` parameter to control how the FastAPI application handles root path prefixes. This is particularly useful when the auth proxy is responsible for managing the `/stac` path prefix. - -When deploying stac-auth-proxy in front of the eoAPI service, you may need to configure the root path behavior to avoid URL conflicts. The `stac.overrideRootPath` parameter allows you to control how the STAC FastAPI application handles root path prefixes. - -### Setting overrideRootPath to Empty String - -For stac-auth-proxy deployments, you often want to set `stac.overrideRootPath` to an empty string: - -```yaml -# values.yaml for eoapi-k8s -stac: +# values.yaml +stac-auth-proxy: enabled: true - overrideRootPath: "" # Empty string removes --root-path argument - ingress: - enabled: false # Disable external ingress for STAC + env: + UPSTREAM_URL: "http://eoapi-stac:8080" # Replace 'eoapi' with your release name + OIDC_DISCOVERY_URL: "https://your-auth-provider.com/.well-known/openid-configuration" + ALLOWED_JWT_AUDIENCES: "https://your-api-audience.com" # Recommended: should match the audience configured in your identity provider for this API. + ROOT_PATH: "/stac" ``` -**Important**: This configuration creates an **intentional inconsistency**: - -- **Ingress routes**: Still configured for `/stac` (if ingress was enabled) -- **FastAPI application**: Runs without any root path prefix (no `--root-path` argument) - -### Why This Works for stac-auth-proxy +For complete configuration options, see the [stac-auth-proxy configuration documentation](https://developmentseed.org/stac-auth-proxy/user-guide/configuration). -This behavior is specifically designed for stac-auth-proxy scenarios where: +### 3. Authentication Policy -1. **stac-auth-proxy** receives requests via its own ingress and handles the `/stac` path prefix -2. **stac-auth-proxy** filters requests managing the `/stac` prefix and forwards them directly to the STAC service without the path prefix -3. **STAC service** responds from its internal service as if it's running at the root path `/` - -### Configuration Examples - -#### Standard Deployment (with ingress) +Control which endpoints require authentication: ```yaml -stac: - enabled: true - # Default behavior - uses ingress.path as root-path - ingress: - enabled: true - path: "/stac" +stac-auth-proxy: + env: + # Set a default policy: read operations (GET) are public, write operations (POST, PUT, PATCH, DELETE) require authentication + DEFAULT_PUBLIC: "true" # This is "false" if not specified + + # Alternatively, you may set your custom policies (JSON objects) + PRIVATE_ENDPOINTS: | + { + "^/collections$": ["POST"], + "^/collections/([^/]+)$": ["PUT", "PATCH", "DELETE"], + "^/collections/([^/]+)/items$": ["POST"], + "^/collections/([^/]+)/items/([^/]+)$": ["PUT", "PATCH", "DELETE"] + } + + PUBLIC_ENDPOINTS: | + { + "^/$": ["GET"], + "^/conformance$": ["GET"], + "^/healthz": ["GET"] + } ``` -Result: FastAPI runs with `--root-path=/stac` - -#### stac-auth-proxy Deployment + Or, you can also create more complex custom filters (see [upstream documentation](https://developmentseed.org/stac-auth-proxy/user-guide/record-level-auth/#custom-filter-factories)). For this you will need to add the extra file and configure **all three** requirements: ```yaml -stac: - enabled: true - overrideRootPath: "" # Empty string - no --root-path argument - ingress: - enabled: false # Access via auth proxy only +stac-auth-proxy: + # 1. Set filter class environment variables + env: + COLLECTIONS_FILTER_CLS: stac_auth_proxy.custom_filters:CollectionsFilter + ITEMS_FILTER_CLS: stac_auth_proxy.custom_filters:ItemsFilter + + # 2. Specify custom filters file path + customFiltersFile: "data/stac-auth-proxy/custom_filters.py" + + # 3. Configure volume mount + extraVolumes: + - name: filters + configMap: + name: stac-auth-proxy-filters + extraVolumeMounts: + - name: filters + mountPath: /app/src/stac_auth_proxy/custom_filters.py + subPath: custom_filters.py + readOnly: true ``` -Result: FastAPI runs without `--root-path` argument - -#### Custom Root Path +**Note**: All three components are required. `customFiltersFile` creates the ConfigMap, `extraVolumes` references it, `extraVolumeMounts` loads it into the container. -```yaml -stac: - enabled: true - overrideRootPath: "/api/v1/stac" # Custom path -``` +## Root Path Behavior -Result: FastAPI runs with `--root-path=/api/v1/stac` +### Why `overrideRootPath: ""` -### Request Flow with stac-auth-proxy +stac-auth-proxy manages the `/stac` prefix and forwards requests without it to the STAC service. Setting `overrideRootPath: ""` removes the `--root-path` argument so FastAPI responds as if running at root `/`. -```mermaid -graph LR - A[Client Request: /stac/collections] --> B[stac-auth-proxy] - B -->|Authentication & Authorization| C[Forward: /collections] - C --> D[STAC Service: receives /collections] - D --> E[Response: collections data] - E --> B - B --> A +**Request flow**: +``` +Client: /stac/collections → Proxy: /collections → STAC service receives: /collections ``` - -## Additional Notes - -- The solution leverages Kubernetes service discovery for internal communication -- No changes required to the STAC service itself -- Zero downtime deployment possible -- Existing deployments without auth proxy remain compatible -- The `overrideRootPath: ""` configuration is specifically for proxy scenarios diff --git a/tests/integration/test_stac_auth.py b/tests/integration/test_stac_auth.py index bbdd0913..165c2f51 100644 --- a/tests/integration/test_stac_auth.py +++ b/tests/integration/test_stac_auth.py @@ -106,3 +106,119 @@ def test_stac_read_operations_work(stac_endpoint: str) -> None: resp = client.get(f"{stac_endpoint}/collections") assert resp.status_code == 200 + + +def test_stac_auth_custom_filters_mounted() -> None: + """Test that custom filters ConfigMap, env vars, and file mount work correctly.""" + import subprocess + + namespace = os.getenv("NAMESPACE", "eoapi") + release = os.getenv("RELEASE_NAME", "eoapi") + + # Check ConfigMap exists + result = subprocess.run( + [ + "kubectl", + "get", + "configmap", + "-n", + namespace, + "eoapi-stac-auth-proxy-custom-filters", + "-o", + "jsonpath={.data.custom_filters\\.py}", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip("Custom filters ConfigMap not found (feature may be disabled)") + + # Verify ConfigMap contains filter classes + assert "class CollectionsFilter" in result.stdout + assert "class ItemsFilter" in result.stdout + + # Get stac-auth-proxy pod name + result = subprocess.run( + [ + "kubectl", + "get", + "pod", + "-n", + namespace, + "-l", + f"app.kubernetes.io/name=stac-auth-proxy,app.kubernetes.io/instance={release}", + "-o", + "jsonpath={.items[0].metadata.name}", + ], + capture_output=True, + text=True, + check=True, + ) + pod_name = result.stdout.strip() + + if not pod_name: + pytest.skip("stac-auth-proxy pod not found") + + # Check env vars are set + result = subprocess.run( + [ + "kubectl", + "exec", + "-n", + namespace, + pod_name, + "--", + "printenv", + "COLLECTIONS_FILTER_CLS", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, "COLLECTIONS_FILTER_CLS env var not set" + assert ( + "stac_auth_proxy.custom_filters:CollectionsFilter" in result.stdout + ), f"Unexpected COLLECTIONS_FILTER_CLS value: {result.stdout}" + + result = subprocess.run( + [ + "kubectl", + "exec", + "-n", + namespace, + pod_name, + "--", + "printenv", + "ITEMS_FILTER_CLS", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, "ITEMS_FILTER_CLS env var not set" + assert ( + "stac_auth_proxy.custom_filters:ItemsFilter" in result.stdout + ), f"Unexpected ITEMS_FILTER_CLS value: {result.stdout}" + + # Check if custom_filters.py is mounted at correct path + result = subprocess.run( + [ + "kubectl", + "exec", + "-n", + namespace, + pod_name, + "--", + "cat", + "/app/src/stac_auth_proxy/custom_filters.py", + ], + capture_output=True, + text=True, + check=True, + ) + + # Verify mounted file contains expected filter classes + assert "class CollectionsFilter" in result.stdout + assert "class ItemsFilter" in result.stdout + assert 'return "1=1"' in result.stdout