Skip to content

Commit 8adf8dd

Browse files
committed
feat: sync monty-eoapi prod with staging changes
1 parent e5f80ed commit 8adf8dd

12 files changed

Lines changed: 442 additions & 596 deletions

File tree

applications/argocd/production/applications/montandon-eoapi/application.yaml

Lines changed: 77 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,48 @@ metadata:
88
spec:
99
project: default
1010
sources:
11+
1112
- repoURL: https://devseed.com/eoapi-k8s/
1213
chart: eoapi
13-
targetRevision: 0.10.0
14+
targetRevision: 0.11.2
1415
helm:
16+
valueFiles:
17+
- values/argocd.yaml
1518
valuesObject:
16-
ingress:
19+
postgrescluster:
20+
# Using azure databae
21+
enabled: false
22+
vector:
1723
enabled: false
18-
# host: "montandon-eoapi.ifrc.org"
19-
# tls:
20-
# enabled: true
21-
# secretName: montandon-eoapi-helm-secret-cert
22-
# annotations:
23-
# # increase the max body size to 100MB
24-
# nginx.ingress.kubernetes.io/proxy-body-size: "100m"
25-
# nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
26-
# nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
27-
# nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
2824
raster:
2925
enabled: false
26+
ingress:
27+
# Using stac-auth-proxy
28+
enabled: false
29+
30+
serviceAccount:
31+
create: true
32+
automount: true
33+
annotations:
34+
azure.workload.identity/client-id : "8bf208ec-d73c-42d1-a4a9-817d2936a883"
35+
labels:
36+
azure.workload.identity/use: "true"
37+
38+
postgresql:
39+
type: "external-secret"
40+
external:
41+
existingSecret:
42+
# Defined here: internal/montandon-eoapi-spc.yaml
43+
name: pgstac-secrets-montandon-eoapi
44+
keys:
45+
username: "DB_USER"
46+
password: "DB_PASSWORD"
47+
# Optional: if these are provided in the secret
48+
# Note: These values override external.host, external.port and external.database if defined
49+
host: "DB_HOST"
50+
database: "DB_NAME"
51+
port: "DB_PORT"
52+
3053
stac:
3154
image:
3255
tag: 6.1.2
@@ -59,86 +82,60 @@ spec:
5982
mountPath: /mnt/secrets-store
6083
readOnly: true
6184
extraVolumes:
85+
# Not required for eoAPI, but secrets-store.csi.k8s.io needs at least one pod to mount SecretProviderClass to sync Azure Key Vault with the Kubernetes secret pgstac-secrets-montandon-eoapi
6286
- name: azure-keyvault-secrets
6387
csi:
6488
driver: secrets-store.csi.k8s.io
6589
readOnly: true
6690
volumeAttributes:
6791
secretProviderClass: azure-secret-provider-montandon-eoapi
68-
vector:
69-
enabled: false
7092

71-
serviceAccount:
72-
create: true
73-
automount: true
74-
annotations:
75-
azure.workload.identity/client-id : "8bf208ec-d73c-42d1-a4a9-817d2936a883"
76-
labels:
77-
azure.workload.identity/use: "true"
78-
79-
# pgstacBootstrap:
80-
# enabled: true
81-
# settings:
82-
# annotations:
83-
# argocd.argoproj.io/hook: Sync
84-
# # labels:
85-
# # azure.workload.identity/use: "true"
86-
# # extraVolumes:
87-
# # - name: azure-keyvault-secrets
88-
# # csi:
89-
# # driver: secrets-store.csi.k8s.io
90-
# # readOnly: true
91-
# # volumeAttributes:
92-
# # secretProviderClass: azure-secret-provider-montandon-eoapi
93-
# queryables:
94-
# # configMap
95-
# - name: "stac-queryables.json"
96-
# configMapRef:
97-
# name: montandon-eoapi-stac-queryables
98-
# key: stac_queryables.json
99-
# indexFields: ["monty:hazard_codes", "monty:country_codes", "roles"]
100-
# deleteMissing: true
101-
postgresql:
102-
type: "external-secret"
103-
external:
104-
existingSecret:
105-
name: pgstac-secrets-montandon-eoapi
106-
keys:
107-
username: "DB_USER"
108-
password: "DB_PASSWORD"
109-
# Optional: if these are provided in the secret
110-
# Note: These values override external.host, external.port and external.database if defined
111-
host: "DB_HOST"
112-
database: "DB_NAME"
113-
port: "DB_PORT"
93+
pgstacBootstrap:
94+
enabled: true
95+
settings:
96+
loadSamples: false
97+
queryables:
98+
- name: "stac_queryables.json"
99+
indexFields: ["monty:hazard_codes","monty:country_codes","roles"]
100+
deleteMissing: true
101+
configMapRef:
102+
name: montandon-eoapi-stac-queryables
103+
key: stac_queryables.json
114104

