Skip to content

Commit 61a562c

Browse files
committed
feat(iam): add in-memory introspection cache
Route IAM introspection and JWKS requests through a loopback Go proxy with bounded TTL caches and request coalescing. Install the service in Core images, preserve operator configuration on upgrades, and cover cache behavior with race-enabled tests.
1 parent 88e3036 commit 61a562c

16 files changed

Lines changed: 2128 additions & 1 deletion

File tree

.github/workflows/tests.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ on:
1616
- '*.md'
1717

1818
jobs:
19+
GoTests:
20+
runs-on: ubuntu-24.04
21+
steps:
22+
- uses: actions/checkout@v7
23+
- uses: actions/setup-go@v6
24+
with:
25+
go-version-file: services/iam-cache/go.mod
26+
cache-dependency-path: services/iam-cache/go.mod
27+
- name: Test IAM cache
28+
working-directory: services/iam-cache
29+
run: |
30+
go test -race ./...
31+
go vet ./...
1932
Lint:
2033
runs-on: ubuntu-24.04
2134
strategy:
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"public_listen_address": "127.0.0.1:11110",
3+
"internal_listen_address": "127.0.0.1:11111",
4+
"core_url": "http://127.0.0.1:11010",
5+
"request_timeout": "5s",
6+
"introspection_cache_ttl": "15s",
7+
"introspection_cache_max_entries": 100000,
8+
"jwks_cache_ttl": "1m",
9+
"jwks_cache_max_entries": 1000
10+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[Unit]
2+
Description=Exordos IAM Cache Service
3+
After=network-online.target ec-user-api.service
4+
5+
[Service]
6+
TimeoutStopSec=10
7+
Restart=always
8+
RestartSec=5s
9+
KillSignal=SIGINT
10+
ExecStart=/usr/bin/exordos-iam-cache -config /etc/exordos_core/iam_cache.json
11+
12+
[Install]
13+
WantedBy=multi-user.target

exordos/images/bootstrap.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ if [[ -n "$PERSISTENT_DISK" ]]; then
8585
persist_migrate_complete
8686
fi
8787

88+
# Existing persistent installations predate the IAM cache configuration. Add
89+
# its default config only when it is absent so operator changes survive future
90+
# image updates.
91+
if [[ ! -f "$GC_CFG_DIR/iam_cache.json" ]]; then
92+
sudo install \
93+
-o root \
94+
-g root \
95+
-m 0644 \
96+
"$GC_PATH/etc/exordos_core/iam_cache.json.example" \
97+
"$GC_CFG_DIR/iam_cache.json"
98+
fi
99+
88100
# Create deprecated path
89101
mkdir -p /var/lib/exordos/data
90102

@@ -159,6 +171,7 @@ fi
159171
log "systemctl enable --now ec-services"
160172
sudo systemctl enable --now \
161173
ec-user-api \
174+
exordos-iam-cache \
162175
ec-orch-api \
163176
ec-status-api \
164177
ec-boot-api \

exordos/images/install.sh

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ GC_PG_PASS="exordos_core"
3232
GC_PG_DB="exordos_core"
3333

3434
SYSTEMD_SERVICE_DIR=/etc/systemd/system/
35+
IAM_CACHE_GO_VERSION="1.23.12"
3536

3637
DEV_SDK_PATH="/opt/gcl_sdk"
3738
SDK_DEV_MODE=$([ -d "$DEV_SDK_PATH" ] && echo "true" || echo "false")
@@ -141,12 +142,73 @@ sudo systemctl enable nginx
141142
# Install exordos core
142143
sudo mkdir -p $GC_CFG_DIR
143144
sudo cp "$GC_PATH/etc/exordos_core/logging.yaml" $GC_CFG_DIR/
145+
sudo install \
146+
-o root \
147+
-g root \
148+
-m 0644 \
149+
"$GC_PATH/etc/exordos_core/iam_cache.json.example" \
150+
"$GC_CFG_DIR/iam_cache.json"
144151
# Drop-in config dir loaded by ec-user-api via --config-dir. The notification
145152
# element lands its [events] override and event_type_mapping.yaml here; must
146153
# exist (oslo --config-dir errors on a missing directory).
147154
sudo mkdir -p $GC_CFG_DIR/exordos_core.d
148155
sudo cp "$GC_PATH/exordos/images/bootstrap.sh" $BOOTSTRAP_PATH/0100-ec-bootstrap.sh
149156

