From 12d5f70f2433dc50eee114cc541beab9f5122a39 Mon Sep 17 00:00:00 2001 From: cmdoret Date: Thu, 20 Aug 2026 14:04:40 +0200 Subject: [PATCH 01/12] doc(readme): update usage docs --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 23525c8..ab08977 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Helm chart to deploy VLLM and envoy on Kubernetes The chart only creates custom resources that rely on these systems being installed on the cluster: - [Gateway API](https://gateway-api.sigs.k8s.io/) CRDs (`gateway.networking.k8s.io`) -- [Envoy Gateway](https://gateway.envoyproxy.io/) with the [Envoy AI Gateway](https://aigateway.envoyproxy.io/) extension (controller in `envoy-gateway-system`) +- [Envoy Gateway](https://gateway.envoyproxy.io/) (>=v1.4.0) with the [Envoy AI Gateway](https://aigateway.envoyproxy.io/) extension. - [Knative Serving](https://knative.dev/docs/serving/) (scale-to-zero model services) - [cert-manager](https://cert-manager.io/) with a `ClusterIssuer` matching `envoy.clusterissuer` - A PostgreSQL server, with roles and databases created up front. See [postgresql.md](docs/postgresql.md) @@ -74,6 +74,10 @@ models: enableTools: false # whether to allow tool calls or not logRequests: false # whether to log all requests in the vllm pod scaleDownDelaySeconds: 3600 # the number of seconds before the model is torn down if there is no traffic + rateLimit: # optional: token-based quota, counted per caller identity + enabled: false + requests: 500000 # tokens per caller per unit + unit: Hour chatTemplate: # if you want to use a custom chat template. Usually left empty. The template must exist in the docker image to work repository: tag: @@ -97,3 +101,32 @@ just to make sure the user is created in openwebui. click on the user icon in the bottom left, go to "Admin Panel" -> "Settings" -> "Models". For each model, click on the Pen icon to edit, then the "Access" button in the top right. Set to "Public", close and "save". This has to be done each time models are changed. + +A model that has scaled to zero takes a minute or two to answer the first message. + +## Usage + +### Web interface + +Open `https://openwebui.` and sign in with the "authentik" button, which +delegates to GitLab. The first sign-in creates the account. + +Members of the `gateway admins` group in authentik become OpenWebUI admins. + +### API access + +Set `openwebui.forwardUserJwtSecret` to any high-entropy string. OpenWebUI then signs a +short-lived per-user JWT into an `X-OpenWebUI-User-Jwt` header on every request it makes to +the gateway, and the gateway is configured to accept it as a second JWT provider. Users can +then treat OpenWebUI as an OpenAI-compatible endpoint: + +```bash +# Settings -> Account -> API Keys in the OpenWebUI UI +export OPENAI_API_KEY=sk-... +export OPENAI_BASE_URL=https://openwebui./api + +curl "$OPENAI_BASE_URL/chat/completions" \ + -H "Authorization: Bearer $OPENAI_API_KEY" -H 'Content-Type: application/json' \ + -d '{"model":"","messages":[{"role":"user","content":"hello"}]}' +``` + From 43f4a2b3526b2d441e1b22cb74571e10b6830225 Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 10:09:32 +0200 Subject: [PATCH 02/12] doc(readme): fix headings --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ab08977..22dc586 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The chart only creates custom resources that rely on these systems being install - [cert-manager](https://cert-manager.io/) with a `ClusterIssuer` matching `envoy.clusterissuer` - A PostgreSQL server, with roles and databases created up front. See [postgresql.md](docs/postgresql.md) -## Usage +## Installation The repository contains a [`justfile`](justfile) to automate routine commands. You may use it as reference, or run it with `just` (by default, just will list available recipes). @@ -129,4 +129,3 @@ curl "$OPENAI_BASE_URL/chat/completions" \ -H "Authorization: Bearer $OPENAI_API_KEY" -H 'Content-Type: application/json' \ -d '{"model":"","messages":[{"role":"user","content":"hello"}]}' ``` - From 76e252d25d8ecad39edc5f28f8499cf7c79ca264 Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 10:15:33 +0200 Subject: [PATCH 03/12] refactor(values): add requests ceiling and jwt secret to openwebui --- values.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/values.yaml b/values.yaml index d891bf3..4f7c2fa 100644 --- a/values.yaml +++ b/values.yaml @@ -5,6 +5,10 @@ envoy: clientBufferLimit: 50Mi security: jwksUri: + rateLimit: + # Shared across all callers. should be well above any per-model limit. Used for + # requests that arrive without a caller identity (no x-sub). Unset means no ceiling. + ceilingRequests: gatewayClass: enabled: false name: @@ -69,6 +73,8 @@ openwebui: user: admin email: admin@sdsc.ethz.ch password: + # HS256 key OpenWebUI signs its per-user identity JWT with. Needed for OpenWebUI API keys. + forwardUserJwtSecret: resources: requests: cpu: 500m From e8cfd1da6b32840d3fec2eb54e1a8ea4d6451717 Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 10:34:08 +0200 Subject: [PATCH 04/12] feat(envoy): global backend traffic policy --- templates/_helpers.tpl | 10 +++++++++ templates/envoy/backend-traffic-policy.yaml | 25 ++++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index e2cd203..10e73bd 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -47,3 +47,13 @@ JWKS URI: use authentik if enabled, otherwise configurable {{- printf "https://authentik.%s/application/o/%s/jwks/" .Values.envoy.baseDomain .Values.authentik.oauthApp.name -}} {{- end -}} {{- end -}} + +{{/* +Key OpenWebUI signs its forwarded per-user identity JWT with, empty when the feature +is off. Also gated on openwebui.enabled. +*/}} +{{- define "openwebui.userJwtSecret" -}} + {{- if and .Values.openwebui.enabled .Values.openwebui.forwardUserJwtSecret -}} +{{ .Values.openwebui.forwardUserJwtSecret }} + {{- end -}} +{{- end -}} diff --git a/templates/envoy/backend-traffic-policy.yaml b/templates/envoy/backend-traffic-policy.yaml index 095382f..a1e0947 100644 --- a/templates/envoy/backend-traffic-policy.yaml +++ b/templates/envoy/backend-traffic-policy.yaml @@ -1,10 +1,12 @@ -{{- $found := false }} +{{- $perUser := false }} {{- range $key, $val := .Values.models }} {{- if and $val.rateLimit $val.rateLimit.enabled }} - {{- $found = true }} + {{- $perUser = true }} {{- end }} {{- end }} -{{- if and .Values.envoy.enabled $found}} +{{- $rl := default dict .Values.envoy.rateLimit }} +{{- $ceiling := $rl.ceilingRequests }} +{{- if and .Values.envoy.enabled (or $perUser $ceiling) }} apiVersion: gateway.envoyproxy.io/v1alpha1 kind: BackendTrafficPolicy metadata: @@ -41,5 +43,22 @@ spec: key: llm_total_token # uses tokens from responses as unit {{- end }} {{- end }} + {{- with $ceiling }} + {{- /* Gateway-wide backstop for requests carrying no x-sub: a selector + on an absent header emits no descriptor at all -> it would go unmetered + with per-model rules. */}} + - limit: + requests: {{ . }} + unit: Hour + cost: + request: + from: Number + number: 0 # Set to 0 so only token usage counts + response: + from: Metadata + metadata: + namespace: io.envoy.ai_gateway + key: llm_total_token # uses tokens from responses as unit + {{- end }} {{- end }} From 3e719738a0c595a1deecc493a9eccc272f10903c Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 11:17:51 +0200 Subject: [PATCH 05/12] feat(init): enable api keys --- src/init/init.go | 178 +++++++++++++++++++------------------ src/init/init_test.go | 198 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 84 deletions(-) create mode 100644 src/init/init_test.go diff --git a/src/init/init.go b/src/init/init.go index 822e14a..f30eec2 100644 --- a/src/init/init.go +++ b/src/init/init.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/knadh/koanf/parsers/toml" "github.com/knadh/koanf/providers/env/v2" @@ -25,8 +26,15 @@ type OpenWebUi struct { AdminEmail string `koanf:"admin_email"` AdminPassword string `koanf:"admin_password"` ModelIds []string `koanf:"model_ids"` + // Whether users may mint and use API keys. Tied to the gateway accepting the + // identity JWT OpenWebUI forwards: without that, a key reaches OpenWebUI's own + // API but every model call fails, so enabling it alone only widens the surface. + EnableApiKeys bool `koanf:"enable_api_keys"` } +// url builds an absolute OpenWebUI API URL. Plain http: the call is in-cluster. +func (o OpenWebUi) url(path string) string { return "http://" + o.Host + path } + type Config struct { Host string `koanf:"host"` OpenWebui OpenWebUi `koanf:"open_webui"` @@ -89,27 +97,33 @@ func initOpenWebui(conf Config) error { fmt.Println("creating admin user") adminToken, err := createOpenWebuiAdmin(conf) if err != nil { - if errors.Is(err, ErrUserExists) { - // On upgrade - fmt.Println("admin already exists, signing in to refresh model config") - adminToken, err = signinOpenWebuiAdmin(conf) - if err != nil { - return err - } - return setupOpenaiConfig(conf, adminToken) + if !errors.Is(err, ErrUserExists) { + return err + } + // On upgrade + fmt.Println("admin already exists, signing in to refresh config") + adminToken, err = signinOpenWebuiAdmin(conf) + if err != nil { + return err } - return err } + // Runs on upgrades too: OpenWebUI stores this in its database, so env vars cannot + // reach an instance that already booted. Each step is a fetch-mutate-post round + // trip, idempotent on repeat but only because they are serialized -- running them + // concurrently would make them overwrite each other's keys. fmt.Println("configuring openwebui") - err = setupOpenWebuiConfig(conf, adminToken) - if err != nil { + if err := setupOpenWebuiConfig(conf, adminToken); err != nil { + return err + } + + fmt.Println("granting users the api_keys feature") + if err := setupUserPermissions(conf, adminToken); err != nil { return err } fmt.Println("setting up oauth and models") - err = setupOpenaiConfig(conf, adminToken) - if err != nil { + if err := setupOpenaiConfig(conf, adminToken); err != nil { return err } @@ -125,7 +139,7 @@ func createOpenWebuiAdmin(conf Config) (string, error) { return "", fmt.Errorf("admin password not set") } - signupURL := fmt.Sprintf("http://%s/api/v1/auths/signup", conf.OpenWebui.Host) + signupURL := conf.OpenWebui.url("/api/v1/auths/signup") res, err := postAuth(signupURL, map[string]string{ "name": conf.OpenWebui.AdminUser, "email": conf.OpenWebui.AdminEmail, @@ -148,7 +162,7 @@ func createOpenWebuiAdmin(conf Config) (string, error) { } func signinOpenWebuiAdmin(conf Config) (string, error) { - signinURL := fmt.Sprintf("http://%s/api/v1/auths/signin", conf.OpenWebui.Host) + signinURL := conf.OpenWebui.url("/api/v1/auths/signin") res, err := postAuth(signinURL, map[string]string{ "email": conf.OpenWebui.AdminEmail, "password": conf.OpenWebui.AdminPassword, @@ -187,15 +201,21 @@ func tokenFromResponse(res *http.Response) (string, error) { return userdata.Token, nil } -func setupOpenWebuiConfig(conf Config, adminToken string) error { - configURL := fmt.Sprintf("http://%s/api/v1/auths/admin/config", conf.OpenWebui.Host) - getReq, err := http.NewRequest("GET", configURL, nil) +// configRoundTrip fetches a JSON config document, applies mutate and posts the +// result back. OpenWebUI's config endpoints replace the whole document instead of +// merging, so keys the mutation does not touch have to be carried over from the GET. +func configRoundTrip(getURL, postURL, adminToken string, mutate func(map[string]any) error) error { + // Without a timeout an unresponsive OpenWebUI wedges the init Job forever, and + // the Job has no activeDeadlineSeconds to cut it short. + client := http.Client{Timeout: 30 * time.Second} + auth := fmt.Sprintf("Bearer %s", adminToken) + + getReq, err := http.NewRequest("GET", getURL, nil) if err != nil { return fmt.Errorf("GET request creation failed: %w", err) } - getReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminToken)) + getReq.Header.Set("Authorization", auth) - client := http.Client{} getResp, err := client.Do(getReq) if err != nil { return fmt.Errorf("config fetch failed: %w", err) @@ -212,19 +232,20 @@ func setupOpenWebuiConfig(conf Config, adminToken string) error { return fmt.Errorf("config parse failed: %w", err) } - config["DEFAULT_USER_ROLE"] = "user" + if err := mutate(config); err != nil { + return fmt.Errorf("config mutation failed: %w", err) + } - updateURL := fmt.Sprintf("http://%s/api/v1/auths/admin/config", conf.OpenWebui.Host) payload, err := json.Marshal(config) if err != nil { return fmt.Errorf("config marshal failed: %w", err) } - updateReq, err := http.NewRequest("POST", updateURL, bytes.NewBuffer(payload)) + updateReq, err := http.NewRequest("POST", postURL, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("UPDATE request creation failed: %w", err) } - updateReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminToken)) + updateReq.Header.Set("Authorization", auth) updateReq.Header.Set("Content-Type", "application/json") updateResp, err := client.Do(updateReq) @@ -241,66 +262,55 @@ func setupOpenWebuiConfig(conf Config, adminToken string) error { return nil } -func setupOpenaiConfig(conf Config, adminToken string) error { - configURL := fmt.Sprintf("http://%s/openai/config", conf.OpenWebui.Host) - getReq, err := http.NewRequest("GET", configURL, nil) - if err != nil { - return fmt.Errorf("GET request creation failed: %w", err) - } - getReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminToken)) - - client := http.Client{} - getResp, err := client.Do(getReq) - if err != nil { - return fmt.Errorf("config fetch failed: %w", err) - } - defer getResp.Body.Close() - - if getResp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(getResp.Body) - return fmt.Errorf("config fetch failed (status %d): %s", getResp.StatusCode, string(body)) - } - - var config map[string]any - if err := json.NewDecoder(getResp.Body).Decode(&config); err != nil { - return fmt.Errorf("config parse failed: %w", err) - } - - // Configure OpenWebUI to use OAuth authentication for the gateway - fmt.Println("setting provider auth type") - config["OPENAI_API_CONFIGS"] = make(map[int]any) - api_conf := config["OPENAI_API_CONFIGS"].(map[int]any) - c := make(map[string]any) - - c["auth_type"] = "system_oauth" - c["model_ids"] = conf.OpenWebui.ModelIds - c["enabled"] = true - c["connection_type"] = "external" - api_conf[0] = c - - updateURL := fmt.Sprintf("http://%s/openai/config/update", conf.OpenWebui.Host) - payload, err := json.Marshal(config) - if err != nil { - return fmt.Errorf("config marshal failed: %w", err) - } - - updateReq, err := http.NewRequest("POST", updateURL, bytes.NewBuffer(payload)) - if err != nil { - return fmt.Errorf("UPDATE request creation failed: %w", err) - } - updateReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminToken)) - updateReq.Header.Set("Content-Type", "application/json") - - updateResp, err := client.Do(updateReq) - if err != nil { - return fmt.Errorf("config update failed: %w", err) - } - defer updateResp.Body.Close() +func setupOpenWebuiConfig(conf Config, adminToken string) error { + configURL := conf.OpenWebui.url("/api/v1/auths/admin/config") + return configRoundTrip(configURL, configURL, adminToken, func(config map[string]any) error { + config["DEFAULT_USER_ROLE"] = "user" + // Gates both minting and presenting an sk- key, and defaults to off. + config["ENABLE_API_KEYS"] = conf.OpenWebui.EnableApiKeys + return nil + }) +} - if updateResp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(updateResp.Body) - return fmt.Errorf("config update failed (status %d): %s", updateResp.StatusCode, string(body)) - } +// setupUserPermissions grants non-admin users the api_keys feature. Admins bypass +// the permission check, so without this only the admin account could use a key. +func setupUserPermissions(conf Config, adminToken string) error { + permsURL := conf.OpenWebui.url("/api/v1/users/default/permissions") + return configRoundTrip(permsURL, permsURL, adminToken, func(perms map[string]any) error { + features, found := perms["features"] + if !found { + features = make(map[string]any) + perms["features"] = features + } + // Replacing a features map we failed to recognise would silently drop every + // other permission in it, so refuse rather than guess. + grants, ok := features.(map[string]any) + if !ok { + return fmt.Errorf("features permission is %T, want an object", features) + } + grants["api_keys"] = conf.OpenWebui.EnableApiKeys + return nil + }) +} - return nil +func setupOpenaiConfig(conf Config, adminToken string) error { + return configRoundTrip( + conf.OpenWebui.url("/openai/config"), + conf.OpenWebui.url("/openai/config/update"), + adminToken, + func(config map[string]any) error { + // No Authorization header upstream: the gateway identifies the caller from + // the signed per-user JWT OpenWebUI forwards alongside the request. + // Keys must be the connection's index as a string; others are dropped. + config["OPENAI_API_CONFIGS"] = map[string]any{ + "0": map[string]any{ + "auth_type": "none", + "model_ids": conf.OpenWebui.ModelIds, + "enabled": true, + "connection_type": "external", + }, + } + return nil + }, + ) } diff --git a/src/init/init_test.go b/src/init/init_test.go new file mode 100644 index 0000000..795e1bc --- /dev/null +++ b/src/init/init_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// configServer stands in for OpenWebUI: it serves current on GET and captures +// whatever gets posted back. +type configServer struct { + current map[string]any + posted map[string]any + authSaw string +} + +// start serves the config and returns the server's host:port. +func (c *configServer) start(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.authSaw = r.Header.Get("Authorization") + switch r.Method { + case "GET": + if err := json.NewEncoder(w).Encode(c.current); err != nil { + t.Errorf("encoding current config: %v", err) + } + case "POST": + if err := json.NewDecoder(r.Body).Decode(&c.posted); err != nil { + t.Errorf("posted body is not JSON: %v", err) + } + w.WriteHeader(http.StatusOK) + } + })) + t.Cleanup(srv.Close) + return strings.TrimPrefix(srv.URL, "http://") +} + +// The config endpoints replace the whole document, so anything the mutation does +// not touch has to survive the round trip or unrelated settings get reset. +func TestConfigRoundTripPreservesUntouchedKeys(t *testing.T) { + srv := &configServer{current: map[string]any{ + "DEFAULT_USER_ROLE": "pending", + "UNRELATED": "keep-me", + "NESTED": map[string]any{"a": float64(1)}, + }} + url := "http://" + srv.start(t) + + err := configRoundTrip(url, url, "tok", func(config map[string]any) error { + config["DEFAULT_USER_ROLE"] = "user" + return nil + }) + if err != nil { + t.Fatalf("configRoundTrip: %v", err) + } + + if srv.posted["UNRELATED"] != "keep-me" { + t.Errorf("UNRELATED dropped, got %v", srv.posted["UNRELATED"]) + } + if nested, ok := srv.posted["NESTED"].(map[string]any); !ok || nested["a"] != float64(1) { + t.Errorf("NESTED dropped, got %v", srv.posted["NESTED"]) + } + if srv.posted["DEFAULT_USER_ROLE"] != "user" { + t.Errorf("mutation not applied, got %v", srv.posted["DEFAULT_USER_ROLE"]) + } + if srv.authSaw != "Bearer tok" { + t.Errorf("admin token not sent, got %q", srv.authSaw) + } +} + +func TestConfigRoundTripReportsFetchFailure(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusForbidden) + })) + t.Cleanup(s.Close) + + err := configRoundTrip(s.URL, s.URL, "tok", func(map[string]any) error { + t.Error("mutate must not run when the fetch fails") + return nil + }) + if err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("want an error naming the status, got %v", err) + } +} + +func TestSetupOpenWebuiConfigGatesApiKeys(t *testing.T) { + for _, enabled := range []bool{true, false} { + t.Run(map[bool]string{true: "enabled", false: "disabled"}[enabled], func(t *testing.T) { + srv := &configServer{current: map[string]any{"ENABLE_API_KEYS": !enabled}} + conf := Config{OpenWebui: OpenWebUi{Host: srv.start(t), EnableApiKeys: enabled}} + + if err := setupOpenWebuiConfig(conf, "tok"); err != nil { + t.Fatalf("setupOpenWebuiConfig: %v", err) + } + if srv.posted["ENABLE_API_KEYS"] != enabled { + t.Errorf("ENABLE_API_KEYS is %v, want %v", srv.posted["ENABLE_API_KEYS"], enabled) + } + if srv.posted["DEFAULT_USER_ROLE"] != "user" { + t.Errorf("DEFAULT_USER_ROLE not set, got %v", srv.posted["DEFAULT_USER_ROLE"]) + } + }) + } +} + +// features is absent on some responses and present on others; either way the grant +// must land without clobbering sibling permission groups. +func TestSetupUserPermissionsGrantsApiKeys(t *testing.T) { + for _, tc := range []struct { + name string + current map[string]any + }{ + {"features absent", map[string]any{"workspace": map[string]any{"models": true}}}, + {"features present", map[string]any{ + "workspace": map[string]any{"models": true}, + "features": map[string]any{"web_search": true, "api_keys": false}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := &configServer{current: tc.current} + conf := Config{OpenWebui: OpenWebUi{Host: srv.start(t), EnableApiKeys: true}} + + if err := setupUserPermissions(conf, "tok"); err != nil { + t.Fatalf("setupUserPermissions: %v", err) + } + features, ok := srv.posted["features"].(map[string]any) + if !ok { + t.Fatalf("features missing from posted permissions: %v", srv.posted) + } + if features["api_keys"] != true { + t.Errorf("api_keys not granted, got %v", features["api_keys"]) + } + if ws, ok := srv.posted["workspace"].(map[string]any); !ok || ws["models"] != true { + t.Errorf("sibling permission group lost, got %v", srv.posted["workspace"]) + } + if _, had := tc.current["features"]; had && features["web_search"] != true { + t.Errorf("existing feature lost, got %v", features) + } + }) + } +} + +// Replacing a features value of an unexpected shape would drop every permission in +// it, so the grant must refuse instead. +func TestSetupUserPermissionsRejectsWrongTypedFeatures(t *testing.T) { + srv := &configServer{current: map[string]any{"features": "not-an-object"}} + conf := Config{OpenWebui: OpenWebUi{Host: srv.start(t), EnableApiKeys: true}} + + err := setupUserPermissions(conf, "tok") + if err == nil { + t.Fatalf("want an error, got nil (posted %v)", srv.posted) + } + if !strings.Contains(err.Error(), "want an object") { + t.Errorf("error should name the problem, got %v", err) + } + if srv.posted != nil { + t.Errorf("permissions were posted anyway: %v", srv.posted) + } +} + +// The gateway needs no Authorization header from OpenWebUI, and OpenWebUI drops +// config entries whose key is not the connection's index as a string. +func TestSetupOpenaiConfigUsesNoAuthAndStringIndex(t *testing.T) { + srv := &configServer{current: map[string]any{ + "ENABLE_OPENAI_API": true, + "OPENAI_API_BASE_URLS": []any{"https://gateway.example.invalid/v1"}, + "OPENAI_API_KEYS": []any{""}, + "OPENAI_API_CONFIGS": map[string]any{}, + }} + conf := Config{OpenWebui: OpenWebUi{Host: srv.start(t), ModelIds: []string{"org/model-a"}}} + + if err := setupOpenaiConfig(conf, "tok"); err != nil { + t.Fatalf("setupOpenaiConfig: %v", err) + } + + configs, ok := srv.posted["OPENAI_API_CONFIGS"].(map[string]any) + if !ok { + t.Fatalf("OPENAI_API_CONFIGS missing: %v", srv.posted) + } + conn, ok := configs["0"].(map[string]any) + if !ok { + t.Fatalf(`connection must be keyed "0", got keys %v`, configs) + } + if conn["auth_type"] != "none" { + t.Errorf("auth_type is %v, want none", conn["auth_type"]) + } + if conn["connection_type"] != "external" || conn["enabled"] != true { + t.Errorf("connection flags wrong: %v", conn) + } + models, ok := conn["model_ids"].([]any) + if !ok || len(models) != 1 || models[0] != "org/model-a" { + t.Errorf("model_ids wrong: %v", conn["model_ids"]) + } + // update_config rejects a payload without this, so the round trip has to carry it. + if srv.posted["ENABLE_OPENAI_API"] != true { + t.Errorf("ENABLE_OPENAI_API lost: %v", srv.posted["ENABLE_OPENAI_API"]) + } +} From 516bb99e3ec6e206269a491204fa7e71b45a786b Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 11:18:21 +0200 Subject: [PATCH 06/12] doc(values): ratelimit unit --- values.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/values.yaml b/values.yaml index 4f7c2fa..69c2aa8 100644 --- a/values.yaml +++ b/values.yaml @@ -6,8 +6,9 @@ envoy: security: jwksUri: rateLimit: - # Shared across all callers. should be well above any per-model limit. Used for - # requests that arrive without a caller identity (no x-sub). Unset means no ceiling. + # Req/hour ceiling shared across all callers. Should be well above any per-model limit. + # Used for requests that arrive without a caller identity (no x-sub). + # Unset means no ceiling. ceilingRequests: gatewayClass: enabled: false From 5f0a51229f2384d4f43ed6c8068dd9a09dde83e7 Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 11:19:11 +0200 Subject: [PATCH 07/12] doc(init): better comments --- src/init/init.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/init/init.go b/src/init/init.go index f30eec2..9e3a022 100644 --- a/src/init/init.go +++ b/src/init/init.go @@ -26,9 +26,8 @@ type OpenWebUi struct { AdminEmail string `koanf:"admin_email"` AdminPassword string `koanf:"admin_password"` ModelIds []string `koanf:"model_ids"` - // Whether users may mint and use API keys. Tied to the gateway accepting the - // identity JWT OpenWebUI forwards: without that, a key reaches OpenWebUI's own - // API but every model call fails, so enabling it alone only widens the surface. + // Enable minting of API keys. Requires the gateway accepting the + // identity JWT OpenWebUI forwards. EnableApiKeys bool `koanf:"enable_api_keys"` } @@ -108,10 +107,9 @@ func initOpenWebui(conf Config) error { } } - // Runs on upgrades too: OpenWebUI stores this in its database, so env vars cannot - // reach an instance that already booted. Each step is a fetch-mutate-post round - // trip, idempotent on repeat but only because they are serialized -- running them - // concurrently would make them overwrite each other's keys. + // Runs on upgrades too: Stored in OpenWebUI db, env vars cannot + // reach existing instances. Each step is a fetch-mutate-post round + // trip, idempotent on repeat. fmt.Println("configuring openwebui") if err := setupOpenWebuiConfig(conf, adminToken); err != nil { return err @@ -202,8 +200,7 @@ func tokenFromResponse(res *http.Response) (string, error) { } // configRoundTrip fetches a JSON config document, applies mutate and posts the -// result back. OpenWebUI's config endpoints replace the whole document instead of -// merging, so keys the mutation does not touch have to be carried over from the GET. +// result back. OpenWebUI's config endpoints replace the whole document. func configRoundTrip(getURL, postURL, adminToken string, mutate func(map[string]any) error) error { // Without a timeout an unresponsive OpenWebUI wedges the init Job forever, and // the Job has no activeDeadlineSeconds to cut it short. From f02e61a0b3c7910331396c0d48d632abcbdf3794 Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 12:42:51 +0200 Subject: [PATCH 08/12] feat(envoy): add openwebui provider to security policy --- templates/envoy/security-policy.yaml | 32 +++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/templates/envoy/security-policy.yaml b/templates/envoy/security-policy.yaml index d535e75..823f683 100644 --- a/templates/envoy/security-policy.yaml +++ b/templates/envoy/security-policy.yaml @@ -1,23 +1,49 @@ -{{- if and .Values.envoy.enabled (include "envoy.jwksUri" .) }} +{{- $jwksUri := include "envoy.jwksUri" . }} +{{- $userJwtSecret := include "openwebui.userJwtSecret" . }} +{{- if and .Values.envoy.enabled (or $jwksUri $userJwtSecret) }} apiVersion: gateway.envoyproxy.io/v1alpha1 kind: SecurityPolicy metadata: - name: {{ include "envoy.fullname" . }}-authentik-jwks + name: {{ include "envoy.fullname" . }}-models-jwt namespace: {{ .Release.Namespace }} spec: targetRef: group: gateway.networking.k8s.io kind: HTTPRoute name: {{ include "envoy.fullname" . }}-models + {{- /* A request has to satisfy only ONE provider: Verification short-circuits on the first + successful provider. A caller presenting both credentials keeps its authentik identity. */}} jwt: providers: + {{- if $jwksUri }} - name: authentik remoteJWKS: - uri: {{ include "envoy.jwksUri" . }} + uri: {{ $jwksUri }} recomputeRoute: true claimToHeaders: - claim: sub header: x-sub - claim: name header: x-name + {{- end }} + {{- with $userJwtSecret }} + {{- /* Allows reaching the models without an authentik token -> for OpenWebUI API keys. */}} + - name: openwebui + {{- /* Pin issuer to only accept tokens minted for this purpose. */}} + issuer: open-webui + localJWKS: + type: Inline + {{- /* k must be unpadded base64url.*/}} + inline: '{"keys":[{"kty":"oct","alg":"HS256","k":"{{ . | b64enc | replace "+" "-" | replace "/" "_" | replace "=" "" }}"}]}' + extractFrom: + headers: + {{- /* No valuePrefix: the header value is the bare token, not "Bearer x". */}} + - name: X-OpenWebUI-User-Jwt + recomputeRoute: true + claimToHeaders: + - claim: sub + header: x-sub + - claim: name + header: x-name + {{- end }} {{- end }} From e7431b7ccca71a8a4811024667b68af8f606bcaa Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 12:45:23 +0200 Subject: [PATCH 09/12] feat(envoy): guard client-side headers --- templates/envoy/client-traffic-policy.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/templates/envoy/client-traffic-policy.yaml b/templates/envoy/client-traffic-policy.yaml index 0890ab0..d786348 100644 --- a/templates/envoy/client-traffic-policy.yaml +++ b/templates/envoy/client-traffic-policy.yaml @@ -1,6 +1,5 @@ {{- if .Values.envoy.enabled }} -# By default, Envoy Gateway sets the buffer limit to 32kiB which is not sufficient for AI workloads. -# This ClientTrafficPolicy sets the buffer limit as configured. +# One gateway-scoped policy: listener-scoped ClientTrafficPolicy override it. apiVersion: gateway.envoyproxy.io/v1alpha1 kind: ClientTrafficPolicy metadata: @@ -11,6 +10,14 @@ spec: - group: gateway.networking.k8s.io kind: Gateway name: {{ include "envoy.fullname" . }} + # By default, Envoy Gateway sets the buffer limit to 32kiB which is not sufficient for AI workloads. connection: bufferLimit: {{ .Values.envoy.clientBufferLimit | default "50Mi" }} +{{- /* A client-set x-sub would outrank the verified claim: jwt_authn appends, and + readers take the first value. Mirror claimToHeaders in security-policy.yaml. */}} + headers: + earlyRequestHeaders: + remove: + - x-sub + - x-name {{- end }} From 235659050bb84401dbc8664166fe1b75f2f6df93 Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 12:46:34 +0200 Subject: [PATCH 10/12] feat(init): config field for api keys --- templates/init_job/init_secret.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/init_job/init_secret.yaml b/templates/init_job/init_secret.yaml index ebfc551..55ce9f0 100644 --- a/templates/init_job/init_secret.yaml +++ b/templates/init_job/init_secret.yaml @@ -11,5 +11,6 @@ stringData: admin_user = "{{ .Values.openwebui.admin.user | required ".Values.openwebui.admin.user is required" }}" admin_email = "{{ .Values.openwebui.admin.email | required ".Values.openwebui.admin.email is required"}}" model_ids = [{{ range $name, $model := .Values.models }}"{{ $model.fullName }}",{{ end }}] + enable_api_keys = {{ if (include "openwebui.userJwtSecret" .) }}true{{ else }}false{{ end }} OPENWEBUI_ADMIN_PASSWORD: {{ .Values.openwebui.admin.password | required ".Values.openwebui.admin.password" | quote }} {{- end }} From 0e41be0938b3f3864e9ac9fa73afe275475fc358 Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 13:01:23 +0200 Subject: [PATCH 11/12] feat(openwebui): set user header --- templates/openwebui/deployment.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/templates/openwebui/deployment.yaml b/templates/openwebui/deployment.yaml index f472915..3ac8285 100644 --- a/templates/openwebui/deployment.yaml +++ b/templates/openwebui/deployment.yaml @@ -76,6 +76,15 @@ spec: value: "false" - name: ENABLE_FORWARD_USER_INFO_HEADERS value: "true" + {{- with (include "openwebui.userJwtSecret" .) }} + {{- /* Replaces the X-OpenWebUI-User-* headers with gateway-signed JWT. + Minted from the user record, so API-key callers get one too. */}} + - name: FORWARD_USER_INFO_HEADER_JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openwebui.fullname" $ }}-oauth + key: FORWARD_USER_INFO_HEADER_JWT_SECRET + {{- end }} - name: ENABLE_OAUTH_ID_TOKEN_COOKIE value: "false" - name: CORS_ALLOW_ORIGIN From 955001b22ab1fcddc50f30c0e1d47e2e54795c28 Mon Sep 17 00:00:00 2001 From: cmdoret Date: Fri, 21 Aug 2026 13:02:21 +0200 Subject: [PATCH 12/12] feat(openwebui): define jwt secret --- templates/openwebui/oauth_secret.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/templates/openwebui/oauth_secret.yaml b/templates/openwebui/oauth_secret.yaml index fbb0d2d..831af65 100644 --- a/templates/openwebui/oauth_secret.yaml +++ b/templates/openwebui/oauth_secret.yaml @@ -7,4 +7,7 @@ metadata: type: Opaque stringData: OAUTH_CLIENT_SECRET: {{ .Values.authentik.oauthApp.clientSecret | required ".Values.authentik.oauthApp.clientSecret is required" }} + {{- with (include "openwebui.userJwtSecret" .) }} + FORWARD_USER_INFO_HEADER_JWT_SECRET: {{ . | quote }} + {{- end }} {{- end }}