Skip to content

Commit 75c40a1

Browse files
committed
Finalize example
1 parent 8e9baf0 commit 75c40a1

File tree

5 files changed

+87
-26
lines changed

5 files changed

+87
-26
lines changed

examples/opa/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ This example demonstrates how to integrate with an Open Policy Agent (OPA) to au
66

77
From the root directory, run:
88

9-
```
9+
```sh
1010
docker compose -f docker-compose.yaml -f examples/opa/docker-compose.yaml up
1111
```
12+
13+
## Testing OPA
14+
15+
```sh
16+
▶ curl -X POST "http://localhost:8181/v1/data/stac/cql2" \
17+
-H "Content-Type: application/json" \
18+
-d '{"input":{"payload": null}}'
19+
{"result":"private = true"}
20+
```
21+
22+
```sh
23+
▶ curl -X POST "http://localhost:8181/v1/data/stac/cql2" \
24+
-H "Content-Type: application/json" \
25+
-d '{"input":{"payload": {"sub": "user1"}}}'
26+
{"result":"1=1"}
27+
```

examples/opa/docker-compose.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ services:
1010
OIDC_DISCOVERY_URL: ${OIDC_DISCOVERY_URL:-http://localhost:8888/.well-known/openid-configuration}
1111
OIDC_DISCOVERY_INTERNAL_URL: ${OIDC_DISCOVERY_INTERNAL_URL:-http://oidc:8888/.well-known/openid-configuration}
1212
ITEMS_FILTER_CLS: opa_integration:OpaIntegration
13-
ITEMS_FILTER_ARGS: "[]"
13+
ITEMS_FILTER_ARGS: '["http://opa:8181", "stac/cql2"]'
1414
env_file:
1515
- path: .env
1616
required: false

examples/opa/policies/example.rego

Lines changed: 0 additions & 19 deletions
This file was deleted.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package stac
2+
3+
default cql2 := "private = true"
4+
5+
cql2 := "1=1" if {
6+
input.payload.sub != null
7+
}
Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,70 @@
11
"""Integration with Open Policy Agent (OPA) to generate CQL2 filters for requests to a STAC API."""
22

3-
import dataclasses
4-
from typing import Any
3+
import logging
4+
from dataclasses import dataclass, field
5+
from time import time
6+
from typing import Any, Callable
57

8+
import httpx
69

7-
@dataclasses.dataclass
10+
logger = logging.getLogger("stac_auth_proxy.opa_integration")
11+
12+
13+
@dataclass
14+
class cache:
15+
"""Cache results of a method call for a given key."""
16+
17+
key: Callable[[Any], Any]
18+
ttl: float = 5.0
19+
cache: dict[tuple[Any], tuple[Any, float]] = field(default_factory=dict)
20+
21+
def __call__(self, func):
22+
"""Decorate a function to cache its results."""
23+
24+
async def wrapped(_self, ctx, *args, **kwargs):
25+
key = self.key(ctx)
26+
if key in self.cache:
27+
result, timestamp = self.cache[key]
28+
age = time() - timestamp
29+
if age <= self.ttl:
30+
logger.debug("%r in cache, returning cached result", key)
31+
return result
32+
logger.debug("%r in cache, but expired.", key)
33+
else:
34+
logger.debug("%r not in cache, calling function", key)
35+
result = await func(_self, ctx, *args, **kwargs)
36+
self.cache[key] = (result, time())
37+
self.prune()
38+
return result
39+
40+
return wrapped
41+
42+
def prune(self):
43+
"""Prune the cache of expired items."""
44+
self.cache = {k: v for k, v in self.cache.items() if v[1] > time() - self.ttl}
45+
46+
47+
@dataclass
848
class OpaIntegration:
9-
"""Integration with Open Policy Agent (OPA) to generate CQL2 filters for requests to a STAC API."""
49+
"""Call Open Policy Agent (OPA) to generate CQL2 filters from request context."""
50+
51+
host: str
52+
decision: str
53+
54+
client: httpx.AsyncClient = field(init=False)
55+
56+
def __post_init__(self):
57+
"""Initialize the client."""
58+
self.client = httpx.AsyncClient(base_url=self.host)
1059

60+
@cache(
61+
key=lambda ctx: ctx["payload"]["sub"] if ctx.get("payload") else None,
62+
ttl=10,
63+
)
1164
async def __call__(self, context: dict[str, Any]) -> str:
1265
"""Generate a CQL2 filter for the request."""
13-
return "(1=1)"
66+
response = await self.client.post(
67+
f"/v1/data/{self.decision}",
68+
json={"input": context},
69+
)
70+
return response.raise_for_status().json()["result"]

0 commit comments

Comments
 (0)