157+
# Build the IAM cache with a temporary Go toolchain. Only the stripped static
158+
# binary is installed into the image; the toolchain and every build cache are
159+
# removed both after a successful build and if the build fails.
160+
case "$(dpkg --print-architecture)" in
161+
amd64)
162+
IAM_CACHE_GO_ARCH="amd64"
163+
IAM_CACHE_GO_SHA256="d3847fef834e9db11bf64e3fb34db9c04db14e068eeb064f49af747010454f90"
164+
;;
165+
arm64)
166+
IAM_CACHE_GO_ARCH="arm64"
167+
IAM_CACHE_GO_SHA256="52ce172f96e21da53b1ae9079808560d49b02ac86cecfa457217597f9bc28ab3"
168+
;;
169+
*)
170+
echo "Unsupported architecture for the IAM cache: $(dpkg --print-architecture)" >&2
171+
exit 1
172+
;;
173+
esac
174+
175+
IAM_CACHE_BUILD_DIR=$(mktemp -d)
176+
cleanup_iam_cache_build() {
177+
if [[ -n "${IAM_CACHE_BUILD_DIR:-}" && -d "$IAM_CACHE_BUILD_DIR" ]]; then
178+
rm -rf -- "$IAM_CACHE_BUILD_DIR"
179+
fi
180+
}
181+
trap cleanup_iam_cache_build EXIT
182+
183+
curl -fsSLo "$IAM_CACHE_BUILD_DIR/go.tar.gz" \
184+
"https://go.dev/dl/go${IAM_CACHE_GO_VERSION}.linux-${IAM_CACHE_GO_ARCH}.tar.gz"
185+
echo "$IAM_CACHE_GO_SHA256 $IAM_CACHE_BUILD_DIR/go.tar.gz" \
186+
| sha256sum --check -
187+
tar -xzf "$IAM_CACHE_BUILD_DIR/go.tar.gz" -C "$IAM_CACHE_BUILD_DIR"
188+
189+
(
190+
cd "$GC_PATH/services/iam-cache"
191+
CGO_ENABLED=0 \
192+
GOCACHE="$IAM_CACHE_BUILD_DIR/go-cache" \
193+
GOPATH="$IAM_CACHE_BUILD_DIR/gopath" \
194+
"$IAM_CACHE_BUILD_DIR/go/bin/go" build \
195+
-buildvcs=false \
196+
-trimpath \
197+
-ldflags="-s -w" \
198+
-o "$IAM_CACHE_BUILD_DIR/exordos-iam-cache" \
199+
./cmd/exordos-iam-cache
200+
)
201+
sudo install \
202+
-o root \
203+
-g root \
204+
-m 0755 \
205+
"$IAM_CACHE_BUILD_DIR/exordos-iam-cache" \
206+
/usr/bin/exordos-iam-cache
207+
208+
cleanup_iam_cache_build
209+
trap - EXIT
210+
unset IAM_CACHE_BUILD_DIR
211+
150212
cd "$GC_PATH"
151213
uv sync
152214
source "$GC_PATH"/.venv/bin/activate
@@ -206,6 +268,7 @@ sudo cp "$GC_PATH/etc/systemd/ec-core-agent.service" $SYSTEMD_SERVICE_DIR
206268
sudo cp "$GC_PATH/etc/systemd/exordos-universal-agent.service" $SYSTEMD_SERVICE_DIR
207269
sudo cp "$GC_PATH/etc/systemd/exordos-universal-scheduler.service" $SYSTEMD_SERVICE_DIR
208270
sudo cp "$GC_PATH/etc/systemd/exordos-repo-proxy-gservice.service" $SYSTEMD_SERVICE_DIR
271+
sudo cp "$GC_PATH/etc/systemd/exordos-iam-cache.service" $SYSTEMD_SERVICE_DIR
209272

