diff --git a/helm/AUTHORIZATION.md b/helm/AUTHORIZATION.md new file mode 100644 index 00000000..f4e8433c --- /dev/null +++ b/helm/AUTHORIZATION.md @@ -0,0 +1,150 @@ +# Authorization configuration guide + +The chart provides two levels of authorization: + +1. **[Route-level authorization](https://developmentseed.org/stac-auth-proxy/user-guide/route-level-auth/)**: Controls which API endpoints are accessible and by whom +2. **[Record-level authorization](https://developmentseed.org/stac-auth-proxy/user-guide/record-level-auth/)**: Filters collections and items based on user permissions + +## Route-Level Authorization + +Configure via `authorization.route` section in `values.yaml`. + +### Mode: `default` (Recommended) + +Public catalog with protected write operations. This is the most common configuration. + +```yaml +authorization: + route: + mode: "default" +``` + +This automatically sets `DEFAULT_PUBLIC=true`, making all read endpoints public while requiring authentication for write operations. + +### Mode: `custom` + +Define specific public and private endpoints with custom rules. + +```yaml +authorization: + route: + mode: "custom" + publicEndpoints: + "^/collections$": ["GET"] + "^/search$": ["GET", "POST"] + "^/api.html$": ["GET"] + "^/healthz": ["GET"] + privateEndpoints: + "^/collections$": [["POST", "collection:create"]] + "^/collections/([^/]+)$": [["PUT", "collection:update"], ["DELETE", "collection:delete"]] + "^/collections/([^/]+)/items$": [["POST", "item:create"]] +``` + +**Endpoint format:** +- `publicEndpoints`: Maps regex paths to HTTP methods arrays +- `privateEndpoints`: Maps regex paths to HTTP methods or `[method, scope]` tuples + - Scopes define required OAuth2 scopes for the operation + +### Mode: `disabled` + +No route-level authorization applied. + +```yaml +authorization: + route: + mode: "disabled" +``` + +## Record-Level Authorization + +Configure via `authorization.record` section in `values.yaml`. + +### Mode: `disabled` (Default) + +No record-level filtering applied. All collections and items are visible to authenticated users. + +```yaml +authorization: + record: + mode: "disabled" +``` + +### Mode: `custom` + +Use Python filter classes to control visibility of collections and items. + +```yaml +authorization: + record: + mode: "custom" + custom: + filtersFile: "data/custom_filters.py" +``` + +This automatically: +- Creates a ConfigMap from your Python file +- Mounts it at `/app/src/stac_auth_proxy/custom_filters.py` +- Sets `COLLECTIONS_FILTER_CLS=stac_auth_proxy.custom_filters:CollectionsFilter` +- Sets `ITEMS_FILTER_CLS=stac_auth_proxy.custom_filters:ItemsFilter` + +Review the stac-auth-proxy [documentation for more information on custom filters](https://developmentseed.org/stac-auth-proxy/user-guide/record-level-auth/#custom-filter-factories). + +### Mode: `opa` + +Use Open Policy Agent for policy-based filtering. + +```yaml +authorization: + record: + mode: "opa" + opa: + url: "http://opa-service:8181" + policy: "stac/items/allow" +``` + +This sets: +- `ITEMS_FILTER_CLS=stac_auth_proxy.filters.opa:Opa` +- `ITEMS_FILTER_ARGS='["http://opa-service:8181", "stac/items/allow"]'` + +## Some configuration examples + +### Example 1: Default for public catalog, protected writes + +```yaml +authorization: + route: + mode: "default" + record: + mode: "disabled" +``` + +### Example 2: Fully protected catalog + +```yaml +authorization: + route: + mode: "custom" + publicEndpoints: + "^/healthz": ["GET"] + privateEndpoints: + "^/collections$": [["GET", "stac:read"], ["POST", "stac:write"]] + "^/search$": [["GET", "stac:read"], ["POST", "stac:read"]] + record: + mode: "custom" + custom: + filtersFile: "data/custom_filters.py" +``` + +## Direct configuration + +Existing charts using `env` variables directly continue to work: + +```yaml +env: + DEFAULT_PUBLIC: "false" + PUBLIC_ENDPOINTS: '{"^/search$": ["GET"]}' + PRIVATE_ENDPOINTS: '{"^/collections$": [["POST", "collection:create"]]}' + ITEMS_FILTER_CLS: "custom.module:Filter" +``` + +**Environment variables specified in `env` take precedence over `authorization` settings.** diff --git a/helm/data/custom_filters.py b/helm/data/custom_filters.py new file mode 100644 index 00000000..47224674 --- /dev/null +++ b/helm/data/custom_filters.py @@ -0,0 +1,27 @@ +""" +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: + """Filter collections based on user permissions.""" + + async def __call__(self, context: dict[str, Any]) -> str: + """Return True if user can access this collection.""" + # Example: Allow all collections for authenticated users + return "1=1" + + +@dataclasses.dataclass +class ItemsFilter: + """Filter items based on user permissions.""" + + async def __call__(self, context: dict[str, Any]) -> str: + """Return True if user can access this item.""" + # Example: Allow all items for authenticated users + return "1=1" diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl index 8bab877e..cb52fd88 100644 --- a/helm/templates/_helpers.tpl +++ b/helm/templates/_helpers.tpl @@ -69,3 +69,76 @@ Render env var value based on type {{- . | toJson | quote -}} {{- end -}} {{- end -}} + +{{/* +Generate authorization environment variables +*/}} +{{- define "stac-auth-proxy.authorizationEnv" -}} +{{- $routeMode := .Values.authorization.route.mode | default "default" -}} +{{- $recordMode := .Values.authorization.record.mode | default "disabled" -}} + +{{- /* Route-level authorization */ -}} +{{- if eq $routeMode "default" -}} +{{- if not (hasKey .Values.env "DEFAULT_PUBLIC") }} +- name: DEFAULT_PUBLIC + value: "true" +{{- end }} +{{- else if eq $routeMode "custom" -}} +{{- if not (hasKey .Values.env "DEFAULT_PUBLIC") }} +- name: DEFAULT_PUBLIC + value: "false" +{{- end }} +{{- if and .Values.authorization.route.publicEndpoints (gt (len .Values.authorization.route.publicEndpoints) 0) (not (hasKey .Values.env "PUBLIC_ENDPOINTS")) }} +- name: PUBLIC_ENDPOINTS + value: {{ .Values.authorization.route.publicEndpoints | toJson | quote }} +{{- end }} +{{- if and .Values.authorization.route.privateEndpoints (gt (len .Values.authorization.route.privateEndpoints) 0) (not (hasKey .Values.env "PRIVATE_ENDPOINTS")) }} +- name: PRIVATE_ENDPOINTS + value: {{ .Values.authorization.route.privateEndpoints | toJson | quote }} +{{- end }} +{{- end }} + +{{- /* Record-level authorization */ -}} +{{- if eq $recordMode "custom" -}} +{{- if not (hasKey .Values.env "COLLECTIONS_FILTER_CLS") }} +- name: COLLECTIONS_FILTER_CLS + value: "stac_auth_proxy.custom_filters:CollectionsFilter" +{{- end }} +{{- if not (hasKey .Values.env "ITEMS_FILTER_CLS") }} +- name: ITEMS_FILTER_CLS + value: "stac_auth_proxy.custom_filters:ItemsFilter" +{{- end }} +{{- else if eq $recordMode "opa" -}} +{{- if not (hasKey .Values.env "ITEMS_FILTER_CLS") }} +- name: ITEMS_FILTER_CLS + value: "stac_auth_proxy.filters:opa.Opa" +{{- end }} +{{- if and (not (hasKey .Values.env "ITEMS_FILTER_ARGS")) .Values.authorization.record.opa }} +- name: ITEMS_FILTER_ARGS + value: {{ list .Values.authorization.record.opa.url .Values.authorization.record.opa.policy | toJson | quote }} +{{- end }} +{{- end }} +{{- end -}} + +{{/* +Generate authorization volumes +*/}} +{{- define "stac-auth-proxy.authorizationVolumes" -}} +{{- if and (eq (.Values.authorization.record.mode | default "disabled") "custom") .Values.authorization.record.custom.filtersFile }} +- name: custom-filters + configMap: + name: {{ include "stac-auth-proxy.fullname" . }}-filters +{{- end }} +{{- end -}} + +{{/* +Generate authorization volume mounts +*/}} +{{- define "stac-auth-proxy.authorizationVolumeMounts" -}} +{{- if and (eq (.Values.authorization.record.mode | default "disabled") "custom") .Values.authorization.record.custom.filtersFile }} +- name: custom-filters + mountPath: /app/src/stac_auth_proxy/custom_filters.py + subPath: custom_filters.py + readOnly: true +{{- end }} +{{- end -}} diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml new file mode 100644 index 00000000..be4eafe5 --- /dev/null +++ b/helm/templates/configmap.yaml @@ -0,0 +1,11 @@ +{{- if and (eq (.Values.authorization.record.mode | default "disabled") "custom") .Values.authorization.record.custom.filtersFile }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "stac-auth-proxy.fullname" . }}-filters + labels: + {{- include "stac-auth-proxy.labels" . | nindent 4 }} +data: + custom_filters.py: | +{{ .Files.Get .Values.authorization.record.custom.filtersFile | nindent 4 }} +{{- end }} diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 92d514fd..709cadc6 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -34,10 +34,21 @@ spec: resources: {{- toYaml .Values.resources | nindent 12 }} env: + {{- include "stac-auth-proxy.authorizationEnv" . | nindent 12 }} {{- range $key, $value := .Values.env }} - name: {{ $key }} value: {{ include "stac-auth-proxy.envValue" $value }} {{- end }} + volumeMounts: + {{- include "stac-auth-proxy.authorizationVolumeMounts" . | nindent 12 }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + {{- include "stac-auth-proxy.authorizationVolumes" . | nindent 8 }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: diff --git a/helm/tests/authorization_test.yaml b/helm/tests/authorization_test.yaml new file mode 100644 index 00000000..3bd433b0 --- /dev/null +++ b/helm/tests/authorization_test.yaml @@ -0,0 +1,220 @@ +suite: test authorization configuration +templates: + - deployment.yaml +tests: + # Route Authorization tests + - it: should set DEFAULT_PUBLIC=true when route mode is default + set: + authorization.route.mode: "default" + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DEFAULT_PUBLIC + value: "true" + + - it: should not set authorization env vars when route mode is disabled + set: + authorization.route.mode: "disabled" + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: DEFAULT_PUBLIC + + - it: should set custom route authorization when mode is custom + set: + authorization.route.mode: "custom" + authorization.route.publicEndpoints: + "^/collections$": ["GET"] + "^/search$": ["GET", "POST"] + authorization.route.privateEndpoints: + "^/collections$": [["POST", "collection:create"]] + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DEFAULT_PUBLIC + value: "false" + - contains: + path: spec.template.spec.containers[0].env + content: + name: PUBLIC_ENDPOINTS + value: '{"^/collections$":["GET"],"^/search$":["GET","POST"]}' + - contains: + path: spec.template.spec.containers[0].env + content: + name: PRIVATE_ENDPOINTS + value: '{"^/collections$":[["POST","collection:create"]]}' + + - it: should allow env vars to override route authorization + set: + authorization.route.mode: "default" + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + env.DEFAULT_PUBLIC: "false" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DEFAULT_PUBLIC + value: "false" + + # Record Authorization tests + - it: should not set filter env vars when record mode is disabled + set: + authorization.record.mode: "disabled" + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: ITEMS_FILTER_CLS + - notContains: + path: spec.template.spec.containers[0].env + content: + name: COLLECTIONS_FILTER_CLS + + - it: should set custom filter env vars when record mode is custom + set: + authorization.record.mode: "custom" + authorization.record.custom.filtersFile: "data/custom_filters.py" + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: COLLECTIONS_FILTER_CLS + value: "stac_auth_proxy.custom_filters:CollectionsFilter" + - contains: + path: spec.template.spec.containers[0].env + content: + name: ITEMS_FILTER_CLS + value: "stac_auth_proxy.custom_filters:ItemsFilter" + + - it: should create volume mount when record mode is custom + set: + authorization.record.mode: "custom" + authorization.record.custom.filtersFile: "data/custom_filters.py" + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: custom-filters + mountPath: /app/src/stac_auth_proxy/custom_filters.py + subPath: custom_filters.py + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: custom-filters + configMap: + name: RELEASE-NAME-stac-auth-proxy-filters + + - it: should set OPA filter env vars when record mode is opa + set: + authorization.record.mode: "opa" + authorization.record.opa.url: "http://opa-service:8181" + authorization.record.opa.policy: "stac/items/allow" + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: ITEMS_FILTER_CLS + value: "stac_auth_proxy.filters:opa.Opa" + - contains: + path: spec.template.spec.containers[0].env + content: + name: ITEMS_FILTER_ARGS + value: '["http://opa-service:8181","stac/items/allow"]' + + - it: should allow env vars to override record authorization + set: + authorization.record.mode: "opa" + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + env.ITEMS_FILTER_CLS: "custom.module:CustomFilter" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: ITEMS_FILTER_CLS + value: "custom.module:CustomFilter" + + # Raw configuration tests + - it: should preserve extraVolumes and extraVolumeMounts + set: + authorization.record.mode: "custom" + authorization.record.custom.filtersFile: "data/custom_filters.py" + extraVolumes: + - name: extra-volume + emptyDir: {} + extraVolumeMounts: + - name: extra-volume + mountPath: /extra + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: custom-filters + configMap: + name: RELEASE-NAME-stac-auth-proxy-filters + - contains: + path: spec.template.spec.volumes + content: + name: extra-volume + emptyDir: {} + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: custom-filters + mountPath: /app/src/stac_auth_proxy/custom_filters.py + subPath: custom_filters.py + readOnly: true + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: extra-volume + mountPath: /extra + + # Combined configuration test + - it: should handle both route and record authorization together + set: + authorization.route.mode: "custom" + authorization.route.publicEndpoints: + "^/search$": ["GET"] + authorization.record.mode: "opa" + authorization.record.opa.url: "http://opa:8181" + authorization.record.opa.policy: "stac/allow" + env.UPSTREAM_URL: "https://example.com" + env.OIDC_DISCOVERY_URL: "https://example.com/.well-known/openid-configuration" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DEFAULT_PUBLIC + value: "false" + - contains: + path: spec.template.spec.containers[0].env + content: + name: PUBLIC_ENDPOINTS + value: '{"^/search$":["GET"]}' + - contains: + path: spec.template.spec.containers[0].env + content: + name: ITEMS_FILTER_CLS + value: "stac_auth_proxy.filters:opa.Opa" diff --git a/helm/tests/configmap_test.yaml b/helm/tests/configmap_test.yaml new file mode 100644 index 00000000..29c99c20 --- /dev/null +++ b/helm/tests/configmap_test.yaml @@ -0,0 +1,51 @@ +suite: test configmap +templates: + - configmap.yaml +tests: + - it: should create configmap when record mode is custom with filtersFile + set: + authorization.record.mode: "custom" + authorization.record.custom.filtersFile: "data/custom_filters.py" + asserts: + - isKind: + of: ConfigMap + - equal: + path: metadata.name + value: RELEASE-NAME-stac-auth-proxy-filters + - isNotEmpty: + path: data + + - it: should not create configmap when record mode is disabled + set: + authorization.record.mode: "disabled" + asserts: + - hasDocuments: + count: 0 + + - it: should not create configmap when record mode is opa + set: + authorization.record.mode: "opa" + authorization.record.opa.url: "http://opa:8181" + authorization.record.opa.policy: "stac/items/allow" + asserts: + - hasDocuments: + count: 0 + + - it: should not create configmap when filtersFile is empty + set: + authorization.record.mode: "custom" + authorization.record.custom.filtersFile: "" + asserts: + - hasDocuments: + count: 0 + + - it: should include proper labels + set: + authorization.record.mode: "custom" + authorization.record.custom.filtersFile: "data/custom_filters.py" + asserts: + - isSubset: + path: metadata.labels + content: + app.kubernetes.io/name: stac-auth-proxy + app.kubernetes.io/instance: RELEASE-NAME diff --git a/helm/values.yaml b/helm/values.yaml index e791e0d2..4e56bdcb 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -21,6 +21,41 @@ ingress: enabled: true secretName: "" # If empty, will be auto-generated as "{host}-tls" +authorization: + + # Route-level authorization + route: + # Mode: "default" (public catalog, protected writes), "custom" (use custom endpoints), "disabled" (no auth) + mode: "default" + + # Custom endpoint configurations (only used when mode: "custom") + # NOTE: Setting these REPLACES the application's default endpoints, does not extend them. + # publicEndpoints: {} + # Example: + # "^/collections$": ["GET"] + # "^/search$": ["GET", "POST"] + # "^/api.html$": ["GET"] + # "^/healthz": ["GET"] + # privateEndpoints: {} + # Example: + # "^/collections$": [["POST", "collection:create"]] + # "^/collections/([^/]+)$": [["PUT", "collection:update"], ["DELETE", "collection:delete"]] + # "^/collections/([^/]+)/items$": [["POST", "item:create"]] + + # Record-level authorization + record: + # Mode: "disabled" (no filtering), "custom" (Python filters), "opa" (Open Policy Agent) + mode: "disabled" + + # Custom filters configuration (only used when mode: "custom") + # custom: + # filtersFile: "" # Path to custom filters Python file (e.g., "data/custom_filters.py") + + # OPA configuration (only used when mode: "opa") + # opa: + # url: "http://opa:8181" + # policy: "stac/items/allow" + resources: limits: cpu: 500m @@ -64,22 +99,28 @@ env: WAIT_FOR_UPSTREAM: true HEALTHZ_PREFIX: "/healthz" OIDC_DISCOVERY_INTERNAL_URL: "" - DEFAULT_PUBLIC: false - PRIVATE_ENDPOINTS: | - { - "^/collections$": ["POST"], - "^/collections/([^/]+)$": ["PUT", "PATCH", "DELETE"], - "^/collections/([^/]+)/items$": ["POST"], - "^/collections/([^/]+)/items/([^/]+)$": ["PUT", "PATCH", "DELETE"], - "^/collections/([^/]+)/bulk_items$": ["POST"] - } - PUBLIC_ENDPOINTS: | - { - "^/api.html$": ["GET"], - "^/api$": ["GET"], - "^/docs/oauth2-redirect": ["GET"], - "^/healthz": ["GET"] - } + + # Note: DEFAULT_PUBLIC, PUBLIC_ENDPOINTS, PRIVATE_ENDPOINTS are now managed + # via authorization.route section. Authorization settings from + # authorization.route and authorization.record sections will be + # automatically merged with these env vars. Manually specified env vars + # take precedence. You can still override them here if needed + # DEFAULT_PUBLIC: false + # PRIVATE_ENDPOINTS: | + # { + # "^/collections$": ["POST"], + # "^/collections/([^/]+)$": ["PUT", "PATCH", "DELETE"], + # "^/collections/([^/]+)/items$": ["POST"], + # "^/collections/([^/]+)/items/([^/]+)$": ["PUT", "PATCH", "DELETE"], + # "^/collections/([^/]+)/bulk_items$": ["POST"] + # } + # PUBLIC_ENDPOINTS: | + # { + # "^/api.html$": ["GET"], + # "^/api$": ["GET"], + # "^/docs/oauth2-redirect": ["GET"], + # "^/healthz": ["GET"] + # } @@ -93,4 +134,4 @@ serviceAccount: name: "" # Image pull secrets to add to the service account imagePullSecrets: [] - # - name: my-registry-secret \ No newline at end of file + # - name: my-registry-secret