From 8875f36dde33a5eedbf456f642d557638eed5f98 Mon Sep 17 00:00:00 2001 From: Admin Bot Date: Mon, 26 Oct 2026 10:23:00 -0700 Subject: [PATCH 1/2] 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. --- go.mod | 18 +- go.sum | 26 +++ internal/auth/jwe_test.go | 21 +++ internal/auth/jwt.go | 222 +++++++++++++++++++++---- internal/auth/zitadel.go | 157 ++++++++++++++++++ internal/auth/zitadel_test.go | 294 +++++++++++++++++++++++++++++++++ internal/config/config.go | 13 +- internal/config/loader.go | 36 ++-- internal/proxy/proxy.go | 50 +++++- k8s/gateway-config-secret.yaml | 2 +- k8s/network-policy.yaml | 14 +- 11 files changed, 789 insertions(+), 64 deletions(-) create mode 100644 internal/auth/jwe_test.go create mode 100644 internal/auth/zitadel.go create mode 100644 internal/auth/zitadel_test.go diff --git a/go.mod b/go.mod index 372b95c..86fc49d 100644 --- a/go.mod +++ b/go.mod @@ -5,39 +5,51 @@ go 1.26.0 require ( github.com/MicahParks/keyfunc/v2 v2.1.0 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/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/trace v1.46.0 go.temporal.io/api v1.63.5 go.temporal.io/sdk v1.48.0 + golang.org/x/net v0.58.0 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // 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/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/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/gorilla/securecookie v1.1.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/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/sdk-go v0.7.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/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/otel/exporters/otlp/otlptrace v1.46.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // 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/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/go.sum b/go.sum index e3f05ca..0cac63b 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,19 @@ 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/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/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 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/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/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.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= 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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/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/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= 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/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y= 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 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/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= 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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 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/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= 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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/auth/jwe_test.go b/internal/auth/jwe_test.go new file mode 100644 index 0000000..7e665d3 --- /dev/null +++ b/internal/auth/jwe_test.go @@ -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) + } +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 8838103..9da98d1 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -3,37 +3,141 @@ package auth import ( "context" "fmt" + gohttp "net/http" "strings" "sync" "time" "github.com/MicahParks/keyfunc/v2" "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). -// Accepts: https://authentik.riotpiao.com/application/o/{provider}/ -func isValidIssuer(iss string) bool { - return strings.Contains(iss, "authentik.riotpiao.com/application/o/") && - strings.HasSuffix(iss, "/") +// issuerMatches reports whether iss is the configured issuer or a sub-path of +// it. Zitadel emits exactly "https://zitadel.riotpiao.com"; providers that +// mount an application path append a suffix (Authentik used +// "/application/o//"). A trailing slash on either side is ignored. +// +// 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. -// Supports multi-issuer: any Authentik service account provider is accepted -// (portfolio-agent, memory-agent, api-gw, etc.) because all share the same -// JWKS signing key. +// audienceValues normalises the aud claim. Zitadel access tokens carry an +// array, older providers carry a single string. +func audienceValues(claims jwt.MapClaims) ([]string, bool) { + 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 { - issuer string // Not used for validation (kept for logging); issuer regex check is sufficient - audience string // Not used for validation; any audience from valid Authentik issuer is accepted + issuer string // Expected issuer (iss claim); empty disables the domain check + audience string // Expected audience; currently logged only (any non-empty aud is accepted) jwksURL string jwks *keyfunc.JWKS 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. -// issuer and audience params are deprecated (ignored for validation) but kept -// for backward compatibility. Multi-issuer validation via isValidIssuer() is used instead. -// JWKS fetching is lazy (deferred until first validation). +// issuer is the expected iss claim (exact value or prefix). audience is kept +// for compatibility; only non-emptiness is enforced. JWKS fetching is lazy +// (deferred until first validation). func NewValidator(issuer, audience, jwksURL string) *Validator { return &Validator{ 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) - if err := v.ensureJWKS(); err != nil { - return nil, err + if v.jwks != nil || v.zitadel == nil { + 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 @@ -120,34 +247,35 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error } } - // Check iss (issuer) - accept any Authentik provider issuer - // (portfolio-agent, memory-agent, api-gw, etc.) - // All use same signing key so JWKS validation is sufficient + // Check iss against the configured issuer (exact match or sub-path). if iss, ok := claims["iss"].(string); !ok { return nil, fmt.Errorf("missing issuer claim") - } else if !isValidIssuer(iss) { - return nil, fmt.Errorf("invalid issuer: %s", iss) + } else if !issuerMatches(iss, v.issuer) { + return nil, fmt.Errorf("invalid issuer: %s (want %s)", iss, v.issuer) } - // Check aud (audience) - accept any Authentik-provided audience - // since all Authentik service accounts use the same signing key. - // The issuer check above is sufficient to ensure JWT came from Authentik. - if aud, ok := claims["aud"].(string); !ok { + // Check aud - Zitadel sends an array; accept a string or array. + if auds, ok := audienceValues(claims); !ok { return nil, fmt.Errorf("missing audience claim") - } else if aud == "" { + } else if len(auds) == 0 { return nil, fmt.Errorf("empty audience claim") } - // Note: Not hardcoding expected audience. Any audience from a valid Authentik - // issuer is accepted, since all service accounts are under the same trust boundary. + // The signature check plus the issuer check establish the trust boundary; + // the audience value itself is not hardcoded. return claims, nil } // 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. 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 perms, ok := permsIface.([]interface{}); ok { 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 roles, ok := rolesIface.([]interface{}); ok { + switch roles := rolesIface.(type) { + case []interface{}: if v.checkPermList(roles, required...) { 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 } diff --git a/internal/auth/zitadel.go b/internal/auth/zitadel.go new file mode 100644 index 0000000..0d4ee49 --- /dev/null +++ b/internal/auth/zitadel.go @@ -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 " 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 +} diff --git a/internal/auth/zitadel_test.go b/internal/auth/zitadel_test.go new file mode 100644 index 0000000..dc26e8e --- /dev/null +++ b/internal/auth/zitadel_test.go @@ -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() +} diff --git a/internal/config/config.go b/internal/config/config.go index 2c09268..5c1d437 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,12 +66,23 @@ type AuthConfig struct { JWKSURL string // RequiredCapability is the permission required for LLM inference (e.g., "llm:inference"). RequiredCapability string - // TokenURL is the Authentik token endpoint for password/refresh grants. + // TokenURL is the OIDC token endpoint used for token exchange + // (client_credentials / password grants). TokenURL string // ClientID is the OAuth2 client ID for token exchange. ClientID string // ClientSecret is the OAuth2 client secret (loaded from env, never from config file). 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. diff --git a/internal/config/loader.go b/internal/config/loader.go index a21375c..37a38ed 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -20,13 +20,16 @@ type rawConfig struct { // rawAuth represents auth configuration in YAML. type rawAuth struct { - Enabled bool `yaml:"enabled"` - Issuer string `yaml:"issuer"` - Audience string `yaml:"audience"` - JWKSURL string `yaml:"jwksUrl"` - RequiredCapability string `yaml:"requiredCapability"` - TokenURL string `yaml:"tokenUrl"` - ClientID string `yaml:"clientId"` + Enabled bool `yaml:"enabled"` + Issuer string `yaml:"issuer"` + Audience string `yaml:"audience"` + JWKSURL string `yaml:"jwksUrl"` + RequiredCapability string `yaml:"requiredCapability"` + 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. @@ -184,14 +187,17 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo // Parse auth config authConfig := AuthConfig{ - Enabled: raw.Auth.Enabled, - Issuer: raw.Auth.Issuer, - Audience: raw.Auth.Audience, - JWKSURL: raw.Auth.JWKSURL, - RequiredCapability: raw.Auth.RequiredCapability, - TokenURL: raw.Auth.TokenURL, - ClientID: raw.Auth.ClientID, - ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"), + Enabled: raw.Auth.Enabled, + Issuer: raw.Auth.Issuer, + Audience: raw.Auth.Audience, + JWKSURL: raw.Auth.JWKSURL, + RequiredCapability: raw.Auth.RequiredCapability, + TokenURL: raw.Auth.TokenURL, + 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 diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 50d413d..5c48558 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -98,8 +98,29 @@ func New(cfg *config.Config) *Handler { defaultMaxBodySize: 100 * 1024 * 1024, } - // Initialize JWT validator if auth is enabled - if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" { + // Initialize JWT validator if auth is enabled. + // + // 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( cfg.Auth.Issuer, cfg.Auth.Audience, @@ -722,16 +743,17 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool { return false } identity.Inject(r, claims) - if h.config.Auth.RequiredCapability != "" { - if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") { + if caps := requiredCapabilities(h.config.Auth.RequiredCapability); len(caps) > 0 { + args := append(caps, "*") + if !h.jwtValidator.CheckPermissions(claims, args...) { writeProblemDetail(w, http.StatusForbidden, "https://api.example.com/problems/insufficient-permissions", "Insufficient Permissions", - fmt.Sprintf("Required capability: %s", h.config.Auth.RequiredCapability), + fmt.Sprintf("Required capability: %s", strings.Join(caps, " or ")), nil) logging.Errorf("auth failed", fmt.Errorf("insufficient permissions"), map[string]string{ "path": r.URL.Path, - "required": h.config.Auth.RequiredCapability, + "required": strings.Join(caps, ","), }) return false } @@ -739,6 +761,22 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool { return true } +// 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. // Returns false when the request is rejected (response already written). func (h *Handler) enforceBodyLimit(w http.ResponseWriter, r *http.Request, route *Route) bool { diff --git a/k8s/gateway-config-secret.yaml b/k8s/gateway-config-secret.yaml index 03cd15a..b9afb99 100644 --- a/k8s/gateway-config-secret.yaml +++ b/k8s/gateway-config-secret.yaml @@ -7,4 +7,4 @@ metadata: app: api-gateway type: Opaque stringData: - config.yaml: "# PRODUCTION GATEWAY CONFIGURATION\n# ==========================================\n# All upstream services MUST use Kubernetes internal service DNS names\n# Format: ..svc.cluster.local\n# \n# This ensures:\n# - Communication within cluster network only (no external IP exposure)\n# - Pod-to-pod service discovery via internal DNS\n# - Security policy enforcement at network level\n# - Service-level load balancing via kube-proxy\n#\n# Routing Pattern:\n# PREFERRED: X-Service header routing (e.g., X-Service: workflow)\n# Legacy: Path-based routing (e.g., /workflow) - being deprecated\n#\nauth:\n enabled: true\n issuer: \"https://authentik.riotpiao.com/application/o/api-gw/\"\n audience: \"api-gw\"\n jwksUrl: \"http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/\"\n requiredCapability: \"llm:inference\"\n tokenUrl: \"http://authentik-server.iam.svc.cluster.local/application/o/token/\"\n clientId: \"api-gw\"\nroutes: []\nmodels:\n# All model services use internal Kubernetes DNS (llm-serving namespace)\n- name: \"reasoning\"\n address: \"reasoning-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"ornith:35b\"\n address: \"ornith-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"qwen2.5:3b-instruct\"\n address: \"qwen-cpu.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"nomic-ai/nomic-embed-text-v2-moe\"\n address: \"embeddings-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/embeddings\"\n- name: \"BAAI/bge-reranker-base\"\n address: \"reranker-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/rerank\"\nadapters:\n- serviceName: sqs\n upstream:\n url: http://management-service.sqs.svc.cluster.local:9090\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: send-message\n methods:\n - verb: POST\n upstreamPath: /sqs/send\n - name: receive-message\n methods:\n - verb: POST\n upstreamPath: /sqs/receive\n - name: list-queues\n methods:\n - verb: GET\n upstreamPath: /sqs/queues\n- serviceName: workflow\n upstream:\n url: grpc://temporal-frontend.temporal.svc.cluster.local:7233\n timeoutSeconds: 60\n auth:\n required: false\n resources:\n - name: execute\n methods:\n - verb: POST\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow\n - name: describe\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution\n - name: list\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions\n- serviceName: memory\n upstream:\n url: http://poimen-memory.poimen.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: query\n methods:\n - verb: POST\n upstreamPath: /memory/query\n - name: ingest\n methods:\n - verb: POST\n upstreamPath: /memory/ingest\n - name: skills\n methods:\n - verb: GET\n upstreamPath: /memory/skills\n- serviceName: s3\n upstream:\n url: http://minio.storage.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-objects\n methods:\n - verb: GET\n upstreamPath: /\n - name: get-object\n methods:\n - verb: GET\n upstreamPath: /\n - name: put-object\n methods:\n - verb: PUT\n upstreamPath: /\n- serviceName: iam\n upstream:\n url: http://authentik-server.iam.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-roles\n methods:\n - verb: GET\n upstreamPath: /api/v3/roles\n - name: list-users\n methods:\n - verb: GET\n upstreamPath: /api/v3/users\n - name: create-role\n methods:\n - verb: POST\n upstreamPath: /api/v3/roles\n" + config.yaml: "# PRODUCTION GATEWAY CONFIGURATION\n# ==========================================\n# All upstream services MUST use Kubernetes internal service DNS names\n# Format: ..svc.cluster.local\n# \n# This ensures:\n# - Communication within cluster network only (no external IP exposure)\n# - Pod-to-pod service discovery via internal DNS\n# - Security policy enforcement at network level\n# - Service-level load balancing via kube-proxy\n#\n# Routing Pattern:\n# PREFERRED: X-Service header routing (e.g., X-Service: workflow)\n# Legacy: Path-based routing (e.g., /workflow) - being deprecated\n#\nauth:\n enabled: true\n # ZITADEL is the only identity provider. CoreDNS rewrites\n # zitadel.riotpiao.com to ingress-nginx, so discovery and JWKS stay inside\n # the cluster and TLS is the wildcard certificate.\n issuer: \"https://zitadel.riotpiao.com\"\n # zitadelDomain enables the official ZITADEL Go SDK path: OIDC discovery,\n # remote JWKS cache, and op.AccessTokenVerifier.\n zitadelDomain: \"zitadel.riotpiao.com\"\n # Audience allow-list: a caller token must carry one of these client IDs.\n # Add a consumer here before it may call the LLM models.\n zitadelClientIds:\n - \"396757800874672511\" # api-gw\n - \"396757803559027071\" # paperless (paperless-ai-agent)\n - \"396757803877794175\" # poimen\n - \"396757801210282367\" # argocd\n - \"396757804213403649\" # queue-sqs\n # Two spellings accepted: ZITADEL role keys cannot carry a colon,\n # Authentik permission strings could.\n requiredCapability: \"llm-inference,llm:inference\"\n # token exchange (client_credentials) against the same instance\n tokenUrl: \"https://zitadel.riotpiao.com/oauth/v2/token\"\n clientId: \"396757800874672511\"\n # jwksUrl is intentionally unset: the SDK discovers it.\nroutes: []\nmodels:\n# All model services use internal Kubernetes DNS (llm-serving namespace)\n- name: \"reasoning\"\n address: \"reasoning-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"ornith:35b\"\n address: \"ornith-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"qwen2.5:3b-instruct\"\n address: \"qwen-cpu.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"nomic-ai/nomic-embed-text-v2-moe\"\n address: \"embeddings-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/embeddings\"\n- name: \"BAAI/bge-reranker-base\"\n address: \"reranker-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/rerank\"\nadapters:\n- serviceName: sqs\n upstream:\n url: http://management-service.sqs.svc.cluster.local:9090\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: send-message\n methods:\n - verb: POST\n upstreamPath: /sqs/send\n - name: receive-message\n methods:\n - verb: POST\n upstreamPath: /sqs/receive\n - name: list-queues\n methods:\n - verb: GET\n upstreamPath: /sqs/queues\n- serviceName: workflow\n upstream:\n url: grpc://temporal-frontend.temporal.svc.cluster.local:7233\n timeoutSeconds: 60\n auth:\n required: false\n resources:\n - name: execute\n methods:\n - verb: POST\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow\n - name: describe\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution\n - name: list\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions\n- serviceName: memory\n upstream:\n url: http://poimen-memory.poimen.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: query\n methods:\n - verb: POST\n upstreamPath: /memory/query\n - name: ingest\n methods:\n - verb: POST\n upstreamPath: /memory/ingest\n - name: skills\n methods:\n - verb: GET\n upstreamPath: /memory/skills\n- serviceName: s3\n upstream:\n url: http://minio.storage.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-objects\n methods:\n - verb: GET\n upstreamPath: /\n - name: get-object\n methods:\n - verb: GET\n upstreamPath: /\n - name: put-object\n methods:\n - verb: PUT\n upstreamPath: /\n- serviceName: iam\n upstream:\n url: http://zitadel.iam.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-roles\n methods:\n - verb: GET\n upstreamPath: /api/v3/roles\n - name: list-users\n methods:\n - verb: GET\n upstreamPath: /api/v3/users\n - name: create-role\n methods:\n - verb: POST\n upstreamPath: /api/v3/roles\n" diff --git a/k8s/network-policy.yaml b/k8s/network-policy.yaml index 619dace..871c266 100644 --- a/k8s/network-policy.yaml +++ b/k8s/network-policy.yaml @@ -99,19 +99,21 @@ spec: ports: - protocol: TCP port: 8080 - # Allow to IAM (Authentik) for JWKS fetch + API - # Authentik pod listens on 9000 (http) and 9443 (https) - # Service translates 80→9000, 443→9443 - # NetworkPolicy matches destination pod port, not service port + # Allow to IAM (Zitadel) for JWKS fetch + API + # Zitadel API listens on 8080 (h2c/plaintext in-cluster); the login UI + # listens on 3000. Public access goes through ingress-nginx on 443 (already + # 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: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: iam ports: - protocol: TCP - port: 9000 + port: 8080 - protocol: TCP - port: 9443 + port: 3000 # Allow to SQS (queue management service) - to: - namespaceSelector: -- 2.54.0 From 29f5c64fab26a2a87c6289af1ac373434a6129ea Mon Sep 17 00:00:00 2001 From: Admin Bot Date: Tue, 27 Oct 2026 01:08:27 -0700 Subject: [PATCH 2/2] feat(gateway): require a token on every adapter 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 -> ":read" anything else -> ":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. --- k8s/gateway-config-secret.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/gateway-config-secret.yaml b/k8s/gateway-config-secret.yaml index b9afb99..aac7dc8 100644 --- a/k8s/gateway-config-secret.yaml +++ b/k8s/gateway-config-secret.yaml @@ -7,4 +7,4 @@ metadata: app: api-gateway type: Opaque stringData: - config.yaml: "# PRODUCTION GATEWAY CONFIGURATION\n# ==========================================\n# All upstream services MUST use Kubernetes internal service DNS names\n# Format: ..svc.cluster.local\n# \n# This ensures:\n# - Communication within cluster network only (no external IP exposure)\n# - Pod-to-pod service discovery via internal DNS\n# - Security policy enforcement at network level\n# - Service-level load balancing via kube-proxy\n#\n# Routing Pattern:\n# PREFERRED: X-Service header routing (e.g., X-Service: workflow)\n# Legacy: Path-based routing (e.g., /workflow) - being deprecated\n#\nauth:\n enabled: true\n # ZITADEL is the only identity provider. CoreDNS rewrites\n # zitadel.riotpiao.com to ingress-nginx, so discovery and JWKS stay inside\n # the cluster and TLS is the wildcard certificate.\n issuer: \"https://zitadel.riotpiao.com\"\n # zitadelDomain enables the official ZITADEL Go SDK path: OIDC discovery,\n # remote JWKS cache, and op.AccessTokenVerifier.\n zitadelDomain: \"zitadel.riotpiao.com\"\n # Audience allow-list: a caller token must carry one of these client IDs.\n # Add a consumer here before it may call the LLM models.\n zitadelClientIds:\n - \"396757800874672511\" # api-gw\n - \"396757803559027071\" # paperless (paperless-ai-agent)\n - \"396757803877794175\" # poimen\n - \"396757801210282367\" # argocd\n - \"396757804213403649\" # queue-sqs\n # Two spellings accepted: ZITADEL role keys cannot carry a colon,\n # Authentik permission strings could.\n requiredCapability: \"llm-inference,llm:inference\"\n # token exchange (client_credentials) against the same instance\n tokenUrl: \"https://zitadel.riotpiao.com/oauth/v2/token\"\n clientId: \"396757800874672511\"\n # jwksUrl is intentionally unset: the SDK discovers it.\nroutes: []\nmodels:\n# All model services use internal Kubernetes DNS (llm-serving namespace)\n- name: \"reasoning\"\n address: \"reasoning-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"ornith:35b\"\n address: \"ornith-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"qwen2.5:3b-instruct\"\n address: \"qwen-cpu.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"nomic-ai/nomic-embed-text-v2-moe\"\n address: \"embeddings-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/embeddings\"\n- name: \"BAAI/bge-reranker-base\"\n address: \"reranker-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/rerank\"\nadapters:\n- serviceName: sqs\n upstream:\n url: http://management-service.sqs.svc.cluster.local:9090\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: send-message\n methods:\n - verb: POST\n upstreamPath: /sqs/send\n - name: receive-message\n methods:\n - verb: POST\n upstreamPath: /sqs/receive\n - name: list-queues\n methods:\n - verb: GET\n upstreamPath: /sqs/queues\n- serviceName: workflow\n upstream:\n url: grpc://temporal-frontend.temporal.svc.cluster.local:7233\n timeoutSeconds: 60\n auth:\n required: false\n resources:\n - name: execute\n methods:\n - verb: POST\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow\n - name: describe\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution\n - name: list\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions\n- serviceName: memory\n upstream:\n url: http://poimen-memory.poimen.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: query\n methods:\n - verb: POST\n upstreamPath: /memory/query\n - name: ingest\n methods:\n - verb: POST\n upstreamPath: /memory/ingest\n - name: skills\n methods:\n - verb: GET\n upstreamPath: /memory/skills\n- serviceName: s3\n upstream:\n url: http://minio.storage.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-objects\n methods:\n - verb: GET\n upstreamPath: /\n - name: get-object\n methods:\n - verb: GET\n upstreamPath: /\n - name: put-object\n methods:\n - verb: PUT\n upstreamPath: /\n- serviceName: iam\n upstream:\n url: http://zitadel.iam.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-roles\n methods:\n - verb: GET\n upstreamPath: /api/v3/roles\n - name: list-users\n methods:\n - verb: GET\n upstreamPath: /api/v3/users\n - name: create-role\n methods:\n - verb: POST\n upstreamPath: /api/v3/roles\n" + config.yaml: "# PRODUCTION GATEWAY CONFIGURATION\n# ==========================================\n# All upstream services MUST use Kubernetes internal service DNS names\n# Format: ..svc.cluster.local\n# \n# This ensures:\n# - Communication within cluster network only (no external IP exposure)\n# - Pod-to-pod service discovery via internal DNS\n# - Security policy enforcement at network level\n# - Service-level load balancing via kube-proxy\n#\n# Routing Pattern:\n# PREFERRED: X-Service header routing (e.g., X-Service: workflow)\n# Legacy: Path-based routing (e.g., /workflow) - being deprecated\n#\nauth:\n enabled: true\n # ZITADEL is the only identity provider. CoreDNS rewrites\n # zitadel.riotpiao.com to ingress-nginx, so discovery and JWKS stay inside\n # the cluster and TLS is the wildcard certificate.\n issuer: \"https://zitadel.riotpiao.com\"\n # zitadelDomain enables the official ZITADEL Go SDK path: OIDC discovery,\n # remote JWKS cache, and op.AccessTokenVerifier.\n zitadelDomain: \"zitadel.riotpiao.com\"\n # Audience allow-list: a caller token must carry one of these client IDs.\n # Add a consumer here before it may call the LLM models.\n zitadelClientIds:\n - \"396757800874672511\" # api-gw\n - \"396757803559027071\" # paperless (paperless-ai-agent)\n - \"396757803877794175\" # poimen\n - \"396757801210282367\" # argocd\n - \"396757804213403649\" # queue-sqs\n # Two spellings accepted. ZITADEL does store role keys with a colon (this\n # project holds iam:read, memory:write and llm:inference), so the colon form\n # is primary and the hyphen form is the legacy alias.\n requiredCapability: \"llm-inference,llm:inference\"\n # token exchange (client_credentials) against the same instance\n tokenUrl: \"https://zitadel.riotpiao.com/oauth/v2/token\"\n clientId: \"396757800874672511\"\n # jwksUrl is intentionally unset: the SDK discovers it.\nroutes: []\nmodels:\n# All model services use internal Kubernetes DNS (llm-serving namespace)\n- name: \"reasoning\"\n address: \"reasoning-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"ornith:35b\"\n address: \"ornith-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"qwen2.5:3b-instruct\"\n address: \"qwen-cpu.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"nomic-ai/nomic-embed-text-v2-moe\"\n address: \"embeddings-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/embeddings\"\n- name: \"BAAI/bge-reranker-base\"\n address: \"reranker-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/rerank\"\nadapters:\n- serviceName: sqs\n upstream:\n url: http://management-service.sqs.svc.cluster.local:9090\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: send-message\n methods:\n - verb: POST\n upstreamPath: /sqs/send\n - name: receive-message\n methods:\n - verb: POST\n upstreamPath: /sqs/receive\n - name: list-queues\n methods:\n - verb: GET\n upstreamPath: /sqs/queues\n- serviceName: workflow\n upstream:\n url: grpc://temporal-frontend.temporal.svc.cluster.local:7233\n timeoutSeconds: 60\n auth:\n required: true\n resources:\n - name: execute\n methods:\n - verb: POST\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow\n - name: describe\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution\n - name: list\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions\n- serviceName: memory\n upstream:\n url: http://poimen-memory.poimen.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: query\n methods:\n - verb: POST\n upstreamPath: /memory/query\n - name: ingest\n methods:\n - verb: POST\n upstreamPath: /memory/ingest\n - name: skills\n methods:\n - verb: GET\n upstreamPath: /memory/skills\n- serviceName: s3\n upstream:\n url: http://minio.storage.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: list-objects\n methods:\n - verb: GET\n upstreamPath: /\n - name: get-object\n methods:\n - verb: GET\n upstreamPath: /\n - name: put-object\n methods:\n - verb: PUT\n upstreamPath: /\n- serviceName: iam\n upstream:\n url: http://zitadel.iam.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: list-roles\n methods:\n - verb: GET\n upstreamPath: /api/v3/roles\n - name: list-users\n methods:\n - verb: GET\n upstreamPath: /api/v3/users\n - name: create-role\n methods:\n - verb: POST\n upstreamPath: /api/v3/roles\n" -- 2.54.0