diff --git a/README.md b/README.md index 23525c8..22dc586 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ 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) -## 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). @@ -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,31 @@ 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"}]}' +``` diff --git a/src/init/init.go b/src/init/init.go index 822e14a..9e3a022 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,14 @@ type OpenWebUi struct { AdminEmail string `koanf:"admin_email"` AdminPassword string `koanf:"admin_password"` ModelIds []string `koanf:"model_ids"` + // Enable minting of API keys. Requires the gateway accepting the + // identity JWT OpenWebUI forwards. + 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 +96,32 @@ 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: 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") - 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 +137,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 +160,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 +199,20 @@ 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. +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 +229,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 +259,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"]) + } +} 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 }} 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 }} 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 }} 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 }} 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 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 }} diff --git a/values.yaml b/values.yaml index d891bf3..69c2aa8 100644 --- a/values.yaml +++ b/values.yaml @@ -5,6 +5,11 @@ envoy: clientBufferLimit: 50Mi security: jwksUri: + rateLimit: + # 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 name: @@ -69,6 +74,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