210273
# Prepare DNSaaS
211274
sudo systemctl disable --now pdns dnsdist@public dnsdist@private

exordos/manifests/core.yaml.j2

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ resources:
215215
host: 127.0.0.1
216216
port: 11010
217217
weight: 1
218+
core_lb_iam_cache_backend_http:
219+
project_id: "12345678-c625-4fee-81d5-f691897b8142"
220+
parent: $core.network.lb.$core_lb:uuid
221+
endpoints:
222+
- kind: host
223+
host: 127.0.0.1
224+
port: 11110
225+
weight: 1
218226
$core.network.lb.$core_lb.vhosts:
219227
core_lb_core_http:
220228
project_id: "12345678-c625-4fee-81d5-f691897b8142"
@@ -248,6 +256,30 @@ resources:
248256
- kind: rewrite_url
249257
regex: "^/api/core/(.*)"
250258
replacement: "/$1"
259+
core_lb_iam_clients:
260+
project_id: "12345678-c625-4fee-81d5-f691897b8142"
261+
parent: $core.network.lb.$core_lb.vhosts.$core_lb_core_http:uuid
262+
condition:
263+
kind: prefix
264+
value: /api/core/v1/iam/clients/
265+
allowed_ips:
266+
- 0.0.0.0/0
267+
actions:
268+
- kind: backend
269+
pool: $core.network.lb.$core_lb.backend_pools.$core_lb_iam_cache_backend_http:uuid
270+
protocol:
271+
kind: http
272+
modifiers:
273+
- kind: auto_header
274+
headers:
275+
- 'Host'
276+
- 'X-Forwarded-For'
277+
- 'X-Forwarded-Port'
278+
- 'X-Forwarded-Proto'
279+
- 'X-Forwarded-Prefix'
280+
- kind: rewrite_url
281+
regex: "^/api/core/(.*)"
282+
replacement: "/$1"
251283
core_lb_iam_default_client:
252284
project_id: "12345678-c625-4fee-81d5-f691897b8142"
253285
parent: $core.network.lb.$core_lb.vhosts.$core_lb_core_http:uuid
@@ -258,7 +290,7 @@ resources:
258290
- 0.0.0.0/0
259291
actions:
260292
- kind: backend
261-
pool: $core.network.lb.$core_lb.backend_pools.$core_lb_core_backend_http:uuid
293+
pool: $core.network.lb.$core_lb.backend_pools.$core_lb_iam_cache_backend_http:uuid
262294
protocol:
263295
kind: http
264296
modifiers:

services/iam-cache/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Exordos IAM Cache
2+
3+
`exordos-iam-cache` is an in-memory caching proxy for the Exordos Core IAM
4+
introspection and JWKS endpoints.
5+
6+
The public listener preserves the existing Core routes:
7+
8+
- `GET /v1/iam/clients/{client_uuid}/actions/introspect`
9+
- `GET /v1/iam/clients/{client_uuid}/actions/jwks`
10+
11+
All other IAM client requests are forwarded unchanged to Core and are never
12+
cached. Any request carrying `X-OTP`, including a token request, also bypasses
13+
the cache. Successful introspection responses without `X-OTP` are cached by
14+
access token. The token UUID is read from the validated access token's `jti`
15+
claim and is used by the reverse index.
16+
17+
The internal listener exposes an idempotent invalidation endpoint:
18+
19+
```text
20+
DELETE /internal/v1/cache/introspection/{token_uuid}
21+
```
22+
23+
Core does not call this endpoint in the first implementation. Until that
24+
integration is added, introspection entries expire only by their configured
25+
TTL, the access token expiration, or capacity eviction.
26+
27+
JWKS responses use a separate cache keyed by IAM client UUID and a separate
28+
TTL.
29+
30+
## Configuration
31+
32+
The deployment example is
33+
[`../../etc/exordos_core/iam_cache.json.example`](../../etc/exordos_core/iam_cache.json.example).
34+
Cache lifetimes and the upstream request timeout use Go duration syntax such
35+
as `15s`, `5m`, or `1h`. The deployed defaults are 15 seconds for
36+
introspection and one minute for JWKS.
37+
38+
The internal listener defaults to loopback. If it is exposed outside the host,
39+
protect it with the deployment's service-to-service authentication layer.
40+
41+
## Run
42+
43+
```bash
44+
go run ./cmd/exordos-iam-cache \
45+
-config ../../etc/exordos_core/iam_cache.json.example
46+
```
47+
48+
## Test
49+
50+
```bash
51+
go test -race ./...
52+
go vet ./...
53+
```
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright 2026 Genesis Corporation
2+
//
3+
// All Rights Reserved.
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License. You may obtain
7+
// a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
// License for the specific language governing permissions and limitations
15+
// under the License.
16+
17+
package main
18+
19+
import (
20+
"context"
21+
"errors"
22+
"flag"
23+
"fmt"
24+
"log"
25+
"net/http"
26+
"os"
27+
"os/signal"
28+
"syscall"
29+
"time"
30+
31+
"github.com/exordos/exordos_core/services/iam-cache/internal/app"
32+
)
33+
34+
func main() {
35+
if err := run(); err != nil {
36+
log.Fatal(err)
37+
}
38+
}
39+
40+
func run() error {
41+
configPath := flag.String(
42+
"config",
43+
"/etc/exordos_core/iam_cache.json",
44+
"path to the JSON configuration file",
45+
)
46+
flag.Parse()
47+
48+
config, err := app.LoadConfig(*configPath)
49+
if err != nil {
50+
return err
51+
}
52+
proxy := app.NewProxy(config)
53+
54+
publicServer := newHTTPServer(
55+
config.PublicListenAddress,
56+
proxy.PublicHandler(),
57+
)
58+
internalServer := newHTTPServer(
59+
config.InternalListenAddress,
60+
proxy.InternalHandler(),
61+
)
62+
63+
runContext, stop := signal.NotifyContext(
64+
context.Background(),
65+
syscall.SIGINT,
66+
syscall.SIGTERM,
67+
)
68+
defer stop()
69+
70+
serverErrors := make(chan error, 2)
71+
startServer("public", publicServer, serverErrors)
72+
startServer("internal", internalServer, serverErrors)
73+
74+
var runErr error
75+
select {
76+
case <-runContext.Done():
77+
case runErr = <-serverErrors:
78+
stop()
79+
}
80+
81+
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
82+
defer cancel()
83+
84+
publicErr := publicServer.Shutdown(shutdownContext)
85+
internalErr := internalServer.Shutdown(shutdownContext)
86+
return errors.Join(runErr, publicErr, internalErr)
87+
}
88+
89+
func newHTTPServer(address string, handler http.Handler) *http.Server {
90+
return &http.Server{
91+
Addr: address,
92+
Handler: handler,
93+
ReadHeaderTimeout: 5 * time.Second,
94+
IdleTimeout: 60 * time.Second,
95+
}
96+
}
97+
98+
func startServer(
99+
name string,
100+
server *http.Server,
101+
errorsChannel chan<- error,
102+
) {
103+
go func() {
104+
log.Printf("%s listener started on %s", name, server.Addr)
105+
err := server.ListenAndServe()
106+
if err != nil && !errors.Is(err, http.ErrServerClosed) {
107+
errorsChannel <- fmt.Errorf("%s listener: %w", name, err)
108+
}
109+
}()
110+
}
111+
112+
func init() {
113+
log.SetOutput(os.Stderr)
114+
log.SetFlags(log.Ldate | log.Ltime | log.LUTC)
115+
}

services/iam-cache/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/exordos/exordos_core/services/iam-cache
2+
3+
go 1.23.0

0 commit comments

Comments
 (0)