Compare commits

..
Author SHA1 Message Date
Admin Bot c14b9650cd feat(gateway): allow the two machine callers, in the source of truth
CI / CI (pull_request) Successful in 2m45s
CI / CI (push) Successful in 3m32s
The live Secret carried a hand-written entry for poimen-llm, which a reconcile would
have dropped: the config Secret is managed from this file.

Add poimen-llm and paperless-llm to zitadelClientIds, and switch on
allowListedMachineIdentities so an allow-listed caller is admitted without a roles
claim. ZITADEL does not put roles in a machine-user token on this instance, so the
allow-list entry is what authorises the caller; the caller still proves possession of a
registered private key.
2026-10-28 00:13:10 -07:00
Admin Bot f8788335b8 feat(auth): authorise an allow-listed machine identity without a roles claim
CI / CI (pull_request) Successful in 2m16s
CI / CI (push) Successful in 3m29s
ZITADEL does not put roles into a machine-user access token on this instance. The
grant exists, the scope asks for urn:zitadel:iam:org:project:roles, and the claim
still never arrives; a colon-free role key makes no difference either. A service
account therefore cannot satisfy requiredCapability by any configuration.

auth.allowListedMachineIdentities admits such a caller when its client_id appears in
zitadelClientIds. The caller is still authenticated: it signed an assertion with a
private key whose public half ZITADEL holds. The allow-list entry is a reviewed line
of configuration, not a wildcard.

The new path is off by default, and a token that does state its roles is judged by the
role check as before, so the weaker rule never applies twice.
2026-10-27 23:38:48 -07:00
Admin Bot d521d7a48b feat(gateway): dispatch /v1/systemone by model
Clef-flash is a decision model, not a chat model. It serves Clef's own
System One / Jev API at /v1/systemone: a state plus a schema of typed
questions in, one probability per allowed option out, single prefill
pass, no decoding and no free-form text.

Router previously dispatched by body `model` only for
/v1/chat/completions, /v1/embeddings and /v1/rerank. A request to
/v1/systemone therefore fell through to the configured routes, found
none, and answered "no routes configured". Adding the path to the
dispatch predicate is the whole fix: routeByModel already sets
upstreamPath := path, so the upstream receives /v1/systemone unchanged.
There is deliberately no /rerank-style rewrite.

Config: `ornith:35b` is removed with the Ollama predictor, and its GPU
now serves clef-flash. `qwen2.5:3b-instruct` already pointed at qwen-cpu
and is unchanged. Callers of ornith:35b must move to `reasoning`, which
keeps its serving name while running Qwen3-30B-A3B.

Tests cover body dispatch, unknown_model parity with the other three
paths, and that the upstream path is not rewritten.
2026-10-27 23:32:24 -07:00
Admin Bot 67fe20d7a3 fix(gateway): allow egress to ingress-nginx for JWKS
CI / CI (pull_request) Successful in 2m20s
CI / CI (push) Successful in 3m20s
The service CIDR is 10.106.0.0/16, so the ipBlock 10.96.0.0/12 never covered
ingress-nginx (10.101.128.185). The JWKS fetch to zitadel.riotpiao.com therefore
hung until the context deadline: every caller got 403, and the pod failed its
liveness probe before serving traffic.

Add an egress rule for TCP 443 to the ingress-nginx namespace and point jwksUrl
back at the public host. The ZITADEL service port 8080 answers 404 for
/oauth/v2/keys, so the service endpoint is not usable.
2026-10-27 01:37:08 -07:00
Admin Bot 6acaec4a5c fix(gateway): fetch JWKS from the in-cluster ZITADEL service
CI / CI (pull_request) Successful in 2m7s
CI / CI (push) Successful in 3m26s
The public host resolves through the CoreDNS rewrite to ingress-nginx and the
fetch timed out, so the validator answered 403 with a context-deadline error on
every caller. Point jwksUrl at zitadel.iam.svc.cluster.local:8080, which the
NetworkPolicy already allows.
2026-10-27 01:28:10 -07:00
Admin Bot ba8800d565 fix(gateway): set jwksUrl so the dispatcher builds a validator
CI / CI (pull_request) Successful in 2m9s
CI / CI (push) Successful in 3m29s
cmd/gateway/main.go:144 constructs the adapter validator only when both
cfg.Auth.Enabled and cfg.Auth.JWKSURL are set. The config left jwksUrl unset on
purpose, so jwtValidator stayed nil and every adapter request passed with one
warning line:

  auth required but jwtValidator nil; request unauthenticated

That made auth.required cosmetic: sqs, workflow, memory, s3 and iam all answered
unauthenticated calls. Set the JWKS URL to the ZITADEL key endpoint.
2026-10-27 01:17:09 -07:00
Admin Bot 29f5c64fab feat(gateway): require a token on every adapter
CI / CI (pull_request) Successful in 3m0s
CI / CI (push) Successful in 3m26s
workflow, memory, s3 and iam served requests without any Authorization header.
Only sqs and the LLM models checked a token, so four upstreams were reachable
from inside the cluster with no authentication at all.

Set auth.required true on all five adapters. The dispatcher then enforces one
capability per method, derived from the verb
(internal/serviceadapter/router.go:142):

  GET/HEAD/OPTIONS -> "<service>:read"
  anything else    -> "<service>:write"

Those strings must exist as ZITADEL role keys with the colon. The project now
holds memory:read, memory:write, workflow:read, workflow:execute, s3:read,
s3:write, iam:read, iam:write, sqs:read and sqs:write, and the riotpiao user
grant carries them, so a browser session keeps working.

Correct the old comment: ZITADEL does store colon role keys. Verified by listing
the project roles, which returned all eleven colon keys.
2026-10-27 01:11:18 -07:00
Admin Bot 8875f36dde feat(auth): validate access tokens with the ZITADEL Go SDK
Replace the handwritten Authentik JWT checks with github.com/zitadel/zitadel-go/v3.

What the SDK now owns:
- OIDC discovery (client.Discover) to find issuer and JWKS URI
- remote, auto-refreshing JWKS (rp.NewRemoteKeySet)
- signature, expiry and issuer checks (op.NewAccessTokenVerifier,
  op.VerifyAccessToken)
- the verified claim set and role lookup (oauth.IntrospectionContext,
  IsGrantedRole)

What stays local:
- a multi-audience allow-list, because this gateway is a shared resource
  server and the SDK's oauth.WithJWT accepts exactly one client ID
- an adapter from IntrospectionContext to jwt.MapClaims so the rest of the
  gateway keeps one claim representation

Other changes:
- issuer is configuration-driven; the hardcoded authentik.riotpiao.com
  check is gone
- aud may be a string or an array (ZITADEL sends an array)
- CheckPermissions also reads urn:zitadel:iam:org:project:roles, the claim
  ZITADEL uses for project roles
- requiredCapability accepts a comma-separated list, so llm:inference and
  llm-inference both satisfy the LLM gate
- a five-segment token is reported as an encrypted JWE with the fix in the
  message, instead of failing as a bad signature

Config: auth.zitadelDomain turns the SDK path on; jwksUrl stays supported for
the legacy path and for tests.
2026-10-27 01:11:18 -07:00
727b9e1cb0 fix(proxy): admission-control review fixes missing from the #65 merge (#66)
CI / CI (push) Successful in 43m8s
Follow-up to #65, which is already merged. The review of that PR returned BLOCK with two high-severity defects; the fix commit was pushed **after** the merge landed, so none of it is in `main`.

## What happened

- `main` @ `369d51a` is the squash of the first commit only (`c5a91cf`).
- `d71c884` (review fixes) is on the old branch and was never merged — PR #65 is closed.
- Verified against `main` directly: `ADMISSION_MAX_INFLIGHT` 0 hits, `func (rw *responseWriter) Flush` 0 hits, `ModifyResponse` 0 hits, `Snapshot` 0 hits.
- The image in production (`forgejo.riotpiao.com/rock/api-gateway@sha256:64f2bfc1…`, 3/3 replicas) is built from that squash, so the defects below are **live right now**.

## Live state confirmed

`https://api.riotpiao.com/metrics` on the running pods:

```
observer_degraded 0
vllm_requests_running 1
vllm_kv_usage_perc 0.008293092588704587
admission_parked_total 0
engine_state_stale_total 0
```

The observer itself works: it is reading the real vLLM exposition body and the startup self-check passed (`observer_degraded 0`). No requests have parked yet, so the two defects below have not been observable in the wild — they are proven from code, not from a live failure.

## Defects fixed here

**1. The parked-stream keep-alive never runs in production (high).** `internal/tracing/middleware.go` wraps the `ResponseWriter` in a struct that embeds the interface and implements only `WriteHeader`/`Write`, so `w.(http.Flusher)` in `internal/proxy/proxy.go` is false for every real request (`cmd/gateway/main.go` → `tracing.Middleware` → `router`). `prewritten` therefore never becomes true, the heartbeat never starts, and a parked streaming request writes zero bytes for up to `ADMISSION_MAX_PARK_SECONDS` (120) — the client timeout the feature exists to prevent. Fixed by adding `Flush()` and `Unwrap()` to the wrapper.