115-
postgrescluster:
116-
enabled: false
117-
# instances:
118-
# - name: eoapi
119-
# replicas: 1
120-
# dataVolumeClaimSpec:
121-
# accessModes:
122-
# - "ReadWriteOnce"
123-
# resources:
124-
# requests:
125-
# storage: "600Gi"
126-
# cpu: "1024m"
127-
# memory: "3048Mi"
128105
- path: applications/argocd/production/applications/montandon-eoapi/internal/
129106
targetRevision: develop
130107
repoURL: https://github.com/IFRCGo/go-deploy.git
108+
helm:
109+
valuesObject:
110+
azure:
111+
clientID: 8bf208ec-d73c-42d1-a4a9-817d2936a883
112+
secretProviderClass:
113+
enabled: true
114+
keyvaultName: montandon-eoapi-producti
115+
131116
- repoURL: https://github.com/developmentseed/stac-auth-proxy.git
132-
targetRevision: v0.9.2
117+
targetRevision: v1.0.3
133118
path: helm/
134119
helm:
135120
valuesObject:
121+
# HealthCheck endpoints - https://github.com/developmentseed/stac-auth-proxy/pull/143
122+
startupProbe:
123+
httpGet:
124+
path: /stac/healthz
125+
livenessProbe:
126+
httpGet:
127+
path: /stac/healthz
128+
readinessProbe:
129+
httpGet:
130+
path: /stac/healthz
136131
env:
137132
UPSTREAM_URL: "http://montandon-eoapi-stac:8080"
138133
# UPSTREAM_URL: "https://montandon-eoapi.ifrc.org/stac"
139134
OIDC_DISCOVERY_URL: "https://goadmin.ifrc.org/o/.well-known/openid-configuration"
140135
OVERRIDE_HOST: "0"
141136
ROOT_PATH: "/stac"
137+
COLLECTIONS_FILTER_CLS: stac_auth_proxy.montandon_filters:CollectionsFilter
138+
ITEMS_FILTER_CLS: stac_auth_proxy.montandon_filters:ItemsFilter
142139
ingress:
143140
enabled: "true"
144141
host: "montandon-eoapi.ifrc.org"
@@ -147,6 +144,15 @@ spec:
147144
enabled: "true"
148145
secretName: "montandon-eoapi-helm-secret-cert"
149146
replicaCount: 1
147+
extraVolumes:
148+
- name: filters
149+
configMap:
150+
name: stac-auth-proxy-filters
151+
extraVolumeMounts:
152+
- name: filters
153+
mountPath: /app/src/stac_auth_proxy/montandon_filters.py
154+
subPath: montandon_filters.py
155+
readOnly: true
150156
destination:
151157
server: https://kubernetes.default.svc
152158
namespace: montandon-eoapi
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
apiVersion: v2
2+
name: montandon-eoapi-extra-manifests
3+
description: Montandon eoAPI extra manifests
4+
type: application
5+
6+
version: 0.1.0
7+
appVersion: "1.0"
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""
2+
CQL2 filter factories.
3+
4+
These classes will be initialized at the startup of the STAC Auth Proxy service and will
5+
be called for each request to collections/items endpoints in order to generate CQL2
6+
filters based on the JWT permissions.
7+
8+
docs: https://developmentseed.org/stac-auth-proxy/user-guide/record-level-auth/
9+
"""
10+
11+
import asyncio
12+
import dataclasses
13+
import os
14+
import time
15+
import logging
16+
from typing import Any, Literal, Optional, Sequence
17+
18+
import httpx
19+
20+
logger = logging.getLogger(__name__)
21+
22+
if not (UPSTREAM_URL := os.environ.get("UPSTREAM_URL")):
23+
raise ValueError("Failed to retrieve upstream URL")
24+
25+
26+
def cql2_in_query(
27+
variable: Literal["collection", "id"], collection_ids: Sequence[str]
28+
) -> str:
29+
"""
30+
Generate CQL2 query to see if value of variable matches any element of sequence of
31+
strings. Due to CQL2 syntax ambiguities around single element arrays with the "in"
32+
operator, we use a direct comparison when there's only one permitted collection.
33+
"""
34+
if not collection_ids:
35+
return "1=0"
36+
37+
if len(collection_ids) == 1:
38+
return f"{variable} = " + repr(list(collection_ids)[0])
39+
40+
return f"{variable} IN ({','.join(repr(c_id) for c_id in collection_ids)})"
41+
42+
43+
@dataclasses.dataclass
44+
class CollectionsFilter:
45+
"""
46+
CQL2 filter factory for collections based on JWT permissions.
47+
"""
48+
49+
collections_claim: str = "collections" # JWT claim with allowed collection IDs
50+
admin_claim: str = "superuser" # JWT claim indicating superuser status
51+
public_collections_filter: str = "(private IS NULL OR private = false)"
52+
53+
async def __call__(self, context: dict[str, Any]) -> str:
54+
jwt_payload: Optional[dict[str, Any]] = context.get("payload")
55+
56+
# Anonymous: no data
57+
if not jwt_payload:
58+
logger.debug("Anonymous user, no collections permitted to be viewed")
59+
return "1=0"
60+
61+
# Superuser: all data
62+
if jwt_payload.get(self.admin_claim) == "true":
63+
logger.debug(
64+
f"Superuser detected for sub {jwt_payload.get('sub')}, "
65+
"no filter applied for collections"
66+
)
67+
return "1=1" # No filter for superusers
68+
69+
# Authenticated user: Allowed to access collections mentioned in JWT
70+
permitted_collections = jwt_payload.get(self.collections_claim, [])
71+
return " OR ".join(
72+
[
73+
self.public_collections_filter,
74+
cql2_in_query("id", permitted_collections),
75+
]
76+
)
77+
78+
79+
@dataclasses.dataclass
80+
class ItemsFilter:
81+
"""
82+
CQL2 filter factory for items based on JWT permissions.
83+
"""
84+
85+
collections_claim: str = "collections" # JWT claim with allowed collection IDs
86+
admin_claim: str = "superuser" # JWT claim indicating superuser status
87+
public_collections_filter: str = "(private IS NULL OR private = false)"
88+
89+
cache_ttl: int = 30 # TTL for caching public collections, in seconds
90+
_client: httpx.AsyncClient = dataclasses.field(
91+
init=False,
92+
repr=False,
93+
default_factory=lambda: httpx.AsyncClient(base_url=UPSTREAM_URL),
94+
)
95+
_public_collections_cache: Optional[list[str]] = dataclasses.field(
96+
init=False, default=None, repr=False
97+
)
98+
_cache_expiry: float = dataclasses.field(init=False, default=0, repr=False)
99+
_cache_lock: asyncio.Lock = dataclasses.field(
100+
init=False, repr=False, default_factory=asyncio.Lock
101+
)
102+
103+
@property
104+
def _cached_public_collections(self) -> Optional[list[str]]:
105+
"""Return cached public collections if still valid, otherwise None."""
106+
if time.time() < self._cache_expiry:
107+
return self._public_collections_cache
108+
return None
109+
110+
@_cached_public_collections.setter
111+
def _cached_public_collections(self, value: list[str]) -> None:
112+
"""Set the cache with a new value and expiry time."""
113+
self._public_collections_cache = value
114+
self._cache_expiry = time.time() + self.cache_ttl
115+
116+
async def _get_public_collections_ids(self) -> list[str]:
117+
"""
118+
Retrieve IDs of public collections from the upstream API.
119+
Uses a lock to prevent concurrent requests from fetching the same data.
120+
"""
121+
# Return cached value if still valid (fast path without lock)
122+
if (cached := self._cached_public_collections) is not None:
123+
logger.debug("Using cached public collections")
124+
return cached
125+
126+
# Acquire lock to prevent concurrent fetches
127+
async with self._cache_lock:
128+
# Double-check cache after acquiring lock
129+
# Another coroutine might have populated it while we waited
130+
if (cached := self._cached_public_collections) is not None:
131+
logger.debug("Using cached public collections (after lock)")
132+
return cached
133+
134+
logger.debug("Fetching public collections from upstream API")
135+
136+
# First request uses params dict
137+
url: Optional[str] = "/collections"
138+
params: Optional[dict[str, Any]] = {
139+
"filter": self.public_collections_filter,
140+
"limit": 100,
141+
}
142+
143+
ids = []
144+
while url:
145+
try:
146+
response = await self._client.get(url, params=params)
147+
response.raise_for_status()
148+
data = response.json()
149+
except httpx.HTTPError:
150+
logger.exception(f"Failed to fetch {url!r}.")
151+
raise
152+
ids.extend(collection["id"] for collection in data["collections"])
153+
154+
# Subsequent requests use the "next" link URL directly (already has params)
155+
url = next(
156+
(link["href"] for link in data["links"] if link["rel"] == "next"),
157+
None,
158+
)
159+
params = None # Clear params after first request
160+
161+
# Update cache
162+
self._cached_public_collections = ids
163+
return ids
164+
165+
async def __call__(self, context: dict[str, Any]) -> str:
166+
jwt_payload: Optional[dict[str, Any]] = context.get("payload")
167+
168+
# Anonymous: no data
169+
if not jwt_payload:
170+
logger.debug("Anonymous user, no items permitted to be viewed")
171+
return "1=0"
172+
173+
# Superuser: all data
174+
if jwt_payload.get(self.admin_claim) == "true":
175+
logger.debug(
176+
f"Superuser detected for sub {jwt_payload.get('sub')}, "
177+
"no filter applied for items"
178+
)
179+
return "1=1"
180+
181+
# Everyone: Allowed access to items in public collections
182+
try:
183+
permitted_collections = set(await self._get_public_collections_ids())
184+
except httpx.HTTPError:
185+
logger.warning("Failed to fetch public collections.")
186+
permitted_collections = set()
187+
188+
# Authenticated user: Allowed to access items in collections mentioned in JWT
189+
if jwt_payload:
190+
permitted_collections.update(jwt_payload.get(self.collections_claim, []))
191+
192+
return cql2_in_query("collection", permitted_collections)

0 commit comments

Comments
 (0)