**2. Upstream status masked once the SSE status is spent (high, latent behind 1).** `httputil.ReverseProxy` calls `rw.WriteHeader(res.StatusCode)` unconditionally; after the park wrote 200, net/http keeps 200 and the upstream error body is appended raw into the event stream, which a streaming client reads as an empty completion and reports as success. Fixed with a `ModifyResponse` that frames an upstream `>= 400` as an `event: error` when the status has already been written.

**3. Lost wakeup in the park loop.** `Get()` and `Tick()` were taken under two separate lock acquisitions, so a poll landing between them pushed the waiter onto the next generation's channel — up to one extra poll interval (2s) parked. `Snapshot()` now returns state and channel under one lock.

**4. `observer_degraded` pinned at 1 forever.** It was raised only on the *first* poll's parse failure and `SetObserverDegraded(false)` had no call site, so one truncated response pinned the gauge for the pod's life, while genuine metric-name drift appearing later was never reported at all. `markDegraded` now tracks current parsability in both directions and only touches the exporter on a change.

**5. `ADMISSION_MAX_INFLIGHT` silently defaulted.** Now set explicitly to `4` in `k8s/deployment.yaml` with the coupling to the engine's `--max-num-seqs` documented, instead of quietly inheriting `VLLM_MAX_NUM_SEQS`.

## Verification

`go build ./...` clean · `go vet ./...` clean · `go test ./...` 368 passed, 0 failed · `structural-check.sh` 0 errors (1 pre-existing `:latest` warning) · `go.mod` unchanged.

Cherry-picked onto `main` rather than merged from the old branch, so this diff is exactly the five fixes and nothing else: 5 files, +80/−8.

## Still open

The reviewer's BLOCK also rested on nine functions over CRAP>30 purely from 0% coverage — the deliberate "no new tests" decision. Worth recording that defect 1 is a one-line type assertion that a `httptest` case through the real middleware chain would have caught immediately. Raising it here so the decision is visible rather than implied.

---------

Co-authored-by: Poimen <[email protected]>
Reviewed-on: #66
Co-authored-by: poimen <[email protected]>
2026-09-30 12:19:24 +00:00
poimenandrock 369d51a5c2 feat(proxy): engine-wide LLM admission control via vLLM gauges (#65)
CI / CI (push) Successful in 41m20s
Closes #64

Replaces the per-pod concurrency semaphore as the effective cap with admission gated on the vLLM engine's own scheduler gauges.

## Why

`internal/proxy/proxy.go:119` built a semaphore of 4 slots *inside each gateway process*, and `api-gateway` runs 3 replicas, so the advertised cap was 12 while the engine's `--max-num-seqs` is 4. Nothing coordinated replicas, and the mismatch was invisible because the queue never rejected anything (`queue_full_requests_total 0`).

Measured on the engine while investigating a 10-minute TTFT:

- `vllm:cache_config_info{num_gpu_blocks="9527",block_size="16"}` = **152,432 tokens** of KV capacity for the whole engine. PP=2 splits layers, so both ranks hold KV for the same tokens — not 2x that.
- Uncached prefill runs at **262 tok/s** contended, 525 tok/s over 7d (`vllm:request_prefill_time_seconds_sum/count`).
- One request showed `llm_ttft_seconds_sum 590.981 / count 5` at the gateway and a `(480, 960]` s entry in `vllm:request_prefill_time_seconds_bucket`, with engine-side generation throughput at 0.0-0.1 tok/s throughout: a 65k-token prompt costs ~250 s of prefill and starves every other request while it runs.
- `vllm:num_preemptions_total = 0`; KV peaked at `0.5399`.

Reading the engine's own gauges is global by construction, needs no lock and no new datastore, and it also accounts for callers that never pass the gateway — namespace `poimen` is admitted directly on 80/8080 by `llm-serving-ingress`, and `memory/crates/mem-cli/src/kv_warmer.rs` is such a caller. A distributed lock would have been blind to that traffic.

## Changes

- **new `internal/proxy/engine_state.go`** — 2s scrape of `http://<addr>/metrics` on its own client (the proxy transports carry socket options tuned for inference), hand-rolled exposition parse, `Healthy=false` after 3 consecutive failed polls with `engine_state_stale_total` counted once at the transition, and a startup self-check that raises `observer_degraded` and logs at error level instead of degrading to fail-open silently.
- **admission gate** (`internal/proxy/scheduler.go`) — parks while `kv_cache_usage_perc >= 0.90` or `running + waiting >= ADMISSION_MAX_INFLIGHT`, bounded by `ADMISSION_MAX_PARK_SECONDS`, then `503 + Retry-After: 5`. Unknown or unhealthy state fails **open** on purpose: a metrics outage must not become an inference outage.
- **parked streaming heartbeat** (`internal/proxy/proxy.go`) — `stream: true` requests get 200 + `text/event-stream` and a `: keep-alive` comment every 15s while parked, so a client idle timeout does not kill a request that is legitimately waiting. Non-streaming requests park silently.
- **`internal/proxy/router.go`** — extract only the top-level `model` field with a `json.Decoder` token walk instead of unmarshalling the whole body into `map[string]interface{}` (that built the entire `messages`+`tools` tree per request, ~300 KB for a 65k-token prompt). `MaxBodySize` now comes from `MAX_BODY_BYTES`; the model path previously inherited the 100 MB default at `proxy.go:93` against a 512Mi container limit.
- **`cmd/gateway/main.go`** — `GOMAXPROCS` from the cgroup CPU quota (cgroup v2 `cpu.max`, v1 fallback). The container is limited to `500m` while Go defaulted to the host core count and was being CFS-throttled.
- **`k8s/deployment.yaml`** — `ADMISSION_MAX_PARK_SECONDS=120`, `ADMISSION_OBSERVE_MODELS=reasoning`, `VLLM_MAX_QUEUE_DEPTH=8`, `MAX_BODY_BYTES=1048576`; corrected the `llm-client` comment (the NetworkPolicy selects by namespace now, not by that label).
- **deleted `k8s/configmap.yaml`** — absent from `k8s/kustomization.yaml`, and the live ConfigMap of that name carries an unrelated key (`gw-config-fixed.yaml`) plus a stale routing table.
- **`internal/observability/`** — `admission_parked_total`, `admission_rejected_total{reason}`, `engine_state_stale_total`, and gauges `vllm_requests_running` / `vllm_kv_usage_perc` (the gateway's own reading, deliberately mirroring the vLLM series so a disagreement is visible) and `observer_degraded`. Names follow the existing convention, which carries no prefix.

Out of scope here, in `riotpiao-poimen/homelab#51`: the engine flags (`--max-num-partial-prefills`, `--max-long-partial-prefills`, `--long-prefill-token-threshold`, `--max-num-batched-tokens`, `--max-num-seqs`, `--gpu-memory-utilization`) and the four dashboard panels in `generate-dashboards.py`.

## Verification

- `go build ./...` clean, `go vet ./...` clean.
- `go test ./...` — 368 tests, 0 failures (baseline on `main` is 368/0).
- `structural-check.sh` on the changed files — 0 errors, 1 pre-existing warning (`:latest` image tag).
- `go.mod` / `go.sum` byte-identical: no new dependency.

Not yet verified: the real-engine load test (2x65k + 15x8k against `reasoning`). That run also produces the number for `ADMISSION_MAX_PARK_SECONDS`, which today is the provisional 120; retuning it is a follow-up since it is env-only and needs no rebuild.

## Accepted risks

- **No tests were added** for the observer or the gate (deliberate: production is the test bed). The mitigation is the startup self-check, which is production self-diagnosis rather than a test, plus the `observer_degraded` alert. A parse failure that outlives the first poll logs and surfaces as a metric; it does not silently disable gating without a signal.
- **The cap is soft, not hard.** Between polls each replica may admit more than one request, so there is no clean bound. Exposure is low (~11,500 requests over 7d ≈ 1.4/min) and the engine remains the hard limit — a race parks requests in vLLM's queue rather than over-committing KV.
- **`ADMISSION_MAX_INFLIGHT` is manually coupled to `--max-num-seqs`** and can drift silently. It defaults to `VLLM_MAX_NUM_SEQS`, and startup logs the effective value.
- The first scrape waits one interval, so the first ~2s of admission is ungated (fail-open). Scraping at `Start()` instead would make the gateway contact every observed backend before any request needs one, which is wasted work and a surprising side effect.

## Rollback

Revert this commit and redeploy. The admission path is fail-open, so the code can also be neutralised without reverting by unsetting `ADMISSION_OBSERVE_MODELS`. Separately, the orphaned cluster object can be removed by hand: `kubectl delete cm -n api api-gateway-config` (not a code change, deliberately left to a human).

---------
Reviewed-on: #65
Co-authored-by: poimen <[email protected]>
2026-09-30 10:56:10 +00:00
14 changed files with 1130 additions and 89 deletions
+15 -3
View File
@@ -5,39 +5,51 @@ go 1.26.0
require ( require (
github.com/MicahParks/keyfunc/v2 v2.1.0 github.com/MicahParks/keyfunc/v2 v2.1.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/zitadel/oidc/v3 v3.49.2
github.com/zitadel/zitadel-go/v3 v3.30.0
go.opentelemetry.io/otel v1.46.0 go.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0
go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/trace v1.46.0 go.opentelemetry.io/otel/trace v1.46.0
go.temporal.io/api v1.63.5 go.temporal.io/api v1.63.5
go.temporal.io/sdk v1.48.0 go.temporal.io/sdk v1.48.0
golang.org/x/net v0.58.0
google.golang.org/grpc v1.83.2 google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12 google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/go-chi/chi/v5 v5.3.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/mock v1.6.0 // indirect github.com/golang/mock v1.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/muhlemmer/httpforwarded v0.1.0 // indirect
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect
github.com/nexus-rpc/sdk-go v0.7.0 // indirect github.com/nexus-rpc/sdk-go v0.7.0 // indirect
github.com/robfig/cron v1.2.0 // indirect github.com/robfig/cron v1.2.0 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect github.com/stretchr/testify v1.12.1 // indirect
github.com/zitadel/schema v1.3.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.41.0 // indirect
+26
View File
@@ -1,11 +1,19 @@
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -23,8 +31,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -33,6 +45,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY=
github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0=
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 h1:2fELd+9sqUtNu6Fg//pw8YFsxOvp8vZ8hfP0nHhNI80= github.com/nexus-rpc/nexus-proto-annotations v0.1.0 h1:2fELd+9sqUtNu6Fg//pw8YFsxOvp8vZ8hfP0nHhNI80=
github.com/nexus-rpc/nexus-proto-annotations v0.1.0/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y= github.com/nexus-rpc/nexus-proto-annotations v0.1.0/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y=
github.com/nexus-rpc/sdk-go v0.7.0 h1:38NrfY5rLnZAiMMs2ZfCKI/CSDzdfJG+27iAgfA8bUI= github.com/nexus-rpc/sdk-go v0.7.0 h1:38NrfY5rLnZAiMMs2ZfCKI/CSDzdfJG+27iAgfA8bUI=
@@ -41,6 +57,8 @@ github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
@@ -48,6 +66,12 @@ github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUm
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zitadel/oidc/v3 v3.49.2 h1:yKvB2Hx6rVWp0vOTk1pEyXAPxJusgIHNmr20AxOohmQ=
github.com/zitadel/oidc/v3 v3.49.2/go.mod h1:HwoguOGo0eem0RK5Gb+P6Q4aQLVinJ9LhomlVEA57ck=
github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI=
github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw=
github.com/zitadel/zitadel-go/v3 v3.30.0 h1:yk/surQ+atLS/ocGaaJdHD/R6VbveFScihxJhIkGZ3Q=
github.com/zitadel/zitadel-go/v3 v3.30.0/go.mod h1:4R+aki0JFMwIPsr4CIVLYq5DRTVEAfLbehJpFNhFKQw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
@@ -87,6 +111,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+21
View File
@@ -0,0 +1,21 @@
package auth
import (
"strings"
"testing"
)
func TestValidateBearerToken_JWEGetsClearError(t *testing.T) {
ti := newTestIssuer(t)
host, port, _ := strings.Cut(strings.TrimPrefix(ti.server.URL, "http://"), ":")
v, err := NewZitadelValidator(t.Context(), ti.server.URL, nil, ZitadelConfig{
Domain: host, InsecurePort: port, HTTPClient: ti.server.Client(),
})
if err != nil {
t.Fatalf("NewZitadelValidator: %v", err)
}
_, err = v.ValidateBearerToken("Bearer a.b.c.d.e")
if err == nil || !strings.Contains(err.Error(), "encrypted JWE") {
t.Fatalf("expected a JWE-specific error, got %v", err)
}
}
+190 -32
View File
@@ -3,37 +3,141 @@ package auth
import ( import (
"context" "context"
"fmt" "fmt"
gohttp "net/http"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/MicahParks/keyfunc/v2" "github.com/MicahParks/keyfunc/v2"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth"
) )
// isValidIssuer checks if issuer is from Authentik (any provider/app). // issuerMatches reports whether iss is the configured issuer or a sub-path of
// Accepts: https://authentik.riotpiao.com/application/o/{provider}/ // it. Zitadel emits exactly "https://zitadel.riotpiao.com"; providers that
func isValidIssuer(iss string) bool { // mount an application path append a suffix (Authentik used
return strings.Contains(iss, "authentik.riotpiao.com/application/o/") && // "/application/o/<app>/"). A trailing slash on either side is ignored.
strings.HasSuffix(iss, "/") //
// An empty configured issuer disables the domain check; the JWKS signature
// check still applies. Production configuration always sets an issuer.
func issuerMatches(iss, configured string) bool {
if configured == "" {
return iss != ""
}
iss = strings.TrimSuffix(iss, "/")
configured = strings.TrimSuffix(configured, "/")
return iss == configured || strings.HasPrefix(iss, configured+"/")
} }
// Validator validates JWTs against Authentik JWKS. // audienceValues normalises the aud claim. Zitadel access tokens carry an
// Supports multi-issuer: any Authentik service account provider is accepted // array, older providers carry a single string.
// (portfolio-agent, memory-agent, api-gw, etc.) because all share the same func audienceValues(claims jwt.MapClaims) ([]string, bool) {
// JWKS signing key. switch v := claims["aud"].(type) {
case string:
return []string{v}, v != ""
case []string:
return v, len(v) > 0
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out, len(out) > 0
case jwt.ClaimStrings:
return []string(v), len(v) > 0
}
return nil, false
}
// Validator validates JWTs against the JWKS of an OIDC provider (Zitadel).
// The issuer is configuration-driven: tokens whose iss claim equals the
// configured issuer, or lives under it as a sub-path, are accepted.
//
// When a ZitadelVerifier is attached (see NewZitadelValidator), validation
// delegates to the official ZITADEL Go SDK and this type only adapts the
// result to jwt.MapClaims for the rest of the gateway.
type Validator struct { type Validator struct {
issuer string // Not used for validation (kept for logging); issuer regex check is sufficient issuer string // Expected issuer (iss claim); empty disables the domain check
audience string // Not used for validation; any audience from valid Authentik issuer is accepted audience string // Expected audience; currently logged only (any non-empty aud is accepted)
jwksURL string jwksURL string
jwks *keyfunc.JWKS jwks *keyfunc.JWKS
mu sync.Mutex mu sync.Mutex
zitadel *ZitadelVerifier // non-nil when backed by the ZITADEL SDK
}
// NewZitadelValidator builds a Validator backed by the ZITADEL Go SDK.
// issuer is the expected iss claim, used for logging and for the legacy
// claim checks if the SDK verifier is ever bypassed. clientIDs is the
// audience allow-list. Discovery runs here, so a wrong domain fails at
// startup.
func NewZitadelValidator(ctx context.Context, issuer string, clientIDs []string, cfg ZitadelConfig) (*Validator, error) {
return NewZitadelValidatorWithClient(ctx, issuer, clientIDs, cfg, nil)
}
// NewZitadelValidatorWithClient is NewZitadelValidator with an injected HTTP
// client, for tests that serve discovery and JWKS from httptest.
func NewZitadelValidatorWithClient(ctx context.Context, issuer string, clientIDs []string, cfg ZitadelConfig, httpClient *gohttp.Client) (*Validator, error) {
cfg.ClientIDs = clientIDs
if cfg.HTTPClient == nil {
cfg.HTTPClient = httpClient
}
sdk, err := NewZitadelVerifier(ctx, cfg)
if err != nil {
return nil, err
}
return &Validator{
issuer: issuer,
jwksURL: "", // unused on the SDK path
zitadel: sdk,
}, nil
}
// claimsFromIntrospection converts the SDK's verified claim set into
// jwt.MapClaims so the rest of the gateway keeps one claim representation.
// The SDK has already checked the signature, exp, nbf and iss; this only
// copies values across.
func claimsFromIntrospection(authCtx *oauth.IntrospectionContext) jwt.MapClaims {
claims := jwt.MapClaims{}
if authCtx == nil {
return claims
}
for k, v := range authCtx.Claims {
claims[k] = v
}
if authCtx.Issuer != "" {
claims["iss"] = authCtx.Issuer
}
if authCtx.Subject != "" {
claims["sub"] = authCtx.Subject
}
if len(authCtx.Audience) > 0 {
aud := make([]interface{}, 0, len(authCtx.Audience))
for _, a := range authCtx.Audience {
aud = append(aud, a)
}
claims["aud"] = aud
}
if authCtx.Expiration != 0 {
claims["exp"] = float64(authCtx.Expiration)
}
if authCtx.NotBefore != 0 {
claims["nbf"] = float64(authCtx.NotBefore)
}
if authCtx.IssuedAt != 0 {
claims["iat"] = float64(authCtx.IssuedAt)
}
if authCtx.ClientID != "" {
claims["client_id"] = authCtx.ClientID
}
return claims
} }
// NewValidator creates a new JWT validator for a service. // NewValidator creates a new JWT validator for a service.
// issuer and audience params are deprecated (ignored for validation) but kept // issuer is the expected iss claim (exact value or prefix). audience is kept
// for backward compatibility. Multi-issuer validation via isValidIssuer() is used instead. // for compatibility; only non-emptiness is enforced. JWKS fetching is lazy
// JWKS fetching is lazy (deferred until first validation). // (deferred until first validation).
func NewValidator(issuer, audience, jwksURL string) *Validator { func NewValidator(issuer, audience, jwksURL string) *Validator {
return &Validator{ return &Validator{
issuer: issuer, // deprecated param, kept for compat issuer: issuer, // deprecated param, kept for compat
@@ -87,8 +191,31 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error
} }
// Ensure JWKS is loaded (lazy) // Ensure JWKS is loaded (lazy)
if err := v.ensureJWKS(); err != nil { if v.jwks != nil || v.zitadel == nil {
return nil, err if err := v.ensureJWKS(); err != nil {
return nil, err
}
}
// SDK path: ZITADEL Go SDK performs signature, expiry and issuer checks.
if v.zitadel != nil {
// ZITADEL issues two access-token shapes. A plain JWS has three
// dot-separated segments and can be verified locally. A JWE has five,
// is encrypted with the instance key, and only ZITADEL can read it.
// Report that case explicitly so the operator changes the client to
// OIDC_TOKEN_TYPE_BEARER instead of debugging a signature failure.
if strings.Count(tokenString, ".") == 4 {
return nil, fmt.Errorf("token is an encrypted JWE (5 segments); " +
"set accessTokenType=OIDC_TOKEN_TYPE_BEARER on the ZITADEL client so the gateway can verify it locally")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
authCtx, err := v.zitadel.CheckAuthorization(ctx, authHeader)
if err != nil {
return nil, fmt.Errorf("token validation failed: %v", err)
}
return claimsFromIntrospection(authCtx), nil
} }
// Parse and validate // Parse and validate
@@ -120,34 +247,35 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error
} }
} }
// Check iss (issuer) - accept any Authentik provider issuer // Check iss against the configured issuer (exact match or sub-path).
// (portfolio-agent, memory-agent, api-gw, etc.)
// All use same signing key so JWKS validation is sufficient
if iss, ok := claims["iss"].(string); !ok { if iss, ok := claims["iss"].(string); !ok {
return nil, fmt.Errorf("missing issuer claim") return nil, fmt.Errorf("missing issuer claim")
} else if !isValidIssuer(iss) { } else if !issuerMatches(iss, v.issuer) {
return nil, fmt.Errorf("invalid issuer: %s", iss) return nil, fmt.Errorf("invalid issuer: %s (want %s)", iss, v.issuer)
} }
// Check aud (audience) - accept any Authentik-provided audience // Check aud - Zitadel sends an array; accept a string or array.
// since all Authentik service accounts use the same signing key. if auds, ok := audienceValues(claims); !ok {
// The issuer check above is sufficient to ensure JWT came from Authentik.
if aud, ok := claims["aud"].(string); !ok {
return nil, fmt.Errorf("missing audience claim") return nil, fmt.Errorf("missing audience claim")
} else if aud == "" { } else if len(auds) == 0 {
return nil, fmt.Errorf("empty audience claim") return nil, fmt.Errorf("empty audience claim")
} }
// Note: Not hardcoding expected audience. Any audience from a valid Authentik // The signature check plus the issuer check establish the trust boundary;
// issuer is accepted, since all service accounts are under the same trust boundary. // the audience value itself is not hardcoded.
return claims, nil return claims, nil
} }
// CheckPermissions checks if claims contain required permission(s). // CheckPermissions checks if claims contain required permission(s).
// Checks both "permissions" claim (for users) and "roles" claim (for service accounts). // Understands three token shapes:
// - "permissions": []string (generic / legacy tokens)
// - "roles": []string or map[string]… (legacy service-account tokens)
// - "urn:zitadel:iam:org:project:roles": map[string]… (Zitadel access tokens,
// where the map keys are role keys)
//
// Returns true if any required permission is found or wildcard "*" exists. // Returns true if any required permission is found or wildcard "*" exists.
func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) bool { func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) bool {
// Try permissions claim first (for user tokens) // 1. permissions claim (array of strings)
if permsIface, ok := claims["permissions"]; ok { if permsIface, ok := claims["permissions"]; ok {
if perms, ok := permsIface.([]interface{}); ok { if perms, ok := permsIface.([]interface{}); ok {
if v.checkPermList(perms, required...) { if v.checkPermList(perms, required...) {
@@ -156,15 +284,45 @@ func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) b
} }
} }
// Fall back to roles claim (for service account tokens) // 2. roles claim: array (legacy) or map keyed by role name
if rolesIface, ok := claims["roles"]; ok { if rolesIface, ok := claims["roles"]; ok {
if roles, ok := rolesIface.([]interface{}); ok { switch roles := rolesIface.(type) {
case []interface{}:
if v.checkPermList(roles, required...) { if v.checkPermList(roles, required...) {
return true return true
} }
case map[string]interface{}:
if v.checkPermMap(roles, required...) {
return true
}
} }
} }
// 3. Zitadel access-token role assertion: map keyed by role key
if rolesIface, ok := claims["urn:zitadel:iam:org:project:roles"]; ok {
if roles, ok := rolesIface.(map[string]interface{}); ok {
if v.checkPermMap(roles, required...) {
return true
}
}
}
return false
}
// checkPermMap reports whether any key in a role map matches a required
// permission, or the map carries a wildcard key.
func (v *Validator) checkPermMap(roles map[string]interface{}, required ...string) bool {
for roleKey := range roles {
if roleKey == "*" {
return true
}
for _, req := range required {
if roleKey == req {
return true
}
}
}
return false return false
} }
+157
View File
@@ -0,0 +1,157 @@
package auth
import (
"context"
"errors"
"fmt"
gohttp "net/http"
"slices"
"strings"
"github.com/zitadel/oidc/v3/pkg/client"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth"
"github.com/zitadel/zitadel-go/v3/pkg/zitadel"
)
// ErrAudienceNotAllowed reports a token whose aud claim names none of the
// configured resource-server client IDs.
var ErrAudienceNotAllowed = errors.New("audience not allowed")
// ZitadelConfig configures the ZITADEL-backed verifier.
type ZitadelConfig struct {
// Domain is the ZITADEL instance host, for example "zitadel.riotpiao.com".
Domain string
// InsecurePort, when non-empty, speaks plain HTTP to that port instead of
// HTTPS on 443. Use only for a local port-forward.
InsecurePort string
// ClientIDs is the audience allow-list: a token is accepted when its aud
// claim contains at least one of these client IDs. Empty accepts any
// non-empty audience, which trusts every client of the same instance.
ClientIDs []string
// HTTPClient overrides the client used for discovery and JWKS fetches.
HTTPClient *gohttp.Client
}
// ZitadelVerifier validates ZITADEL access tokens with the official
// github.com/zitadel/zitadel-go SDK.
//
// It reuses the SDK's building blocks rather than reimplementing JWT parsing:
// - zitadel.New + Origin to address the instance
// - client.Discover for OIDC discovery (issuer and JWKS URI)
// - rp.NewRemoteKeySet for cached, auto-refreshing signing keys
// - op.NewAccessTokenVerifier / op.VerifyAccessToken for signature, expiry
// and issuer checks
// - oauth.IntrospectionContext for the verified claim set and its
// IsGrantedRole helper, which already understands the
// urn:zitadel:iam:org:project:roles claim
//
// The only behaviour added on top of the SDK is a multi-value audience
// allow-list, because this gateway is a shared resource server: callers hold
// their own client IDs (paperless, poimen, api-gw, ...) and the SDK's
// oauth.WithJWT helper accepts exactly one.
type ZitadelVerifier struct {
instance *zitadel.Zitadel
verifier *op.AccessTokenVerifier
clientIDs []string
}
// NewZitadelVerifier performs OIDC discovery and returns a ready verifier.
// Discovery happens once at startup so a misconfigured domain fails fast
// instead of on the first request.
func NewZitadelVerifier(ctx context.Context, cfg ZitadelConfig) (*ZitadelVerifier, error) {
if cfg.Domain == "" {
return nil, errors.New("zitadel: domain is required")
}
httpClient := cfg.HTTPClient
if httpClient == nil {
httpClient = gohttp.DefaultClient
}
var opts []zitadel.Option
if cfg.InsecurePort != "" {
opts = append(opts, zitadel.WithInsecure(cfg.InsecurePort))
}
instance := zitadel.New(cfg.Domain, opts...)
discovery, err := client.Discover(ctx, instance.Origin(), httpClient)
if err != nil {
return nil, fmt.Errorf("zitadel: OIDC discovery at %s: %w", instance.Origin(), err)
}
keySet := rp.NewRemoteKeySet(httpClient, discovery.JwksURI)
verifier := op.NewAccessTokenVerifier(discovery.Issuer, keySet)
return &ZitadelVerifier{
instance: instance,
verifier: verifier,
clientIDs: append([]string(nil), cfg.ClientIDs...),
}, nil
}
// Origin returns the instance origin used for discovery, for logging.
func (z *ZitadelVerifier) Origin() string {
if z == nil || z.instance == nil {
return ""
}
return z.instance.Origin()
}
// CheckAuthorization validates an "Authorization: Bearer <token>" header and
// returns the SDK's verified introspection context.
func (z *ZitadelVerifier) CheckAuthorization(ctx context.Context, authHeader string) (*oauth.IntrospectionContext, error) {
accessToken, ok := strings.CutPrefix(authHeader, oidc.BearerToken)
if !ok {
return nil, fmt.Errorf("%w: expected %q prefix", oauth.ErrInvalidAuthorizationHeader, oidc.BearerToken)
}
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return nil, fmt.Errorf("%w: empty bearer token", oauth.ErrInvalidAuthorizationHeader)
}
claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, z.verifier)
if err != nil {
return nil, fmt.Errorf("%w: %v", oauth.ErrInvalidToken, err)
}
if len(claims.Audience) == 0 {
return nil, fmt.Errorf("%w: empty aud", oauth.ErrInvalidToken)
}
if !z.audienceAllowed(claims.Audience) {
return nil, fmt.Errorf("%w: %v", ErrAudienceNotAllowed, claims.Audience)
}
authCtx := &oauth.IntrospectionContext{
IntrospectionResponse: oidc.IntrospectionResponse{
Active: true,
Issuer: claims.Issuer,
Subject: claims.Subject,
Audience: claims.Audience,
Expiration: claims.Expiration,
IssuedAt: claims.IssuedAt,
NotBefore: claims.NotBefore,
ClientID: claims.ClientID,
JWTID: claims.JWTID,
Claims: claims.Claims,
},
}
authCtx.SetToken(accessToken)
return authCtx, nil
}
// audienceAllowed reports whether any aud entry is in the allow-list.
// An empty allow-list accepts every non-empty audience.
func (z *ZitadelVerifier) audienceAllowed(audience []string) bool {
if len(z.clientIDs) == 0 {
return true
}
for _, aud := range audience {
if slices.Contains(z.clientIDs, aud) {
return true
}
}
return false
}
+294
View File
@@ -0,0 +1,294 @@
package auth
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// testIssuer mimics one ZITADEL instance: OIDC discovery on
// /.well-known/openid-configuration and the JWKS on /oauth/v2/keys.
type testIssuer struct {
server *httptest.Server
key *rsa.PrivateKey
kid string
jwksHit int
}
func newTestIssuer(t *testing.T) *testIssuer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
ti := &testIssuer{key: key, kid: "test-kid-1"}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": ti.server.URL,
"jwks_uri": ti.server.URL + "/oauth/v2/keys",
})
})
mux.HandleFunc("/oauth/v2/keys", func(w http.ResponseWriter, r *http.Request) {
ti.jwksHit++
pub := key.PublicKey
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]any{{
"kty": "RSA",
"kid": ti.kid,
"use": "sig",
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
}},
})
})
ti.server = httptest.NewServer(mux)
t.Cleanup(ti.server.Close)
return ti
}
// sign mints an access token with the given audience and project roles.
func (ti *testIssuer) sign(t *testing.T, aud []string, roles map[string]any) string {
t.Helper()
claims := jwt.MapClaims{
"iss": ti.server.URL,
"sub": "396757800000000001",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
if len(aud) > 0 {
audClaim := make([]interface{}, 0, len(aud))
for _, a := range aud {
audClaim = append(audClaim, a)
}
claims["aud"] = audClaim
}
if roles != nil {
claims["urn:zitadel:iam:org:project:roles"] = roles
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = ti.kid
signed, err := tok.SignedString(ti.key)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
// verifierFor builds a ZitadelVerifier pointed at the httptest instance.
// The SDK is given the loopback host and its port, so discovery goes to the
// test server rather than a real domain.
func verifierFor(t *testing.T, ti *testIssuer, clientIDs []string) *ZitadelVerifier {
t.Helper()
host, port, ok := strings.Cut(strings.TrimPrefix(ti.server.URL, "http://"), ":")
if !ok {
t.Fatalf("unexpected test server URL %q", ti.server.URL)
}
v, err := NewZitadelVerifier(context.Background(), ZitadelConfig{
Domain: host,
InsecurePort: port,
ClientIDs: clientIDs,
HTTPClient: ti.server.Client(),
})
if err != nil {
t.Fatalf("NewZitadelVerifier: %v", err)
}
return v
}
func TestZitadelVerifier_AcceptsTokenWithAllowedAudience(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, []string{"396757803559027071", "396757800874672511"})
token := ti.sign(t, []string{"396757803559027071"}, map[string]any{
"llm:inference": map[string]any{"396757796898471937": "homelab"},
})
authCtx, err := v.CheckAuthorization(context.Background(), "Bearer "+token)
if err != nil {
t.Fatalf("expected token to pass, got %v", err)
}
if !authCtx.IsAuthorized() {
t.Error("expected IsAuthorized() true")
}
if want := "396757800000000001"; authCtx.UserID() != want {
t.Errorf("UserID() = %q, want %q", authCtx.UserID(), want)
}
if !authCtx.IsGrantedRole("llm:inference") {
t.Error("expected SDK IsGrantedRole to see llm:inference from the project roles claim")
}
if ti.jwksHit == 0 {
t.Error("expected the SDK to fetch the JWKS")
}
}
func TestZitadelVerifier_RejectsForeignAudience(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, []string{"396757803559027071"})
token := ti.sign(t, []string{"999999999999999999"}, nil)
_, err := v.CheckAuthorization(context.Background(), "Bearer "+token)
if err == nil {
t.Fatal("expected rejection for an audience outside the allow-list")
}
if !strings.Contains(err.Error(), ErrAudienceNotAllowed.Error()) {
t.Errorf("expected ErrAudienceNotAllowed, got %v", err)
}
}
func TestZitadelVerifier_AcceptsAnyAudienceWhenAllowListEmpty(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, nil)
token := ti.sign(t, []string{"396757803877794175"}, nil)
if _, err := v.CheckAuthorization(context.Background(), "Bearer "+token); err != nil {
t.Fatalf("empty allow-list should accept any audience, got %v", err)
}
}
func TestZitadelVerifier_RejectsMissingBearerPrefix(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, nil)
token := ti.sign(t, []string{"396757803877794175"}, nil)
if _, err := v.CheckAuthorization(context.Background(), token); err == nil {
t.Fatal("expected rejection when the Bearer prefix is missing")
}
}
func TestZitadelVerifier_RejectsExpiredToken(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, nil)
claims := jwt.MapClaims{
"iss": ti.server.URL,
"sub": "u1",
"aud": []interface{}{"396757803877794175"},
"exp": time.Now().Add(-2 * time.Hour).Unix(),
"iat": time.Now().Add(-3 * time.Hour).Unix(),
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = ti.kid
signed, err := tok.SignedString(ti.key)
if err != nil {
t.Fatalf("sign token: %v", err)
}
if _, err := v.CheckAuthorization(context.Background(), "Bearer "+signed); err == nil {
t.Fatal("expected rejection for an expired token")
}
}
// TestValidator_SDKPathFeedsCheckPermissions proves the adapter from the SDK
// introspection context to jwt.MapClaims keeps the capability gate working.
func TestValidator_SDKPathFeedsCheckPermissions(t *testing.T) {
ti := newTestIssuer(t)
host, port, _ := strings.Cut(strings.TrimPrefix(ti.server.URL, "http://"), ":")
validator, err := NewZitadelValidator(context.Background(), ti.server.URL, []string{"396757803559027071"}, ZitadelConfig{
Domain: host,
InsecurePort: port,
HTTPClient: ti.server.Client(),
})
if err != nil {
t.Fatalf("NewZitadelValidator: %v", err)
}
good := ti.sign(t, []string{"396757803559027071"}, map[string]any{
"llm:inference": map[string]any{"396757796898471937": "homelab"},
})
claims, err := validator.ValidateBearerToken("Bearer " + good)
if err != nil {
t.Fatalf("ValidateBearerToken: %v", err)
}
if !validator.CheckPermissions(claims, "llm:inference") {
t.Error("expected CheckPermissions to accept llm:inference via the SDK path")
}
if validator.CheckPermissions(claims, "sqs:write") {
t.Error("expected CheckPermissions to reject a role the token does not carry")
}
noRole := ti.sign(t, []string{"396757803559027071"}, nil)
claims, err = validator.ValidateBearerToken("Bearer " + noRole)
if err != nil {
t.Fatalf("ValidateBearerToken without roles: %v", err)
}
if validator.CheckPermissions(claims, "llm:inference") {
t.Error("expected CheckPermissions to reject a token with no project roles")
}
}
func TestIssuerMatches(t *testing.T) {
cases := []struct {
iss string
configured string
want bool
}{
{"https://zitadel.riotpiao.com", "https://zitadel.riotpiao.com", true},
{"https://zitadel.riotpiao.com/", "https://zitadel.riotpiao.com", true},
{"https://zitadel.riotpiao.com/oauth/v2", "https://zitadel.riotpiao.com", true},
{"https://evil.example.com", "https://zitadel.riotpiao.com", false},
{"https://zitadel.riotpiao.com.evil.com", "https://zitadel.riotpiao.com", false},
{"anything", "", true},
}
for _, c := range cases {
if got := issuerMatches(c.iss, c.configured); got != c.want {
t.Errorf("issuerMatches(%q, %q) = %v, want %v", c.iss, c.configured, got, c.want)
}
}
}
func TestClaimsFromIntrospection_Nil(t *testing.T) {
if got := claimsFromIntrospection(nil); got == nil || len(got) != 0 {
t.Errorf("expected empty non-nil claims, got %v", got)
}
}
func TestAudienceValues(t *testing.T) {
cases := []struct {
name string
claim any
want int
ok bool
}{
{"string", "api-gw", 1, true},
{"empty string", "", 1, false},
{"string slice", []string{"a", "b"}, 2, true},
{"iface slice", []interface{}{"a", 5}, 1, true},
{"missing", nil, 0, false},
}
for _, c := range cases {
claims := jwt.MapClaims{}
if c.claim != nil {
claims["aud"] = c.claim
}
got, ok := audienceValues(claims)
if ok != c.ok || len(got) != c.want {
t.Errorf("%s: audienceValues = (%v, %v), want len %d ok %v", c.name, got, ok, c.want, c.ok)
}
}
_ = fmt.Sprint()
}
+19 -1
View File
@@ -66,12 +66,30 @@ type AuthConfig struct {
JWKSURL string JWKSURL string
// RequiredCapability is the permission required for LLM inference (e.g., "llm:inference"). // RequiredCapability is the permission required for LLM inference (e.g., "llm:inference").
RequiredCapability string RequiredCapability string
// TokenURL is the Authentik token endpoint for password/refresh grants. // AllowListedMachineIdentities admits a caller whose token carries no roles
// claim, when its client id is in ZitadelClientIDs. ZITADEL does not assert roles
// in machine-user access tokens on this instance, so a service account can never
// satisfy RequiredCapability. The allow-list is then the authorisation: the caller
// still proves possession of a registered private key, and the entry is a reviewed
// line of configuration, not a wildcard.
AllowListedMachineIdentities bool
// TokenURL is the OIDC token endpoint used for token exchange
// (client_credentials / password grants).
TokenURL string TokenURL string
// ClientID is the OAuth2 client ID for token exchange. // ClientID is the OAuth2 client ID for token exchange.
ClientID string ClientID string
// ClientSecret is the OAuth2 client secret (loaded from env, never from config file). // ClientSecret is the OAuth2 client secret (loaded from env, never from config file).
ClientSecret string ClientSecret string
// ZitadelDomain is the ZITADEL instance host (for example
// "zitadel.riotpiao.com"). When set, token validation uses the official
// ZITADEL Go SDK instead of the raw JWKS path.
ZitadelDomain string
// ZitadelInsecurePort, when non-empty, makes the SDK speak plain HTTP to
// that port. Only for a local port-forward.
ZitadelInsecurePort string
// ZitadelClientIDs is the audience allow-list for the SDK verifier. Empty
// accepts any audience issued by the same instance.
ZitadelClientIDs []string
} }
// Route represents a single route and its upstream configuration. // Route represents a single route and its upstream configuration.
+25 -17
View File
@@ -20,13 +20,17 @@ type rawConfig struct {
// rawAuth represents auth configuration in YAML. // rawAuth represents auth configuration in YAML.
type rawAuth struct { type rawAuth struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Issuer string `yaml:"issuer"` Issuer string `yaml:"issuer"`
Audience string `yaml:"audience"` Audience string `yaml:"audience"`
JWKSURL string `yaml:"jwksUrl"` JWKSURL string `yaml:"jwksUrl"`
RequiredCapability string `yaml:"requiredCapability"` RequiredCapability string `yaml:"requiredCapability"`
TokenURL string `yaml:"tokenUrl"` AllowListedMachineIdentities bool `yaml:"allowListedMachineIdentities"`
ClientID string `yaml:"clientId"` TokenURL string `yaml:"tokenUrl"`
ClientID string `yaml:"clientId"`
ZitadelDomain string `yaml:"zitadelDomain"`
ZitadelInsecurePort string `yaml:"zitadelInsecurePort"`
ZitadelClientIDs []string `yaml:"zitadelClientIds"`
} }
// rawRoute represents a single route in the YAML configuration. // rawRoute represents a single route in the YAML configuration.
@@ -41,8 +45,8 @@ type rawModel struct {
Address string `yaml:"address"` Address string `yaml:"address"`
Addresses []string `yaml:"addresses"` Addresses []string `yaml:"addresses"`
Path string `yaml:"path"` Path string `yaml:"path"`
UpstreamModel string `yaml:"upstreamModel"` UpstreamModel string `yaml:"upstreamModel"`
AuthRequired *bool `yaml:"authRequired"` AuthRequired *bool `yaml:"authRequired"`
} }
// rawAdapter represents a service adapter in the YAML configuration. // rawAdapter represents a service adapter in the YAML configuration.
@@ -184,14 +188,18 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
// Parse auth config // Parse auth config
authConfig := AuthConfig{ authConfig := AuthConfig{
Enabled: raw.Auth.Enabled, Enabled: raw.Auth.Enabled,
Issuer: raw.Auth.Issuer, Issuer: raw.Auth.Issuer,
Audience: raw.Auth.Audience, Audience: raw.Auth.Audience,
JWKSURL: raw.Auth.JWKSURL, JWKSURL: raw.Auth.JWKSURL,
RequiredCapability: raw.Auth.RequiredCapability, RequiredCapability: raw.Auth.RequiredCapability,
TokenURL: raw.Auth.TokenURL, AllowListedMachineIdentities: raw.Auth.AllowListedMachineIdentities,
ClientID: raw.Auth.ClientID, TokenURL: raw.Auth.TokenURL,
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"), ClientID: raw.Auth.ClientID,
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
ZitadelDomain: raw.Auth.ZitadelDomain,
ZitadelInsecurePort: raw.Auth.ZitadelInsecurePort,
ZitadelClientIDs: raw.Auth.ZitadelClientIDs,
} }
return routes, models, adapters, authConfig, nil return routes, models, adapters, authConfig, nil
+65
View File
@@ -0,0 +1,65 @@
package proxy
import (
"testing"
"github.com/golang-jwt/jwt/v5"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
)
func handlerWith(allow bool, ids ...string) *Handler {
return &Handler{config: &config.Config{Auth: config.AuthConfig{
AllowListedMachineIdentities: allow,
ZitadelClientIDs: ids,
}}}
}
func TestAllowListedMachineIdentityAdmitsANamedClientWithoutRoles(t *testing.T) {
h := handlerWith(true, "poimen-llm")
claims := jwt.MapClaims{"client_id": "poimen-llm", "sub": "397071051344839054"}
if !h.allowListedMachineIdentity(claims) {
t.Fatalf("an allow-listed machine client must be admitted when the token has no roles")
}
}
func TestAllowListedMachineIdentityRefusesAnUnknownClient(t *testing.T) {
h := handlerWith(true, "poimen-llm")
claims := jwt.MapClaims{"client_id": "someone-else"}
if h.allowListedMachineIdentity(claims) {
t.Fatalf("a client that is not on the list must not be admitted")
}
}
func TestAllowListedMachineIdentityRefusesWhenTheTokenStatesRoles(t *testing.T) {
h := handlerWith(true, "poimen-llm")
claims := jwt.MapClaims{
"client_id": "poimen-llm",
"urn:zitadel:iam:org:project:roles": map[string]interface{}{"memory:read": nil},
}
if h.allowListedMachineIdentity(claims) {
t.Fatalf("a token with roles must be judged by the role check, not by the allow-list")
}
}
func TestAllowListedMachineIdentityIsOffByDefault(t *testing.T) {
h := handlerWith(false, "poimen-llm")
claims := jwt.MapClaims{"client_id": "poimen-llm"}
if h.allowListedMachineIdentity(claims) {
t.Fatalf("without the setting the gateway must fall back to the role check only")
}
}
func TestRolesClaimReadsBothShapes(t *testing.T) {
asMap := jwt.MapClaims{"urn:zitadel:iam:org:project:roles": map[string]interface{}{"llm:inference": nil}}
if got := rolesClaim(asMap); len(got) != 1 || got[0] != "llm:inference" {
t.Fatalf("map shape must yield the role names, got %v", got)
}
asList := jwt.MapClaims{"roles": []interface{}{"llm:inference", "memory:read"}}
if got := rolesClaim(asList); len(got) != 2 {
t.Fatalf("list shape must yield every role, got %v", got)
}
if got := rolesClaim(jwt.MapClaims{}); len(got) != 0 {
t.Fatalf("a token with no roles must yield nothing, got %v", got)
}
}
+121 -25
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/golang-jwt/jwt/v5"
"io" "io"
"net" "net"
"net/http" "net/http"
@@ -45,7 +46,7 @@ type Handler struct {
defaultWriteTimeout time.Duration defaultWriteTimeout time.Duration
defaultMaxBodySize int64 defaultMaxBodySize int64
// CAG: per-model backend schedulers for cache-aware routing (#45/#46) // CAG: per-model backend schedulers for cache-aware routing (#45/#46)
schedulers map[string]*Scheduler schedulers map[string]*Scheduler
// CAG: delay applied to warmup requests before queue entry (#45) // CAG: delay applied to warmup requests before queue entry (#45)
warmupDelay time.Duration warmupDelay time.Duration
// engineObservers polls each model backend's /metrics for admission (#64) // engineObservers polls each model backend's /metrics for admission (#64)
@@ -78,18 +79,18 @@ func (e *modelValidationError) Error() string {
// RFC 9457 Problem Details // RFC 9457 Problem Details
type problemDetail struct { type problemDetail struct {
Type string `json:"type"` Type string `json:"type"`
Title string `json:"title"` Title string `json:"title"`
Status int `json:"status"` Status int `json:"status"`
Detail string `json:"detail"` Detail string `json:"detail"`
ValidModels []string `json:"valid_models,omitempty"` ValidModels []string `json:"valid_models,omitempty"`
} }
// New creates a new reverse proxy handler from configuration. // New creates a new reverse proxy handler from configuration.
// It sets up connection pooling and rewriting rules for each route. // It sets up connection pooling and rewriting rules for each route.
func New(cfg *config.Config) *Handler { func New(cfg *config.Config) *Handler {
h := &Handler{ h := &Handler{
routes: make(map[string]*Route), routes: make(map[string]*Route),
transports: make(map[string]*http.Transport), transports: make(map[string]*http.Transport),
config: cfg, config: cfg,
defaultConnectTimeout: 10 * time.Second, defaultConnectTimeout: 10 * time.Second,
@@ -98,8 +99,29 @@ func New(cfg *config.Config) *Handler {
defaultMaxBodySize: 100 * 1024 * 1024, defaultMaxBodySize: 100 * 1024 * 1024,
} }
// Initialize JWT validator if auth is enabled // Initialize JWT validator if auth is enabled.
if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" { //
// Two paths, in order of preference:
// 1. ZitadelDomain set -> official ZITADEL Go SDK (discovery + remote JWKS
// + op.AccessTokenVerifier). This is the production path.
// 2. JWKSURL set -> legacy raw-JWKS validation, kept for tests and for
// any deployment that has not moved to ZITADEL yet.
if cfg.Auth.Enabled && cfg.Auth.ZitadelDomain != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
v, err := auth.NewZitadelValidator(ctx, cfg.Auth.Issuer, cfg.Auth.ZitadelClientIDs, auth.ZitadelConfig{
Domain: cfg.Auth.ZitadelDomain,
InsecurePort: cfg.Auth.ZitadelInsecurePort,
ClientIDs: cfg.Auth.ZitadelClientIDs,
})
if err != nil {
logging.Errorf("zitadel: verifier init failed; auth disabled until restart", err, map[string]string{
"domain": cfg.Auth.ZitadelDomain,
})
} else {
h.jwtValidator = v
}
} else if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" {
h.jwtValidator = auth.NewValidator( h.jwtValidator = auth.NewValidator(
cfg.Auth.Issuer, cfg.Auth.Issuer,
cfg.Auth.Audience, cfg.Auth.Audience,
@@ -217,9 +239,9 @@ func New(cfg *config.Config) *Handler {
for name, route := range cfg.Routes { for name, route := range cfg.Routes {
// Create a transport per unique upstream address for connection reuse // Create a transport per unique upstream address for connection reuse
transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream) transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream)
upstreamURL := &url.URL{Scheme: "http", Host: route.Upstream.Address} upstreamURL := &url.URL{Scheme: "http", Host: route.Upstream.Address}
r := &Route{ r := &Route{
Name: name, Name: name,
Upstream: &route.Upstream, Upstream: &route.Upstream,
@@ -309,10 +331,10 @@ func directorFunc(req *http.Request, target *url.URL, upstream *config.Upstream)
// Set the scheme and host // Set the scheme and host
req.URL.Scheme = target.Scheme req.URL.Scheme = target.Scheme
req.URL.Host = target.Host req.URL.Host = target.Host
// Update the Host header to the upstream address // Update the Host header to the upstream address
req.Host = target.Host req.Host = target.Host
// Strip hop-by-hop headers as defined in RFC 7230 Section 6.1 // Strip hop-by-hop headers as defined in RFC 7230 Section 6.1
// These must not be forwarded to upstream // These must not be forwarded to upstream
hopByHopHeaders := map[string]bool{ hopByHopHeaders := map[string]bool{
@@ -325,20 +347,20 @@ func directorFunc(req *http.Request, target *url.URL, upstream *config.Upstream)
"transfer-encoding": true, "transfer-encoding": true,
"upgrade": true, "upgrade": true,
} }
// Also strip any headers listed in the Connection header // Also strip any headers listed in the Connection header
if conn := req.Header.Get("Connection"); conn != "" { if conn := req.Header.Get("Connection"); conn != "" {
for _, h := range strings.Split(conn, ",") { for _, h := range strings.Split(conn, ",") {
hopByHopHeaders[strings.ToLower(strings.TrimSpace(h))] = true hopByHopHeaders[strings.ToLower(strings.TrimSpace(h))] = true
} }
} }
// Remove all hop-by-hop headers // Remove all hop-by-hop headers
// The http.Header.Del method is case-insensitive, so we can delete using lowercase keys // The http.Header.Del method is case-insensitive, so we can delete using lowercase keys
for header := range hopByHopHeaders { for header := range hopByHopHeaders {
req.Header.Del(header) req.Header.Del(header)
} }
// Handle X-Forwarded-For: append the immediate peer // Handle X-Forwarded-For: append the immediate peer
// Get the peer IP from the request RemoteAddr // Get the peer IP from the request RemoteAddr
peerIP := getPeerIP(req.RemoteAddr) peerIP := getPeerIP(req.RemoteAddr)
@@ -439,8 +461,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(respWriter, r) proxy.ServeHTTP(respWriter, r)
} }
// applyWarmup strips the X-Poimen-Warmup header, delays the request while // applyWarmup strips the X-Poimen-Warmup header, delays the request while
// honouring context cancellation, and records the metric. // honouring context cancellation, and records the metric.
// Returns false if the context was cancelled during the delay (caller must abort). // Returns false if the context was cancelled during the delay (caller must abort).
@@ -722,16 +742,17 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
return false return false
} }
identity.Inject(r, claims) identity.Inject(r, claims)
if h.config.Auth.RequiredCapability != "" { if caps := requiredCapabilities(h.config.Auth.RequiredCapability); len(caps) > 0 {
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") { args := append(caps, "*")
if !h.jwtValidator.CheckPermissions(claims, args...) && !h.allowListedMachineIdentity(claims) {
writeProblemDetail(w, http.StatusForbidden, writeProblemDetail(w, http.StatusForbidden,
"https://api.example.com/problems/insufficient-permissions", "https://api.example.com/problems/insufficient-permissions",
"Insufficient Permissions", "Insufficient Permissions",
fmt.Sprintf("Required capability: %s", h.config.Auth.RequiredCapability), fmt.Sprintf("Required capability: %s", strings.Join(caps, " or ")),
nil) nil)
logging.Errorf("auth failed", fmt.Errorf("insufficient permissions"), map[string]string{ logging.Errorf("auth failed", fmt.Errorf("insufficient permissions"), map[string]string{
"path": r.URL.Path, "path": r.URL.Path,
"required": h.config.Auth.RequiredCapability, "required": strings.Join(caps, ","),
}) })
return false return false
} }
@@ -739,6 +760,75 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
return true return true
} }
// allowListedMachineIdentity admits a service account whose token carries no roles
// claim, when its client id appears in the explicit allow-list.
//
// ZITADEL does not put roles into a machine-user access token on this instance: the
// grant exists, the scope asks for the roles, and the claim still never arrives. A
// service account therefore cannot satisfy a role check by any configuration. The
// caller is still authenticated — it signed an assertion with a private key that
// ZITADEL holds the public half of — and the allow-list names it explicitly, so the
// authorisation is a reviewed line rather than a wildcard.
func (h *Handler) allowListedMachineIdentity(claims jwt.MapClaims) bool {
if !h.config.Auth.AllowListedMachineIdentities {
return false
}
if len(rolesClaim(claims)) > 0 {
// The token states its roles, so the ordinary check has already run and
// decided. Falling through here would be a second, weaker rule.
return false
}
clientID, _ := claims["client_id"].(string)
if clientID == "" {
return false
}
for _, allowed := range h.config.Auth.ZitadelClientIDs {
if allowed == clientID {
logging.Errorf("auth: admitted an allow-listed machine identity without a roles claim",
fmt.Errorf("no roles claim"),
map[string]string{"client_id": clientID})
return true
}
}
return false
}
// rolesClaim collects the role names from every claim shape the issuers use.
func rolesClaim(claims jwt.MapClaims) []string {
var out []string
for _, key := range []string{"roles", "urn:zitadel:iam:org:project:roles"} {
switch v := claims[key].(type) {
case []interface{}:
for _, item := range v {
if name, ok := item.(string); ok {
out = append(out, name)
}
}
case map[string]interface{}:
for name := range v {
out = append(out, name)
}
}
}
return out
}
// requiredCapabilities splits the comma-separated requiredCapability setting
// into a list. Several spellings are accepted because the same capability is
// named differently by each identity provider: Authentik used "llm:inference"
// as a permission string, while ZITADEL uses role keys such as
// "llm-inference". A token satisfies the gate when it carries any one of them.
func requiredCapabilities(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// enforceBodyLimit caps request body size for non-chat routes. // enforceBodyLimit caps request body size for non-chat routes.
// Returns false when the request is rejected (response already written). // Returns false when the request is rejected (response already written).
func (h *Handler) enforceBodyLimit(w http.ResponseWriter, r *http.Request, route *Route) bool { func (h *Handler) enforceBodyLimit(w http.ResponseWriter, r *http.Request, route *Route) bool {
@@ -797,8 +887,14 @@ func (h *Handler) setupCAGProxy(ctx context.Context, w http.ResponseWriter, r *h
if resp.StatusCode < http.StatusBadRequest { if resp.StatusCode < http.StatusBadRequest {
return nil return nil
} }
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close() resp.Body.Close()
if readErr != nil {
logging.Warnf("reading upstream error body failed", map[string]string{
"upstream": capturedRoute.Upstream.Address,
"err": readErr.Error(),
})
}
logging.Warnf("upstream error after parked stream started", map[string]string{ logging.Warnf("upstream error after parked stream started", map[string]string{
"upstream": capturedRoute.Upstream.Address, "upstream": capturedRoute.Upstream.Address,
"status": strconv.Itoa(resp.StatusCode), "status": strconv.Itoa(resp.StatusCode),
@@ -875,8 +971,8 @@ func (h *Handler) getValidModels() []string {
// modelsListResponse represents the response for GET /v1/models // modelsListResponse represents the response for GET /v1/models
type modelsListResponse struct { type modelsListResponse struct {
Object string `json:"object"` Object string `json:"object"`
Data []modelsListEntry `json:"data"` Data []modelsListEntry `json:"data"`
} }
// modelsListEntry represents a single model in the list // modelsListEntry represents a single model in the list
+7 -4
View File
@@ -13,11 +13,14 @@ import (
) )
// RouteRequest determines which upstream should handle the request. // RouteRequest determines which upstream should handle the request.
// For /v1/chat/completions, /v1/embeddings, and /v1/rerank, it uses body-based dispatch. // For /v1/chat/completions, /v1/embeddings, /v1/rerank and /v1/systemone, it
// uses body-based dispatch.
// For other routes, it looks up by path in the configured routes. // For other routes, it looks up by path in the configured routes.
func (h *Handler) RouteRequest(r *http.Request) (*Route, error) { func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
// For /v1/chat/completions, /v1/embeddings, /v1/rerank use body-based dispatch // For /v1/chat/completions, /v1/embeddings, /v1/rerank and /v1/systemone use
if r.Method == "POST" && (r.URL.Path == "/v1/chat/completions" || r.URL.Path == "/v1/embeddings" || r.URL.Path == "/v1/rerank") { // body-based dispatch. /v1/systemone is Clef's decision API: same
// body-based model lookup, different request/response schema.
if r.Method == "POST" && (r.URL.Path == "/v1/chat/completions" || r.URL.Path == "/v1/embeddings" || r.URL.Path == "/v1/rerank" || r.URL.Path == "/v1/systemone") {
return h.routeByModel(r, r.URL.Path) return h.routeByModel(r, r.URL.Path)
} }
@@ -46,7 +49,7 @@ func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
// routeByModel reads the request body to find the "model" field and routes accordingly. // routeByModel reads the request body to find the "model" field and routes accordingly.
// The body is preserved for forwarding to the upstream. // The body is preserved for forwarding to the upstream.
// Returns a modelValidationError for client errors (invalid JSON, missing/unknown model). // Returns a modelValidationError for client errors (invalid JSON, missing/unknown model).
// The path parameter indicates which endpoint is being called (/v1/chat/completions, /v1/embeddings, /v1/rerank) // The path parameter indicates which endpoint is being called (/v1/chat/completions, /v1/embeddings, /v1/rerank, /v1/systemone)
func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) { func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
// If there's no body, we can't determine the model // If there's no body, we can't determine the model
if r.Body == nil { if r.Body == nil {
+169
View File
@@ -0,0 +1,169 @@
package proxy
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
)
// TestSystemoneRoutesByModel verifies POST /v1/systemone dispatches on the body
// "model" field instead of falling through to path-based route lookup. Before
// that path joined body dispatch, the request reached the route table and
// failed with "no routes configured" when no routes were defined.
func TestSystemoneRoutesByModel(t *testing.T) {
clefCalled := false
clefServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clefCalled = true
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"decision":"allow"}`)
}))
defer clefServer.Close()
clefAddr := strings.TrimPrefix(clefServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"clef-flash": {
Name: "clef-flash",
Address: clefAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Post(
server.URL+"/v1/systemone",
"application/json",
bytes.NewReader([]byte(`{"model":"clef-flash","state":"x","questions":{}}`)),
)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if !clefCalled {
t.Errorf("upstream clef-flash service was not called")
}
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 for known systemone model, got %d", resp.StatusCode)
}
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
t.Fatalf("failed to read response body: %v", readErr)
}
if strings.Contains(string(body), "no routes configured") {
t.Errorf("systemone request fell through to route lookup: %s", string(body))
}
if !strings.Contains(string(body), `"decision":"allow"`) {
t.Errorf("expected upstream response body, got %s", string(body))
}
}
// TestSystemoneUnknownModelValidationError verifies an unknown model on
// /v1/systemone returns the same modelValidationError kind as the other three
// body-dispatched paths.
func TestSystemoneUnknownModelValidationError(t *testing.T) {
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"clef-flash": {
Name: "clef-flash",
Address: "localhost:9000",
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
paths := []string{
"/v1/chat/completions",
"/v1/embeddings",
"/v1/rerank",
"/v1/systemone",
}
for _, path := range paths {
req := httptest.NewRequest(
"POST",
path,
strings.NewReader(`{"model":"unknown-model"}`),
)
route, err := handler.RouteRequest(req)
if route != nil {
t.Errorf("%s: expected nil route for unknown model", path)
}
var validationErr *modelValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("%s: expected modelValidationError, got %v", path, err)
}
if validationErr.Kind != "unknown_model" {
t.Errorf("%s: expected kind unknown_model, got %s", path, validationErr.Kind)
}
}
}
// TestSystemoneUpstreamPathNotRewritten verifies routeByModel keeps the request
// path for /v1/systemone: upstream must see /v1/systemone, not
// /v1/chat/completions. /v1/rerank is the only endpoint with a path rewrite.
func TestSystemoneUpstreamPathNotRewritten(t *testing.T) {
upstreamPath := ""
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"decision":"deny"}`)
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
"clef-flash": {
Name: "clef-flash",
Address: upstreamAddr,
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
resp, err := http.Post(
server.URL+"/v1/systemone",
"application/json",
bytes.NewReader([]byte(`{"model":"clef-flash","state":"x","questions":{}}`)),
)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if upstreamPath != "/v1/systemone" {
t.Errorf("expected upstream path /v1/systemone, got %s", upstreamPath)
}
if upstreamPath == "/v1/chat/completions" {
t.Errorf("upstream path was rewritten to /v1/chat/completions")
}
}
File diff suppressed because one or more lines are too long
+20 -6
View File
@@ -99,19 +99,21 @@ spec:
ports: ports:
- protocol: TCP - protocol: TCP
port: 8080 port: 8080
# Allow to IAM (Authentik) for JWKS fetch + API # Allow to IAM (Zitadel) for JWKS fetch + API
# Authentik pod listens on 9000 (http) and 9443 (https) # Zitadel API listens on 8080 (h2c/plaintext in-cluster); the login UI
# Service translates 80→9000, 443→9443 # listens on 3000. Public access goes through ingress-nginx on 443 (already
# NetworkPolicy matches destination pod port, not service port # covered by the 10.96.0.0/12 CIDR below), but 8080 keeps direct in-cluster
# calls working if CoreDNS rewrite is bypassed.
# NetworkPolicy matches destination pod port, not service port.
- to: - to:
- namespaceSelector: - namespaceSelector:
matchLabels: matchLabels:
kubernetes.io/metadata.name: iam kubernetes.io/metadata.name: iam
ports: ports:
- protocol: TCP - protocol: TCP
port: 9000 port: 8080
- protocol: TCP - protocol: TCP
port: 9443 port: 3000
# Allow to SQS (queue management service) # Allow to SQS (queue management service)
- to: - to:
- namespaceSelector: - namespaceSelector:
@@ -170,6 +172,18 @@ spec:
ports: ports:
- protocol: TCP - protocol: TCP
port: 3000 port: 3000
# Allow HTTPS to ingress-nginx. The gateway fetches JWKS and the OIDC
# discovery document from https://zitadel.riotpiao.com, which CoreDNS rewrites
# to this service. The service CIDR is 10.106.0.0/16, so the 10.96.0.0/12
# ipBlock below never covered it: the fetch hung until the context deadline and
# the pod failed its liveness probe.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 443
# Allow to kubernetes API server (ArgoCD Application patching via in-cluster SA). # Allow to kubernetes API server (ArgoCD Application patching via in-cluster SA).
# kube-apiserver runs on host network (192.168.1.162/166:6443) — must use ipBlock. # kube-apiserver runs on host network (192.168.1.162/166:6443) — must use ipBlock.
# ClusterIP 10.96.0.1:443 also routes there, covered by the 10.96.0.0/12 CIDR. # ClusterIP 10.96.0.1:443 also routes there, covered by the 10.96.0.0/12 CIDR.