From e16494d1de68be710b6b64f22b3c5a25c83a4421 Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Thu, 20 Aug 2026 16:36:03 -0700 Subject: [PATCH 1/6] fix(event-ledger): route JWT auth away from the API key evaluator The policy provider funnelled every credential to the API key policy evaluator, whose contract requires an opaque API key. JWT-bearing callers were therefore rejected, and because the per-route scope wrappers were inert under that provider, token scopes were never enforced either. Split the two credentials into independent paths chosen by token shape. A JWT is verified against the configured JWKS and then authorized by the per-route scope check. An API key is forwarded to the evaluator as before and skips the scope check, since it carries no scopes. Requests the evaluator authorizes are marked so the scope wrapper lets them through. This removes the request-clone and no-op ResponseWriter workaround that let the JWT parser run inside the policy middleware, and passes the issuer and audience options through to the parser. Also accept the evaluator's actual verdict field name. It reports "allowed" while the response type only read "allow", so successful evaluations deserialized as denials. The existing client test hardcoded the wrong shape and masked this. Adds coverage for both paths, including scope enforcement driven through the real parser against a generated ES256 key and JWKS endpoint. --- .../cmd/api/startup/run_service.go | 23 +- .../internal/middleware/BUILD.bazel | 4 + .../internal/middleware/dual_auth.go | 71 +++++ .../internal/middleware/dual_auth_test.go | 282 ++++++++++++++++++ .../event-ledger/internal/middleware/jwt.go | 5 + .../internal/middleware/policy.go | 89 +----- .../internal/middleware/policy_test.go | 24 +- .../internal/policy/api_keys_client_test.go | 3 +- 8 files changed, 402 insertions(+), 99 deletions(-) create mode 100644 src/control-plane-services/event-ledger/internal/middleware/dual_auth.go create mode 100644 src/control-plane-services/event-ledger/internal/middleware/dual_auth_test.go diff --git a/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go b/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go index b06b78702..0add4321b 100644 --- a/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go +++ b/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go @@ -177,8 +177,6 @@ func runService(cfg config.Config) error { authRouter.Use(tracingMW) authRouter.Use(loggerMW) - // If we're not using Policy, we need to handle scope checks locally in our middleware - // We assume we're using Policy by default var requireLocalScopeCheck = false if cfg.Auth.Enabled { @@ -295,14 +293,25 @@ func runService(cfg config.Config) error { policyMiddleware := middleware.NewPolicyMiddleware( policyClient, "nv-cloud-functions", - cfg.Auth.JWKSetUrl, - cacheDuration, - jwkCache, - &cfg.HTTP, logger, ) - authRouter.Use(policyMiddleware) + var jwtMiddleware mux.MiddlewareFunc + if cfg.Auth.JWKSetUrl != "" { + jwtOpts := middleware.NewJWTParserOptions(cfg.Auth.JWKSetUrl, nil, cacheDuration, &cfg.HTTP) + jwtOpts.Issuer = cfg.Auth.Issuer + jwtOpts.Audience = cfg.Auth.Audience + jwtMiddleware = middleware.NewParseJWTMiddleware(jwtOpts, jwkCache) + } + + jwtPath := jwtMiddleware + if !cfg.SelfManaged && jwtMiddleware != nil { + jwtPath = middleware.ChainMiddleware(jwtMiddleware, policyMiddleware) + } else if cfg.SelfManaged { + requireLocalScopeCheck = true + } + + authRouter.Use(middleware.NewDualAuthMiddleware(jwtPath, policyMiddleware)) default: // This should never be reached since ValidateAuthConfig handles invalid providers logger.Error("auth is enabled but no valid auth provider was provided") diff --git a/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel b/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel index e9daf21dd..eb797652e 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel +++ b/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel @@ -6,6 +6,7 @@ go_library( "body_limit.go", "constants.go", "cors.go", + "dual_auth.go", "http_client.go", "jwt.go", "metrics.go", @@ -47,6 +48,7 @@ alias( go_test( name = "middleware_test", srcs = [ + "dual_auth_test.go", "jwt_test.go", "metrics_test.go", "policy_test.go", @@ -63,7 +65,9 @@ go_test( "@com_github_nvidia_nvcf_src_libraries_go_lib//pkg/nvkit/clients/pdp_types", "@com_github_prometheus_client_golang//prometheus/promhttp", "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", "@com_github_uptrace_opentelemetry_go_extra_otelzap//:otelzap", + "@org_golang_google_protobuf//types/known/structpb", "@org_uber_go_zap//zaptest", ], ) diff --git a/src/control-plane-services/event-ledger/internal/middleware/dual_auth.go b/src/control-plane-services/event-ledger/internal/middleware/dual_auth.go new file mode 100644 index 000000000..d2e8999f1 --- /dev/null +++ b/src/control-plane-services/event-ledger/internal/middleware/dual_auth.go @@ -0,0 +1,71 @@ +/* +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +const pdpAuthorizedContextKey contextKey = "pdp_authorized" + +func BearerToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + return "" + } + return strings.TrimPrefix(authHeader, "Bearer ") +} + +func MarkPDPAuthorized(ctx context.Context) context.Context { + return context.WithValue(ctx, pdpAuthorizedContextKey, true) +} + +func IsPDPAuthorized(ctx context.Context) bool { + authorized, ok := ctx.Value(pdpAuthorizedContextKey).(bool) + return ok && authorized +} + +func ChainMiddleware(first, second mux.MiddlewareFunc) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return first(second(next)) + } +} + +// Routes by token shape: JWT-shaped tokens take jwtPath, the rest apiKeyPath. +func NewDualAuthMiddleware(jwtPath, apiKeyPath mux.MiddlewareFunc) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + if jwtPath == nil { + return apiKeyPath(next) + } + + jwtChain := jwtPath(next) + policyChain := apiKeyPath(next) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isJWTShapedToken(BearerToken(r)) { + jwtChain.ServeHTTP(w, r) + return + } + policyChain.ServeHTTP(w, r) + }) + } +} diff --git a/src/control-plane-services/event-ledger/internal/middleware/dual_auth_test.go b/src/control-plane-services/event-ledger/internal/middleware/dual_auth_test.go new file mode 100644 index 000000000..d1a436f14 --- /dev/null +++ b/src/control-plane-services/event-ledger/internal/middleware/dual_auth_test.go @@ -0,0 +1,282 @@ +/* +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package middleware + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/config" + policyclient "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/policy" + pdpv1 "github.com/NVIDIA/nvcf/src/libraries/go/lib/pkg/nvkit/clients/pdp_types" + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/mux" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uptrace/opentelemetry-go-extra/otelzap" + "go.uber.org/zap/zaptest" + "google.golang.org/protobuf/types/known/structpb" +) + +type recordingPolicyClient struct { + called bool + allowed bool +} + +func (c *recordingPolicyClient) Evaluate(_ context.Context, _ *pdpv1.RuleRequest) (*pdpv1.RuleResponse, error) { + c.called = true + result, err := structpb.NewValue(map[string]interface{}{ + "allowed": c.allowed, + "ncaId": "nca-1", + "ownerId": "owner-1", + }) + if err != nil { + return nil, err + } + return &pdpv1.RuleResponse{Result: result}, nil +} + +func (c *recordingPolicyClient) PolicyConfig() *policyclient.PolicyConfig { + return &policyclient.PolicyConfig{Namespace: "event-ledger", PolicyFQDN: "apikey.allow"} +} + +func staticJWTMiddleware(claims jwt.MapClaims) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if claims == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), claimsContextKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func newDualAuthTestHandler(t *testing.T, claims jwt.MapClaims, client *recordingPolicyClient, requiredScopes Scopes) http.Handler { + t.Helper() + logger := otelzap.New(zaptest.NewLogger(t)) + dualAuth := NewDualAuthMiddleware( + staticJWTMiddleware(claims), + NewPolicyMiddleware(client, "nv-cloud-functions", logger), + ) + scoped := MaybeRequireScopes(logger, true, requiredScopes, RequireAnyScopes) + return dualAuth(scoped(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }))) +} + +func TestDualAuthJWTNeverReachesPolicyDecisionPoint(t *testing.T) { + client := &recordingPolicyClient{allowed: true} + claims := jwt.MapClaims{"sub": "sis-api", "scopes": []interface{}{"fnds:createEvent"}} + handler := newDualAuthTestHandler(t, claims, client, WriteScopes) + + req := httptest.NewRequest(http.MethodPost, "/v3/ledger/cloudevents", nil) + req.Header.Set("Authorization", "Bearer header.payload.signature") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.False(t, client.called, "OpenBao JWT must not be sent to api-keys-api") +} + +func TestDualAuthJWTWithoutRequiredScopeIsForbidden(t *testing.T) { + client := &recordingPolicyClient{allowed: true} + claims := jwt.MapClaims{ + "sub": "sis-api", + "scopes": []interface{}{"fnds:createEvent", "fnds:archiveEvents"}, + } + handler := newDualAuthTestHandler(t, claims, client, ReadScopes) + + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer header.payload.signature") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusForbidden, recorder.Code) + assert.False(t, client.called) +} + +func TestDualAuthAPIKeySkipsJWTVerificationAndScopeCheck(t *testing.T) { + client := &recordingPolicyClient{allowed: true} + handler := newDualAuthTestHandler(t, nil, client, ReadScopes) + + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer nvapi-opaque-key") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.True(t, client.called, "API key must be authorized by api-keys-api") +} + +func TestDualAuthAPIKeyDeniedByPolicyDecisionPoint(t *testing.T) { + client := &recordingPolicyClient{allowed: false} + handler := newDualAuthTestHandler(t, nil, client, ReadScopes) + + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer nvapi-opaque-key") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusForbidden, recorder.Code) + assert.True(t, client.called) +} + +func TestPolicyAuthzResponseAcceptsBothVerdictFieldNames(t *testing.T) { + var apiKeysShaped PolicyAuthzResponse + require.NoError(t, json.Unmarshal([]byte(`{"allowed":true}`), &apiKeysShaped)) + assert.True(t, apiKeysShaped.Allowed) + assert.False(t, apiKeysShaped.Allow) + + var managedShaped PolicyAuthzResponse + require.NoError(t, json.Unmarshal([]byte(`{"allow":true}`), &managedShaped)) + assert.True(t, managedShaped.Allow) +} + +func TestManagedJWTStillDelegatesToPolicyDecisionPoint(t *testing.T) { + client := &recordingPolicyClient{allowed: true} + claims := jwt.MapClaims{"sub": "user-1", "scopes": []interface{}{"fnds:getEvents"}} + logger := otelzap.New(zaptest.NewLogger(t)) + + policyMiddleware := NewPolicyMiddleware(client, "nv-cloud-functions", logger) + jwtPath := ChainMiddleware(staticJWTMiddleware(claims), policyMiddleware) + + var capturedCtx context.Context + handler := NewDualAuthMiddleware(jwtPath, policyMiddleware)( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedCtx = r.Context() + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer header.payload.signature") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.True(t, client.called, "managed deployments must still consult the PDP") + require.NotNil(t, capturedCtx) + assert.Equal(t, "user-1", GetClaims(capturedCtx)["sub"]) +} + +func TestPDPAuthorizedContextMarker(t *testing.T) { + assert.False(t, IsPDPAuthorized(context.Background())) + assert.True(t, IsPDPAuthorized(MarkPDPAuthorized(context.Background()))) +} + +func TestBearerToken(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + assert.Equal(t, "", BearerToken(req)) + + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + assert.Equal(t, "", BearerToken(req)) + + req.Header.Set("Authorization", "Bearer abc.def.ghi") + assert.Equal(t, "abc.def.ghi", BearerToken(req)) +} + +func newSigningKeyAndJWKS(t *testing.T) (*ecdsa.PrivateKey, string, func()) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + key, err := jwk.FromRaw(&privateKey.PublicKey) + require.NoError(t, err) + require.NoError(t, key.Set(jwk.KeyIDKey, "test-key")) + require.NoError(t, key.Set(jwk.AlgorithmKey, "ES256")) + + set := jwk.NewSet() + require.NoError(t, set.AddKey(key)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(set)) + })) + return privateKey, server.URL, server.Close +} + +func signToken(t *testing.T, key *ecdsa.PrivateKey, scopes []string) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "sub": "sis-api", + "scopes": scopes, + "exp": time.Now().Add(time.Hour).Unix(), + }) + token.Header[jwk.KeyIDKey] = "test-key" + signed, err := token.SignedString(key) + require.NoError(t, err) + return signed +} + +func TestRealJWTParserEnforcesScopes(t *testing.T) { + key, jwksURL, closeServer := newSigningKeyAndJWKS(t) + defer closeServer() + + logger := otelzap.New(zaptest.NewLogger(t)) + jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) + jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) + jwtMiddleware := NewParseJWTMiddleware(jwtOpts, jwkCache) + + tests := []struct { + name string + tokenScope []string + required Scopes + wantStatus int + }{ + {"write scope on write route", []string{"fnds:createEvent"}, WriteScopes, http.StatusOK}, + {"archive scope on archive route", []string{"fnds:archiveEvents"}, ArchiveScopes, http.StatusOK}, + {"write scope on read route", []string{"fnds:createEvent"}, ReadScopes, http.StatusForbidden}, + {"read scope on read route", []string{"fnds:getEvents"}, ReadScopes, http.StatusOK}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := &recordingPolicyClient{allowed: true} + handler := NewDualAuthMiddleware( + jwtMiddleware, + NewPolicyMiddleware(client, "nv-cloud-functions", logger), + )(MaybeRequireScopes(logger, true, tc.required, RequireAnyScopes)( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }))) + + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer "+signToken(t, key, tc.tokenScope)) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String()) + assert.False(t, client.called) + }) + } +} diff --git a/src/control-plane-services/event-ledger/internal/middleware/jwt.go b/src/control-plane-services/event-ledger/internal/middleware/jwt.go index dc8b33534..597805447 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/jwt.go +++ b/src/control-plane-services/event-ledger/internal/middleware/jwt.go @@ -299,6 +299,11 @@ func requireScopes(requiredScopes Scopes, scopeRequirement ScopeRequirement) fun claims, ok := r.Context().Value(claimsContextKey).(jwt.MapClaims) if !ok { + // API keys carry no scopes. + if IsPDPAuthorized(parentCtx) { + next.ServeHTTP(w, r) + return + } logger.WarnContext(traceCtx, ErrMissingClaims) status := http.StatusUnauthorized // http.Error(w, ErrMissingClaims, status) diff --git a/src/control-plane-services/event-ledger/internal/middleware/policy.go b/src/control-plane-services/event-ledger/internal/middleware/policy.go index 3be1afb02..37768b317 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/policy.go +++ b/src/control-plane-services/event-ledger/internal/middleware/policy.go @@ -23,19 +23,15 @@ import ( "net/http" "strconv" "strings" - "time" "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/observability/logging" "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/policy" "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" - "github.com/lestrrat-go/jwx/v2/jwk" "github.com/uptrace/opentelemetry-go-extra/otelzap" "go.uber.org/zap" pdpv1 "github.com/NVIDIA/nvcf/src/libraries/go/lib/pkg/nvkit/clients/pdp_types" - - "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/config" ) // Policy context keys - using the contextKey type already defined in jwt.go @@ -53,6 +49,7 @@ const ( // PolicyAuthzResponse holds the response from Policy authorization type PolicyAuthzResponse struct { Allow bool `json:"allow"` + Allowed bool `json:"allowed"` StatusCode int `json:"statusCode"` Reasons []string `json:"reasons"` ActorID string `json:"actorId"` @@ -185,8 +182,7 @@ func mergePolicyClaims(jwtClaims map[string]interface{}, authResponse PolicyAuth return claims } -// NewPolicyMiddleware creates a new Policy middleware -func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, jwtPubKeySetURL string, jwtTokenExpiration time.Duration, jwkCache *jwk.Cache, httpConfig *config.HTTPClientConfig, logger *otelzap.Logger) mux.MiddlewareFunc { +func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, logger *otelzap.Logger) mux.MiddlewareFunc { if policyClient == nil { if logger != nil { logger.Error("policy client is nil - denying requests") @@ -198,19 +194,6 @@ func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, jwt } } - // Initialize JWT parser if URL is provided - var jwtMiddleware mux.MiddlewareFunc - if jwtPubKeySetURL != "" { - jwtOpts := NewJWTParserOptions( - jwtPubKeySetURL, - nil, // Use default signing method - jwtTokenExpiration, - httpConfig, - ) - jwtMiddleware = NewParseJWTMiddleware(jwtOpts, jwkCache) - logger.Info("jwt middleware initialized successfully") - } - return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Tracing handled by external library @@ -238,44 +221,16 @@ func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, jwt "service": serviceName, } - // Try to parse as JWT if parser is available var jwtClaims map[string]interface{} - - if jwtMiddleware != nil && token != "" && isJWTShapedToken(token) { - // Create a handler that will capture the JWT claims - claimsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - claims, ok := r.Context().Value(claimsContextKey).(jwt.MapClaims) - if ok { - jwtClaims = map[string]interface{}(claims) - logger.InfoContext(traceCtx, "policy: jwt token parsed successfully") - - // Add JWT specific fields to auth context - if subj, ok := claims["sub"].(string); ok { - setAuthContextField(authCtx, subjectField, subj) - } - if scopes, ok := claims["scopes"].([]interface{}); ok && len(scopes) > 0 { - authCtx["scopes"] = scopes - } - } - }) - - // Apply JWT middleware to process the token - jwtChain := jwtMiddleware(claimsHandler) - - // Create a fake ResponseWriter that doesn't actually write - dummyWriter := &dummyResponseWriter{header: make(http.Header)} - - // Copy the request to avoid modifying the original - reqCopy := r.Clone(traceCtx) - jwtChain.ServeHTTP(dummyWriter, reqCopy) - - if jwtClaims == nil { - logger.WarnContext(traceCtx, "policy: jwt-shaped token failed validation") - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return + if claims, ok := r.Context().Value(claimsContextKey).(jwt.MapClaims); ok { + jwtClaims = map[string]interface{}(claims) + if subj, ok := claims["sub"].(string); ok { + setAuthContextField(authCtx, subjectField, subj) } - } else if token != "" { - logger.InfoContext(traceCtx, "policy: token appears to be an api key, not a jwt") + if scopes, ok := claims["scopes"].([]interface{}); ok && len(scopes) > 0 { + authCtx["scopes"] = scopes + } + } else { setAuthContextField(authCtx, apiKeyField, token) } @@ -344,8 +299,8 @@ func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, jwt zap.Int("reason_count", len(authResponse.Reasons)), ) - // 8. Check if allowed - if !authResponse.Allow { + // Upstream evaluators disagree on the verdict field name. + if !authResponse.Allow && !authResponse.Allowed { statusCode := authResponse.StatusCode if statusCode == 0 { statusCode = http.StatusUnauthorized @@ -367,7 +322,7 @@ func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, jwt // 9. Authorization succeeded - enrich context with user info logger.InfoContext(traceCtx, "policy: authorization successful") - var requestCtx = r.Context() + var requestCtx = MarkPDPAuthorized(r.Context()) // Create enriched context if authResponse.ActorID != "" { requestCtx = context.WithValue(requestCtx, policyActorIDContextKey, authResponse.ActorID) @@ -396,21 +351,3 @@ func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, jwt }) } } - -// dummyResponseWriter is a no-op ResponseWriter used to capture JWT claims -// without actually writing anything to the client -type dummyResponseWriter struct { - header http.Header -} - -func (d *dummyResponseWriter) Header() http.Header { - return d.header -} - -func (d *dummyResponseWriter) Write([]byte) (int, error) { - return 0, nil -} - -func (d *dummyResponseWriter) WriteHeader(statusCode int) { - // Do nothing -} diff --git a/src/control-plane-services/event-ledger/internal/middleware/policy_test.go b/src/control-plane-services/event-ledger/internal/middleware/policy_test.go index 024eeb0fd..d4d228478 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/policy_test.go +++ b/src/control-plane-services/event-ledger/internal/middleware/policy_test.go @@ -719,18 +719,20 @@ func TestPolicyAuthInputFields(t *testing.T) { func TestNewPolicyMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) { client := &rejectingJWTPolicyClient{} logger := otelzap.New(zaptest.NewLogger(t)) - policyMiddleware := NewPolicyMiddleware( - client, - "test-service", + policyMiddleware := NewPolicyMiddleware(client, "test-service", logger) + + jwtOpts := NewJWTParserOptions( "https://issuer.test/.well-known/jwks.json", + nil, time.Minute, - jwk.NewCache(context.Background()), &config.HTTPClientConfig{}, - logger, ) + jwtMiddleware := NewParseJWTMiddleware(jwtOpts, jwk.NewCache(context.Background())) + + dualAuth := NewDualAuthMiddleware(jwtMiddleware, policyMiddleware) handlerCalled := false - handler := policyMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := dualAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handlerCalled = true w.WriteHeader(http.StatusOK) })) @@ -747,15 +749,7 @@ func TestNewPolicyMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) } func TestNewPolicyMiddlewareRejectsRequestsWithNilClientAndLogger(t *testing.T) { - policyMiddleware := NewPolicyMiddleware( - nil, - "test-service", - "", - 0, - nil, - &config.HTTPClientConfig{}, - nil, - ) + policyMiddleware := NewPolicyMiddleware(nil, "test-service", nil) handlerCalled := false handler := policyMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/src/control-plane-services/event-ledger/internal/policy/api_keys_client_test.go b/src/control-plane-services/event-ledger/internal/policy/api_keys_client_test.go index fb90ef092..3eea8ec78 100644 --- a/src/control-plane-services/event-ledger/internal/policy/api_keys_client_test.go +++ b/src/control-plane-services/event-ledger/internal/policy/api_keys_client_test.go @@ -35,8 +35,9 @@ func TestAPIKeysClient_PolicyConfig(t *testing.T) { } func TestAPIKeysClient_Evaluate(t *testing.T) { + // api-keys-api returns result.allowed, not result.allow. allowResponse := map[string]any{ - "result": map[string]any{"allow": true}, + "result": map[string]any{"allowed": true, "ncaId": "nca-1", "ownerId": "owner-1"}, } tests := []struct { From af1ec1d0c1147f764162199b16f724af4d7be887 Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Fri, 28 Aug 2026 15:37:35 -0700 Subject: [PATCH 2/6] refactor(event-ledger): collapse JWT/API-key dispatch into one auth middleware Route registration previously had to build and wire two separate middlewares (a JWT parser and the policy client) and pass both into a dispatcher, so every call site needed to know both credential types exist. Fold the dispatch into a single NewAuthMiddleware, replacing the exported NewPolicyMiddleware/NewDualAuthMiddleware pair. run_service.go now makes one call and sees one mux.MiddlewareFunc; JWT verification, scope enforcement, and delegation to the policy client for API keys are all internal to it. Verified against a self-managed cluster: OpenBao JWT writes still succeed, JWT reads still 403 for missing scope, malformed/missing credentials still 401, and a minted API key still authorizes reads, in-cluster and through the gateway. Signed-off-by: Shelley Shen --- .../cmd/api/startup/run_service.go | 34 +-- .../{dual_auth_test.go => auth_test.go} | 247 +++++++----------- .../internal/middleware/dual_auth.go | 71 ----- .../event-ledger/internal/middleware/jwt.go | 2 +- .../internal/middleware/policy.go | 72 ++++- .../internal/middleware/policy_test.go | 14 +- 6 files changed, 189 insertions(+), 251 deletions(-) rename src/control-plane-services/event-ledger/internal/middleware/{dual_auth_test.go => auth_test.go} (64%) delete mode 100644 src/control-plane-services/event-ledger/internal/middleware/dual_auth.go diff --git a/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go b/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go index 0add4321b..e6149f0e6 100644 --- a/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go +++ b/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go @@ -288,30 +288,24 @@ func runService(cfg config.Config) error { } } - // Create Policy middleware with the client - // The underlying HTTP client will be refreshed automatically when credentials change - policyMiddleware := middleware.NewPolicyMiddleware( - policyClient, - "nv-cloud-functions", - logger, - ) - - var jwtMiddleware mux.MiddlewareFunc + var jwtOpts *middleware.JWTParserOptions if cfg.Auth.JWKSetUrl != "" { - jwtOpts := middleware.NewJWTParserOptions(cfg.Auth.JWKSetUrl, nil, cacheDuration, &cfg.HTTP) - jwtOpts.Issuer = cfg.Auth.Issuer - jwtOpts.Audience = cfg.Auth.Audience - jwtMiddleware = middleware.NewParseJWTMiddleware(jwtOpts, jwkCache) + opts := middleware.NewJWTParserOptions(cfg.Auth.JWKSetUrl, nil, cacheDuration, &cfg.HTTP) + opts.Issuer = cfg.Auth.Issuer + opts.Audience = cfg.Auth.Audience + jwtOpts = &opts } - jwtPath := jwtMiddleware - if !cfg.SelfManaged && jwtMiddleware != nil { - jwtPath = middleware.ChainMiddleware(jwtMiddleware, policyMiddleware) - } else if cfg.SelfManaged { - requireLocalScopeCheck = true - } + requireLocalScopeCheck = cfg.SelfManaged - authRouter.Use(middleware.NewDualAuthMiddleware(jwtPath, policyMiddleware)) + authRouter.Use(middleware.NewAuthMiddleware( + policyClient, + "nv-cloud-functions", + jwtOpts, + jwkCache, + cfg.SelfManaged, + logger, + )) default: // This should never be reached since ValidateAuthConfig handles invalid providers logger.Error("auth is enabled but no valid auth provider was provided") diff --git a/src/control-plane-services/event-ledger/internal/middleware/dual_auth_test.go b/src/control-plane-services/event-ledger/internal/middleware/auth_test.go similarity index 64% rename from src/control-plane-services/event-ledger/internal/middleware/dual_auth_test.go rename to src/control-plane-services/event-ledger/internal/middleware/auth_test.go index d1a436f14..aa33c8b32 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/dual_auth_test.go +++ b/src/control-plane-services/event-ledger/internal/middleware/auth_test.go @@ -32,7 +32,6 @@ import ( policyclient "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/policy" pdpv1 "github.com/NVIDIA/nvcf/src/libraries/go/lib/pkg/nvkit/clients/pdp_types" "github.com/golang-jwt/jwt/v5" - "github.com/gorilla/mux" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -63,39 +62,61 @@ func (c *recordingPolicyClient) PolicyConfig() *policyclient.PolicyConfig { return &policyclient.PolicyConfig{Namespace: "event-ledger", PolicyFQDN: "apikey.allow"} } -func staticJWTMiddleware(claims jwt.MapClaims) mux.MiddlewareFunc { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if claims == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - ctx := context.WithValue(r.Context(), claimsContextKey, claims) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } +func newSigningKeyAndJWKS(t *testing.T) (*ecdsa.PrivateKey, string, func()) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + key, err := jwk.FromRaw(&privateKey.PublicKey) + require.NoError(t, err) + require.NoError(t, key.Set(jwk.KeyIDKey, "test-key")) + require.NoError(t, key.Set(jwk.AlgorithmKey, "ES256")) + + set := jwk.NewSet() + require.NoError(t, set.AddKey(key)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(set)) + })) + return privateKey, server.URL, server.Close } -func newDualAuthTestHandler(t *testing.T, claims jwt.MapClaims, client *recordingPolicyClient, requiredScopes Scopes) http.Handler { +func signToken(t *testing.T, key *ecdsa.PrivateKey, scopes []string) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "sub": "sis-api", + "scopes": scopes, + "exp": time.Now().Add(time.Hour).Unix(), + }) + token.Header[jwk.KeyIDKey] = "test-key" + signed, err := token.SignedString(key) + require.NoError(t, err) + return signed +} + +func newAuthTestHandler(t *testing.T, jwtOpts *JWTParserOptions, jwkCache *jwk.Cache, client *recordingPolicyClient, selfManaged bool, requiredScopes Scopes) http.Handler { t.Helper() logger := otelzap.New(zaptest.NewLogger(t)) - dualAuth := NewDualAuthMiddleware( - staticJWTMiddleware(claims), - NewPolicyMiddleware(client, "nv-cloud-functions", logger), - ) + authMiddleware := NewAuthMiddleware(client, "nv-cloud-functions", jwtOpts, jwkCache, selfManaged, logger) scoped := MaybeRequireScopes(logger, true, requiredScopes, RequireAnyScopes) - return dualAuth(scoped(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + return authMiddleware(scoped(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))) } -func TestDualAuthJWTNeverReachesPolicyDecisionPoint(t *testing.T) { +func TestSelfManagedJWTNeverReachesPolicyDecisionPoint(t *testing.T) { + key, jwksURL, closeServer := newSigningKeyAndJWKS(t) + defer closeServer() + + jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) + jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) + client := &recordingPolicyClient{allowed: true} - claims := jwt.MapClaims{"sub": "sis-api", "scopes": []interface{}{"fnds:createEvent"}} - handler := newDualAuthTestHandler(t, claims, client, WriteScopes) + handler := newAuthTestHandler(t, &jwtOpts, jwkCache, client, true, WriteScopes) req := httptest.NewRequest(http.MethodPost, "/v3/ledger/cloudevents", nil) - req.Header.Set("Authorization", "Bearer header.payload.signature") + req.Header.Set("Authorization", "Bearer "+signToken(t, key, []string{"fnds:createEvent"})) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) @@ -104,27 +125,45 @@ func TestDualAuthJWTNeverReachesPolicyDecisionPoint(t *testing.T) { assert.False(t, client.called, "OpenBao JWT must not be sent to api-keys-api") } -func TestDualAuthJWTWithoutRequiredScopeIsForbidden(t *testing.T) { - client := &recordingPolicyClient{allowed: true} - claims := jwt.MapClaims{ - "sub": "sis-api", - "scopes": []interface{}{"fnds:createEvent", "fnds:archiveEvents"}, +func TestSelfManagedJWTScopesEnforcedByRoute(t *testing.T) { + key, jwksURL, closeServer := newSigningKeyAndJWKS(t) + defer closeServer() + + jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) + jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) + + tests := []struct { + name string + tokenScope []string + required Scopes + wantStatus int + }{ + {"write scope on write route", []string{"fnds:createEvent"}, WriteScopes, http.StatusOK}, + {"archive scope on archive route", []string{"fnds:archiveEvents"}, ArchiveScopes, http.StatusOK}, + {"write scope on read route", []string{"fnds:createEvent"}, ReadScopes, http.StatusForbidden}, + {"read scope on read route", []string{"fnds:getEvents"}, ReadScopes, http.StatusOK}, } - handler := newDualAuthTestHandler(t, claims, client, ReadScopes) - req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) - req.Header.Set("Authorization", "Bearer header.payload.signature") - recorder := httptest.NewRecorder() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := &recordingPolicyClient{allowed: true} + handler := newAuthTestHandler(t, &jwtOpts, jwkCache, client, true, tc.required) - handler.ServeHTTP(recorder, req) + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer "+signToken(t, key, tc.tokenScope)) + recorder := httptest.NewRecorder() - assert.Equal(t, http.StatusForbidden, recorder.Code) - assert.False(t, client.called) + handler.ServeHTTP(recorder, req) + + assert.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String()) + assert.False(t, client.called) + }) + } } -func TestDualAuthAPIKeySkipsJWTVerificationAndScopeCheck(t *testing.T) { +func TestAPIKeySkipsJWTVerificationAndScopeCheck(t *testing.T) { client := &recordingPolicyClient{allowed: true} - handler := newDualAuthTestHandler(t, nil, client, ReadScopes) + handler := newAuthTestHandler(t, nil, nil, client, true, ReadScopes) req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) req.Header.Set("Authorization", "Bearer nvapi-opaque-key") @@ -136,9 +175,9 @@ func TestDualAuthAPIKeySkipsJWTVerificationAndScopeCheck(t *testing.T) { assert.True(t, client.called, "API key must be authorized by api-keys-api") } -func TestDualAuthAPIKeyDeniedByPolicyDecisionPoint(t *testing.T) { +func TestAPIKeyDeniedByPolicyDecisionPoint(t *testing.T) { client := &recordingPolicyClient{allowed: false} - handler := newDualAuthTestHandler(t, nil, client, ReadScopes) + handler := newAuthTestHandler(t, nil, nil, client, true, ReadScopes) req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) req.Header.Set("Authorization", "Bearer nvapi-opaque-key") @@ -146,38 +185,30 @@ func TestDualAuthAPIKeyDeniedByPolicyDecisionPoint(t *testing.T) { handler.ServeHTTP(recorder, req) + // api-keys-api omits statusCode, which PolicyAuthzResponse defaults to 403. assert.Equal(t, http.StatusForbidden, recorder.Code) assert.True(t, client.called) } -func TestPolicyAuthzResponseAcceptsBothVerdictFieldNames(t *testing.T) { - var apiKeysShaped PolicyAuthzResponse - require.NoError(t, json.Unmarshal([]byte(`{"allowed":true}`), &apiKeysShaped)) - assert.True(t, apiKeysShaped.Allowed) - assert.False(t, apiKeysShaped.Allow) +func TestManagedJWTStillDelegatesToPolicyDecisionPoint(t *testing.T) { + key, jwksURL, closeServer := newSigningKeyAndJWKS(t) + defer closeServer() - var managedShaped PolicyAuthzResponse - require.NoError(t, json.Unmarshal([]byte(`{"allow":true}`), &managedShaped)) - assert.True(t, managedShaped.Allow) -} + jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) + jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) -func TestManagedJWTStillDelegatesToPolicyDecisionPoint(t *testing.T) { client := &recordingPolicyClient{allowed: true} - claims := jwt.MapClaims{"sub": "user-1", "scopes": []interface{}{"fnds:getEvents"}} logger := otelzap.New(zaptest.NewLogger(t)) - - policyMiddleware := NewPolicyMiddleware(client, "nv-cloud-functions", logger) - jwtPath := ChainMiddleware(staticJWTMiddleware(claims), policyMiddleware) + authMiddleware := NewAuthMiddleware(client, "nv-cloud-functions", &jwtOpts, jwkCache, false, logger) var capturedCtx context.Context - handler := NewDualAuthMiddleware(jwtPath, policyMiddleware)( - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedCtx = r.Context() - w.WriteHeader(http.StatusOK) - })) + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedCtx = r.Context() + w.WriteHeader(http.StatusOK) + })) req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) - req.Header.Set("Authorization", "Bearer header.payload.signature") + req.Header.Set("Authorization", "Bearer "+signToken(t, key, []string{"fnds:getEvents"})) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) @@ -185,98 +216,16 @@ func TestManagedJWTStillDelegatesToPolicyDecisionPoint(t *testing.T) { assert.Equal(t, http.StatusOK, recorder.Code) assert.True(t, client.called, "managed deployments must still consult the PDP") require.NotNil(t, capturedCtx) - assert.Equal(t, "user-1", GetClaims(capturedCtx)["sub"]) -} - -func TestPDPAuthorizedContextMarker(t *testing.T) { - assert.False(t, IsPDPAuthorized(context.Background())) - assert.True(t, IsPDPAuthorized(MarkPDPAuthorized(context.Background()))) -} - -func TestBearerToken(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - assert.Equal(t, "", BearerToken(req)) - - req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") - assert.Equal(t, "", BearerToken(req)) - - req.Header.Set("Authorization", "Bearer abc.def.ghi") - assert.Equal(t, "abc.def.ghi", BearerToken(req)) + assert.Equal(t, "sis-api", GetClaims(capturedCtx)["sub"]) } -func newSigningKeyAndJWKS(t *testing.T) (*ecdsa.PrivateKey, string, func()) { - t.Helper() - privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - key, err := jwk.FromRaw(&privateKey.PublicKey) - require.NoError(t, err) - require.NoError(t, key.Set(jwk.KeyIDKey, "test-key")) - require.NoError(t, key.Set(jwk.AlgorithmKey, "ES256")) - - set := jwk.NewSet() - require.NoError(t, set.AddKey(key)) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(set)) - })) - return privateKey, server.URL, server.Close -} - -func signToken(t *testing.T, key *ecdsa.PrivateKey, scopes []string) string { - t.Helper() - token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ - "sub": "sis-api", - "scopes": scopes, - "exp": time.Now().Add(time.Hour).Unix(), - }) - token.Header[jwk.KeyIDKey] = "test-key" - signed, err := token.SignedString(key) - require.NoError(t, err) - return signed -} - -func TestRealJWTParserEnforcesScopes(t *testing.T) { - key, jwksURL, closeServer := newSigningKeyAndJWKS(t) - defer closeServer() - - logger := otelzap.New(zaptest.NewLogger(t)) - jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) - jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) - jwtMiddleware := NewParseJWTMiddleware(jwtOpts, jwkCache) - - tests := []struct { - name string - tokenScope []string - required Scopes - wantStatus int - }{ - {"write scope on write route", []string{"fnds:createEvent"}, WriteScopes, http.StatusOK}, - {"archive scope on archive route", []string{"fnds:archiveEvents"}, ArchiveScopes, http.StatusOK}, - {"write scope on read route", []string{"fnds:createEvent"}, ReadScopes, http.StatusForbidden}, - {"read scope on read route", []string{"fnds:getEvents"}, ReadScopes, http.StatusOK}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := &recordingPolicyClient{allowed: true} - handler := NewDualAuthMiddleware( - jwtMiddleware, - NewPolicyMiddleware(client, "nv-cloud-functions", logger), - )(MaybeRequireScopes(logger, true, tc.required, RequireAnyScopes)( - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }))) - - req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) - req.Header.Set("Authorization", "Bearer "+signToken(t, key, tc.tokenScope)) - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, req) +func TestPolicyAuthzResponseAcceptsBothVerdictFieldNames(t *testing.T) { + var apiKeysShaped PolicyAuthzResponse + require.NoError(t, json.Unmarshal([]byte(`{"allowed":true}`), &apiKeysShaped)) + assert.True(t, apiKeysShaped.Allowed) + assert.False(t, apiKeysShaped.Allow) - assert.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String()) - assert.False(t, client.called) - }) - } + var managedShaped PolicyAuthzResponse + require.NoError(t, json.Unmarshal([]byte(`{"allow":true}`), &managedShaped)) + assert.True(t, managedShaped.Allow) } diff --git a/src/control-plane-services/event-ledger/internal/middleware/dual_auth.go b/src/control-plane-services/event-ledger/internal/middleware/dual_auth.go deleted file mode 100644 index d2e8999f1..000000000 --- a/src/control-plane-services/event-ledger/internal/middleware/dual_auth.go +++ /dev/null @@ -1,71 +0,0 @@ -/* -SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package middleware - -import ( - "context" - "net/http" - "strings" - - "github.com/gorilla/mux" -) - -const pdpAuthorizedContextKey contextKey = "pdp_authorized" - -func BearerToken(r *http.Request) string { - authHeader := r.Header.Get("Authorization") - if !strings.HasPrefix(authHeader, "Bearer ") { - return "" - } - return strings.TrimPrefix(authHeader, "Bearer ") -} - -func MarkPDPAuthorized(ctx context.Context) context.Context { - return context.WithValue(ctx, pdpAuthorizedContextKey, true) -} - -func IsPDPAuthorized(ctx context.Context) bool { - authorized, ok := ctx.Value(pdpAuthorizedContextKey).(bool) - return ok && authorized -} - -func ChainMiddleware(first, second mux.MiddlewareFunc) mux.MiddlewareFunc { - return func(next http.Handler) http.Handler { - return first(second(next)) - } -} - -// Routes by token shape: JWT-shaped tokens take jwtPath, the rest apiKeyPath. -func NewDualAuthMiddleware(jwtPath, apiKeyPath mux.MiddlewareFunc) mux.MiddlewareFunc { - return func(next http.Handler) http.Handler { - if jwtPath == nil { - return apiKeyPath(next) - } - - jwtChain := jwtPath(next) - policyChain := apiKeyPath(next) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if isJWTShapedToken(BearerToken(r)) { - jwtChain.ServeHTTP(w, r) - return - } - policyChain.ServeHTTP(w, r) - }) - } -} diff --git a/src/control-plane-services/event-ledger/internal/middleware/jwt.go b/src/control-plane-services/event-ledger/internal/middleware/jwt.go index 597805447..30be7be9e 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/jwt.go +++ b/src/control-plane-services/event-ledger/internal/middleware/jwt.go @@ -300,7 +300,7 @@ func requireScopes(requiredScopes Scopes, scopeRequirement ScopeRequirement) fun claims, ok := r.Context().Value(claimsContextKey).(jwt.MapClaims) if !ok { // API keys carry no scopes. - if IsPDPAuthorized(parentCtx) { + if isPDPAuthorized(parentCtx) { next.ServeHTTP(w, r) return } diff --git a/src/control-plane-services/event-ledger/internal/middleware/policy.go b/src/control-plane-services/event-ledger/internal/middleware/policy.go index 37768b317..65d97a4f4 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/policy.go +++ b/src/control-plane-services/event-ledger/internal/middleware/policy.go @@ -28,6 +28,7 @@ import ( "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/policy" "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/uptrace/opentelemetry-go-extra/otelzap" "go.uber.org/zap" @@ -182,7 +183,7 @@ func mergePolicyClaims(jwtClaims map[string]interface{}, authResponse PolicyAuth return claims } -func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, logger *otelzap.Logger) mux.MiddlewareFunc { +func newPolicyMiddleware(policyClient policy.Authorizer, serviceName string, logger *otelzap.Logger) mux.MiddlewareFunc { if policyClient == nil { if logger != nil { logger.Error("policy client is nil - denying requests") @@ -322,7 +323,7 @@ func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, log // 9. Authorization succeeded - enrich context with user info logger.InfoContext(traceCtx, "policy: authorization successful") - var requestCtx = MarkPDPAuthorized(r.Context()) + var requestCtx = markPDPAuthorized(r.Context()) // Create enriched context if authResponse.ActorID != "" { requestCtx = context.WithValue(requestCtx, policyActorIDContextKey, authResponse.ActorID) @@ -351,3 +352,70 @@ func NewPolicyMiddleware(policyClient policy.Authorizer, serviceName string, log }) } } + +const pdpAuthorizedContextKey contextKey = "pdp_authorized" + +func markPDPAuthorized(ctx context.Context) context.Context { + return context.WithValue(ctx, pdpAuthorizedContextKey, true) +} + +func isPDPAuthorized(ctx context.Context) bool { + authorized, ok := ctx.Value(pdpAuthorizedContextKey).(bool) + return ok && authorized +} + +func bearerToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + return "" + } + return strings.TrimPrefix(authHeader, "Bearer ") +} + +func chainMiddleware(first, second mux.MiddlewareFunc) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return first(second(next)) + } +} + +// NewAuthMiddleware dispatches each request to one of two authorization paths +// based on whether the bearer token is JWT-shaped. +// +// A JWT is always verified locally against jwtOpts first. In self-managed +// deployments that is the entire check: the caller's per-route scope +// requirement then decides access, and the token never reaches policyClient. +// In managed deployments, the verified JWT is additionally sent to +// policyClient for an allow/deny decision. +// +// Anything else is treated as an opaque API key and sent to policyClient +// directly. policyClient's evaluation contract only accepts an API key, which +// is why a JWT cannot be routed through it in self-managed deployments. +func NewAuthMiddleware(policyClient policy.Authorizer, serviceName string, jwtOpts *JWTParserOptions, jwkCache *jwk.Cache, selfManaged bool, logger *otelzap.Logger) mux.MiddlewareFunc { + apiKeyAuth := newPolicyMiddleware(policyClient, serviceName, logger) + + var jwtVerify mux.MiddlewareFunc + if jwtOpts != nil { + jwtVerify = NewParseJWTMiddleware(*jwtOpts, jwkCache) + } + if jwtVerify == nil { + return apiKeyAuth + } + + jwtAuth := jwtVerify + if !selfManaged { + jwtAuth = chainMiddleware(jwtVerify, apiKeyAuth) + } + + return func(next http.Handler) http.Handler { + jwtChain := jwtAuth(next) + apiKeyChain := apiKeyAuth(next) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isJWTShapedToken(bearerToken(r)) { + jwtChain.ServeHTTP(w, r) + return + } + apiKeyChain.ServeHTTP(w, r) + }) + } +} diff --git a/src/control-plane-services/event-ledger/internal/middleware/policy_test.go b/src/control-plane-services/event-ledger/internal/middleware/policy_test.go index d4d228478..f902d3c06 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/policy_test.go +++ b/src/control-plane-services/event-ledger/internal/middleware/policy_test.go @@ -716,10 +716,9 @@ func TestPolicyAuthInputFields(t *testing.T) { assert.Equal(t, "token-1", authCtx["credential"]) } -func TestNewPolicyMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) { +func TestNewAuthMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) { client := &rejectingJWTPolicyClient{} logger := otelzap.New(zaptest.NewLogger(t)) - policyMiddleware := NewPolicyMiddleware(client, "test-service", logger) jwtOpts := NewJWTParserOptions( "https://issuer.test/.well-known/jwks.json", @@ -727,12 +726,11 @@ func TestNewPolicyMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) time.Minute, &config.HTTPClientConfig{}, ) - jwtMiddleware := NewParseJWTMiddleware(jwtOpts, jwk.NewCache(context.Background())) - dualAuth := NewDualAuthMiddleware(jwtMiddleware, policyMiddleware) + authMiddleware := NewAuthMiddleware(client, "test-service", &jwtOpts, jwk.NewCache(context.Background()), true, logger) handlerCalled := false - handler := dualAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handlerCalled = true w.WriteHeader(http.StatusOK) })) @@ -748,11 +746,11 @@ func TestNewPolicyMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) assert.False(t, handlerCalled) } -func TestNewPolicyMiddlewareRejectsRequestsWithNilClientAndLogger(t *testing.T) { - policyMiddleware := NewPolicyMiddleware(nil, "test-service", nil) +func TestNewAuthMiddlewareRejectsRequestsWithNilClientAndLogger(t *testing.T) { + authMiddleware := NewAuthMiddleware(nil, "test-service", nil, nil, true, nil) handlerCalled := false - handler := policyMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handlerCalled = true w.WriteHeader(http.StatusOK) })) From 72bd5c09168af5f7a74f8d99b22ce8bafbc269f4 Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Fri, 28 Aug 2026 16:07:15 -0700 Subject: [PATCH 3/6] test(event-ledger): rewrite policy middleware tests against the real Authorizer The previous suite drove a hand-written testPolicyMiddleware that duplicated the auth logic instead of calling newPolicyMiddleware/NewAuthMiddleware, so it verified itself rather than production code. It also set JWT scopes as []string in test claims, while the real code type-asserts claims["scopes"] as []interface{} (what real JSON-decoded claims produce), so the scope-forwarding path was never actually exercised. Replace the fake harness with stubPolicyClient, which implements the real policy.Authorizer interface, and drive every test through the production middleware. Assert on the actual RuleRequest.Input built for the evaluator (apiKey, subject, scopes, service) instead of a parallel test-only shape. Merge auth_test.go's dispatch coverage in alongside it. Also fixes BUILD.bazel, left listing dual_auth.go and dual_auth_test.go as srcs after both were deleted in the prior commit. Signed-off-by: Shelley Shen --- .../internal/middleware/BUILD.bazel | 3 - .../internal/middleware/auth_test.go | 231 ---- .../internal/middleware/policy_test.go | 1146 +++++------------ 3 files changed, 344 insertions(+), 1036 deletions(-) delete mode 100644 src/control-plane-services/event-ledger/internal/middleware/auth_test.go diff --git a/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel b/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel index eb797652e..5e9fef892 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel +++ b/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel @@ -6,7 +6,6 @@ go_library( "body_limit.go", "constants.go", "cors.go", - "dual_auth.go", "http_client.go", "jwt.go", "metrics.go", @@ -48,7 +47,6 @@ alias( go_test( name = "middleware_test", srcs = [ - "dual_auth_test.go", "jwt_test.go", "metrics_test.go", "policy_test.go", @@ -60,7 +58,6 @@ go_test( "//src/control-plane-services/event-ledger/internal/policy", "//src/control-plane-services/event-ledger/pkg/testutils", "@com_github_golang_jwt_jwt_v5//:jwt", - "@com_github_gorilla_mux//:mux", "@com_github_lestrrat_go_jwx_v2//jwk", "@com_github_nvidia_nvcf_src_libraries_go_lib//pkg/nvkit/clients/pdp_types", "@com_github_prometheus_client_golang//prometheus/promhttp", diff --git a/src/control-plane-services/event-ledger/internal/middleware/auth_test.go b/src/control-plane-services/event-ledger/internal/middleware/auth_test.go deleted file mode 100644 index aa33c8b32..000000000 --- a/src/control-plane-services/event-ledger/internal/middleware/auth_test.go +++ /dev/null @@ -1,231 +0,0 @@ -/* -SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package middleware - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/config" - policyclient "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/policy" - pdpv1 "github.com/NVIDIA/nvcf/src/libraries/go/lib/pkg/nvkit/clients/pdp_types" - "github.com/golang-jwt/jwt/v5" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uptrace/opentelemetry-go-extra/otelzap" - "go.uber.org/zap/zaptest" - "google.golang.org/protobuf/types/known/structpb" -) - -type recordingPolicyClient struct { - called bool - allowed bool -} - -func (c *recordingPolicyClient) Evaluate(_ context.Context, _ *pdpv1.RuleRequest) (*pdpv1.RuleResponse, error) { - c.called = true - result, err := structpb.NewValue(map[string]interface{}{ - "allowed": c.allowed, - "ncaId": "nca-1", - "ownerId": "owner-1", - }) - if err != nil { - return nil, err - } - return &pdpv1.RuleResponse{Result: result}, nil -} - -func (c *recordingPolicyClient) PolicyConfig() *policyclient.PolicyConfig { - return &policyclient.PolicyConfig{Namespace: "event-ledger", PolicyFQDN: "apikey.allow"} -} - -func newSigningKeyAndJWKS(t *testing.T) (*ecdsa.PrivateKey, string, func()) { - t.Helper() - privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - key, err := jwk.FromRaw(&privateKey.PublicKey) - require.NoError(t, err) - require.NoError(t, key.Set(jwk.KeyIDKey, "test-key")) - require.NoError(t, key.Set(jwk.AlgorithmKey, "ES256")) - - set := jwk.NewSet() - require.NoError(t, set.AddKey(key)) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(set)) - })) - return privateKey, server.URL, server.Close -} - -func signToken(t *testing.T, key *ecdsa.PrivateKey, scopes []string) string { - t.Helper() - token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ - "sub": "sis-api", - "scopes": scopes, - "exp": time.Now().Add(time.Hour).Unix(), - }) - token.Header[jwk.KeyIDKey] = "test-key" - signed, err := token.SignedString(key) - require.NoError(t, err) - return signed -} - -func newAuthTestHandler(t *testing.T, jwtOpts *JWTParserOptions, jwkCache *jwk.Cache, client *recordingPolicyClient, selfManaged bool, requiredScopes Scopes) http.Handler { - t.Helper() - logger := otelzap.New(zaptest.NewLogger(t)) - authMiddleware := NewAuthMiddleware(client, "nv-cloud-functions", jwtOpts, jwkCache, selfManaged, logger) - scoped := MaybeRequireScopes(logger, true, requiredScopes, RequireAnyScopes) - return authMiddleware(scoped(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }))) -} - -func TestSelfManagedJWTNeverReachesPolicyDecisionPoint(t *testing.T) { - key, jwksURL, closeServer := newSigningKeyAndJWKS(t) - defer closeServer() - - jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) - jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) - - client := &recordingPolicyClient{allowed: true} - handler := newAuthTestHandler(t, &jwtOpts, jwkCache, client, true, WriteScopes) - - req := httptest.NewRequest(http.MethodPost, "/v3/ledger/cloudevents", nil) - req.Header.Set("Authorization", "Bearer "+signToken(t, key, []string{"fnds:createEvent"})) - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) - assert.False(t, client.called, "OpenBao JWT must not be sent to api-keys-api") -} - -func TestSelfManagedJWTScopesEnforcedByRoute(t *testing.T) { - key, jwksURL, closeServer := newSigningKeyAndJWKS(t) - defer closeServer() - - jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) - jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) - - tests := []struct { - name string - tokenScope []string - required Scopes - wantStatus int - }{ - {"write scope on write route", []string{"fnds:createEvent"}, WriteScopes, http.StatusOK}, - {"archive scope on archive route", []string{"fnds:archiveEvents"}, ArchiveScopes, http.StatusOK}, - {"write scope on read route", []string{"fnds:createEvent"}, ReadScopes, http.StatusForbidden}, - {"read scope on read route", []string{"fnds:getEvents"}, ReadScopes, http.StatusOK}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := &recordingPolicyClient{allowed: true} - handler := newAuthTestHandler(t, &jwtOpts, jwkCache, client, true, tc.required) - - req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) - req.Header.Set("Authorization", "Bearer "+signToken(t, key, tc.tokenScope)) - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, req) - - assert.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String()) - assert.False(t, client.called) - }) - } -} - -func TestAPIKeySkipsJWTVerificationAndScopeCheck(t *testing.T) { - client := &recordingPolicyClient{allowed: true} - handler := newAuthTestHandler(t, nil, nil, client, true, ReadScopes) - - req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) - req.Header.Set("Authorization", "Bearer nvapi-opaque-key") - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) - assert.True(t, client.called, "API key must be authorized by api-keys-api") -} - -func TestAPIKeyDeniedByPolicyDecisionPoint(t *testing.T) { - client := &recordingPolicyClient{allowed: false} - handler := newAuthTestHandler(t, nil, nil, client, true, ReadScopes) - - req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) - req.Header.Set("Authorization", "Bearer nvapi-opaque-key") - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, req) - - // api-keys-api omits statusCode, which PolicyAuthzResponse defaults to 403. - assert.Equal(t, http.StatusForbidden, recorder.Code) - assert.True(t, client.called) -} - -func TestManagedJWTStillDelegatesToPolicyDecisionPoint(t *testing.T) { - key, jwksURL, closeServer := newSigningKeyAndJWKS(t) - defer closeServer() - - jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) - jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) - - client := &recordingPolicyClient{allowed: true} - logger := otelzap.New(zaptest.NewLogger(t)) - authMiddleware := NewAuthMiddleware(client, "nv-cloud-functions", &jwtOpts, jwkCache, false, logger) - - var capturedCtx context.Context - handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedCtx = r.Context() - w.WriteHeader(http.StatusOK) - })) - - req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) - req.Header.Set("Authorization", "Bearer "+signToken(t, key, []string{"fnds:getEvents"})) - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) - assert.True(t, client.called, "managed deployments must still consult the PDP") - require.NotNil(t, capturedCtx) - assert.Equal(t, "sis-api", GetClaims(capturedCtx)["sub"]) -} - -func TestPolicyAuthzResponseAcceptsBothVerdictFieldNames(t *testing.T) { - var apiKeysShaped PolicyAuthzResponse - require.NoError(t, json.Unmarshal([]byte(`{"allowed":true}`), &apiKeysShaped)) - assert.True(t, apiKeysShaped.Allowed) - assert.False(t, apiKeysShaped.Allow) - - var managedShaped PolicyAuthzResponse - require.NoError(t, json.Unmarshal([]byte(`{"allow":true}`), &managedShaped)) - assert.True(t, managedShaped.Allow) -} diff --git a/src/control-plane-services/event-ledger/internal/middleware/policy_test.go b/src/control-plane-services/event-ledger/internal/middleware/policy_test.go index f902d3c06..9d83c367e 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/policy_test.go +++ b/src/control-plane-services/event-ledger/internal/middleware/policy_test.go @@ -19,8 +19,11 @@ package middleware import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "encoding/json" - "fmt" + "errors" "net/http" "net/http/httptest" "testing" @@ -30,337 +33,116 @@ import ( policyclient "github.com/NVIDIA/nvcf/src/control-plane-services/event-ledger/internal/policy" pdpv1 "github.com/NVIDIA/nvcf/src/libraries/go/lib/pkg/nvkit/clients/pdp_types" "github.com/golang-jwt/jwt/v5" - "github.com/gorilla/mux" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/uptrace/opentelemetry-go-extra/otelzap" "go.uber.org/zap/zaptest" + "google.golang.org/protobuf/types/known/structpb" ) -// RuleRequest simulates the pdp_types.RuleRequest type for testing -type RuleRequest struct { - Namespace string `json:"namespace,omitempty"` - RuleName string `json:"rule_name,omitempty"` - Input map[string]interface{} `json:"input,omitempty"` +type stubPolicyClient struct { + called bool + lastReq *pdpv1.RuleRequest + result map[string]interface{} + empty bool + err error } -// RuleResponse simulates the pdp_types.RuleResponse type for testing -type RuleResponse struct { - Result json.RawMessage `json:"result,omitempty"` -} - -// PolicyConfig simulates the clients.PolicyConfig type for testing -type PolicyConfig struct { - Namespace string - PolicyFQDN string - SubjectField string - APIKeyField string -} - -// PolicyAuthZClientInterface matches the interface used by the middleware -type PolicyAuthZClientInterface interface { - Evaluate(ctx context.Context, req *RuleRequest) (*RuleResponse, error) - PolicyConfig() *PolicyConfig -} - -// testPolicyMiddleware is a test-specific version of NewPolicyMiddleware that works with our test interfaces -func testPolicyMiddleware(testClient PolicyAuthZClientInterface, serviceName string, jwtPubKeySetURL string, jwtTokenExpiration time.Duration) mux.MiddlewareFunc { - if testClient == nil { - return func(next http.Handler) http.Handler { - return next - } - } - - // For tests, we don't need to actually set up JWT middleware - // Just keep the URLs for reference in the tests - _ = jwtPubKeySetURL - _ = jwtTokenExpiration - - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // 1. Extract the token (simple bearer token extraction) - token := "" - authHeader := r.Header.Get("Authorization") - if authHeader != "" && len(authHeader) > 7 && authHeader[:7] == "Bearer " { - token = authHeader[7:] - } - - // 2. Set up auth context from client request - policyConfig := testClient.PolicyConfig() - apiKeyField := policyConfig.APIKeyField - if apiKeyField == "" { - apiKeyField = defaultAuthAPIKeyField - } - authCtx := map[string]interface{}{ - "path": r.URL.Path, - "method": r.Method, - "service": serviceName, - } - - // Add token if available - if token != "" { - setAuthContextField(authCtx, apiKeyField, token) - } - - // 3. Prepare request for Evaluate - testReq := &RuleRequest{ - Namespace: policyConfig.Namespace, - RuleName: policyConfig.PolicyFQDN, - Input: authCtx, - } - - // 4. Call Evaluate on the test client - testResp, err := testClient.Evaluate(r.Context(), testReq) - if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - // 5. Parse the response - if testResp == nil || testResp.Result == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var authResponse PolicyAuthzResponse - if err := json.Unmarshal(testResp.Result, &authResponse); err != nil { - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // 6. Check if allowed - if !authResponse.Allow { - message := "Unauthorized" - statusCode := authResponse.StatusCode - if statusCode == 0 { - statusCode = http.StatusUnauthorized - } - - if len(authResponse.Reasons) > 0 { - message = authResponse.Reasons[0] - } - - http.Error(w, message, statusCode) - return - } - - // 7. Authorization succeeded - enrich context with user info - ctx := r.Context() - - // Add claims to context - if authResponse.ActorID != "" { - ctx = context.WithValue(ctx, policyActorIDContextKey, authResponse.ActorID) - } - if authResponse.OrgName != "" { - ctx = context.WithValue(ctx, policyOrgNameContextKey, authResponse.OrgName) - } - if authResponse.ActorType != "" { - ctx = context.WithValue(ctx, policyActorTypeContextKey, authResponse.ActorType) - } - if len(authResponse.Roles) > 0 { - ctx = context.WithValue(ctx, policyRolesContextKey, authResponse.Roles) - } - - // Get JWT claims if they exist - var jwtClaims map[string]interface{} - if jwtClaimsValue := ctx.Value(claimsContextKey); jwtClaimsValue != nil { - if mapClaims, ok := jwtClaimsValue.(jwt.MapClaims); ok { - jwtClaims = map[string]interface{}(mapClaims) - } - } - - claims := mergePolicyClaims(jwtClaims, authResponse) - - ctx = context.WithValue(ctx, policyClaimsContextKey, claims) - - // 8. Update request with enriched context and call next handler - r = r.WithContext(ctx) - next.ServeHTTP(w, r) - }) +func (c *stubPolicyClient) Evaluate(_ context.Context, req *pdpv1.RuleRequest) (*pdpv1.RuleResponse, error) { + c.called = true + c.lastReq = req + if c.err != nil { + return nil, c.err } -} - -// Test Fixture: Success Policy client that always authorizes -type PassPolicyClient struct{} - -func (c *PassPolicyClient) Evaluate(ctx context.Context, req *RuleRequest) (*RuleResponse, error) { - resp := PolicyAuthzResponse{ - Allow: true, - StatusCode: 200, - ActorID: "user123", - OrgName: "org123", - ActorType: "user", - Roles: []string{"admin", "user"}, - Reasons: []string{"authorized"}, + if c.empty { + return &pdpv1.RuleResponse{}, nil } - data, _ := json.Marshal(resp) - return &RuleResponse{Result: data}, nil -} - -func (c *PassPolicyClient) PolicyConfig() *PolicyConfig { - return &PolicyConfig{ - Namespace: "testns", - PolicyFQDN: "testpolicy", + result, err := structpb.NewValue(c.result) + if err != nil { + return nil, err } + return &pdpv1.RuleResponse{Result: result}, nil } -// Test Fixture: Anonymous Policy client for no token scenarios -type AnonymousPolicyClient struct{} - -func (c *AnonymousPolicyClient) Evaluate(ctx context.Context, req *RuleRequest) (*RuleResponse, error) { - resp := PolicyAuthzResponse{ - Allow: true, - StatusCode: 200, - ActorID: "anonymous", - OrgName: "anonymous", - ActorType: "anonymous", - Roles: []string{"guest"}, - Reasons: []string{"authorized"}, - } - data, _ := json.Marshal(resp) - return &RuleResponse{Result: data}, nil +func (c *stubPolicyClient) PolicyConfig() *policyclient.PolicyConfig { + return &policyclient.PolicyConfig{Namespace: "testns", PolicyFQDN: "testpolicy"} } -func (c *AnonymousPolicyClient) PolicyConfig() *PolicyConfig { - return &PolicyConfig{ - Namespace: "testns", - PolicyFQDN: "testpolicy", +func allowResult(overrides map[string]interface{}) map[string]interface{} { + result := map[string]interface{}{ + "allow": true, + "statusCode": 200, + "actorId": "user123", + "orgName": "org123", + "actorType": "user", + "roles": []interface{}{"admin", "user"}, + "reasons": []interface{}{"authorized"}, } -} - -// Test Fixture: Forbidden Policy client that denies access -type ForbiddenPolicyClient struct{} - -func (c *ForbiddenPolicyClient) Evaluate(ctx context.Context, req *RuleRequest) (*RuleResponse, error) { - resp := PolicyAuthzResponse{ - Allow: false, - StatusCode: 403, - Reasons: []string{"unauthorized access"}, + for k, v := range overrides { + result[k] = v } - data, _ := json.Marshal(resp) - return &RuleResponse{Result: data}, nil -} - -func (c *ForbiddenPolicyClient) PolicyConfig() *PolicyConfig { - return &PolicyConfig{ - Namespace: "testns", - PolicyFQDN: "testpolicy", - } -} - -// Test Fixture: Error Policy client that returns an error -type ErrorPolicyClient struct{} - -func (c *ErrorPolicyClient) Evaluate(ctx context.Context, req *RuleRequest) (*RuleResponse, error) { - return nil, fmt.Errorf("service unavailable") -} - -func (c *ErrorPolicyClient) PolicyConfig() *PolicyConfig { - return &PolicyConfig{ - Namespace: "testns", - PolicyFQDN: "testpolicy", - } -} - -// Test Fixture: Empty Policy client that returns an empty response -type EmptyPolicyClient struct{} - -func (c *EmptyPolicyClient) Evaluate(ctx context.Context, req *RuleRequest) (*RuleResponse, error) { - return &RuleResponse{}, nil + return result } -func (c *EmptyPolicyClient) PolicyConfig() *PolicyConfig { - return &PolicyConfig{ - Namespace: "testns", - PolicyFQDN: "testpolicy", - } -} - -// Test Fixture: JWT Policy client for JWT integration tests -type JWTPolicyClient struct{} - -func (c *JWTPolicyClient) Evaluate(ctx context.Context, req *RuleRequest) (*RuleResponse, error) { - resp := PolicyAuthzResponse{ - Allow: true, - StatusCode: 200, - ActorID: "user456", - OrgName: "org456", - ActorType: "user", - Roles: []string{"admin"}, - Reasons: []string{"authorized"}, - } - data, _ := json.Marshal(resp) - return &RuleResponse{Result: data}, nil -} - -func (c *JWTPolicyClient) PolicyConfig() *PolicyConfig { - return &PolicyConfig{ - Namespace: "testns", - PolicyFQDN: "testpolicy", - } -} - -type rejectingJWTPolicyClient struct { - called bool -} - -func (c *rejectingJWTPolicyClient) Evaluate(ctx context.Context, req *pdpv1.RuleRequest) (*pdpv1.RuleResponse, error) { - c.called = true - return nil, nil -} - -func (c *rejectingJWTPolicyClient) PolicyConfig() *policyclient.PolicyConfig { - return &policyclient.PolicyConfig{ - Namespace: "testns", - PolicyFQDN: "testpolicy", - } -} - -// Test fixture interface -type TestFixture interface { - GetPolicyClient() PolicyAuthZClientInterface -} - -// Success fixture -type PassFixture struct{} - -func (f *PassFixture) GetPolicyClient() PolicyAuthZClientInterface { - return &PassPolicyClient{} +func testLogger(t *testing.T) *otelzap.Logger { + t.Helper() + return otelzap.New(zaptest.NewLogger(t)) } -// Anonymous fixture -type AnonymousFixture struct{} - -func (f *AnonymousFixture) GetPolicyClient() PolicyAuthZClientInterface { - return &AnonymousPolicyClient{} -} - -// Forbidden fixture -type ForbiddenFixture struct{} - -func (f *ForbiddenFixture) GetPolicyClient() PolicyAuthZClientInterface { - return &ForbiddenPolicyClient{} +func servePolicy(t *testing.T, client policyclient.Authorizer, req *http.Request) (*httptest.ResponseRecorder, context.Context) { + t.Helper() + var capturedCtx context.Context + handler := newPolicyMiddleware(client, "test-service", testLogger(t))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedCtx = r.Context() + w.WriteHeader(http.StatusOK) + })) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder, capturedCtx } -// Error fixture -type ErrorFixture struct{} +func newSigningKeyAndJWKS(t *testing.T) (*ecdsa.PrivateKey, string, func()) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) -func (f *ErrorFixture) GetPolicyClient() PolicyAuthZClientInterface { - return &ErrorPolicyClient{} -} + key, err := jwk.FromRaw(&privateKey.PublicKey) + require.NoError(t, err) + require.NoError(t, key.Set(jwk.KeyIDKey, "test-key")) + require.NoError(t, key.Set(jwk.AlgorithmKey, "ES256")) -// Empty fixture -type EmptyFixture struct{} + set := jwk.NewSet() + require.NoError(t, set.AddKey(key)) -func (f *EmptyFixture) GetPolicyClient() PolicyAuthZClientInterface { - return &EmptyPolicyClient{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(set)) + })) + return privateKey, server.URL, server.Close } -// JWT fixture -type JWTFixture struct{} - -func (f *JWTFixture) GetPolicyClient() PolicyAuthZClientInterface { - return &JWTPolicyClient{} +func signToken(t *testing.T, key *ecdsa.PrivateKey, scopes []string) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "sub": "sis-api", + "scopes": scopes, + "exp": time.Now().Add(time.Hour).Unix(), + }) + token.Header[jwk.KeyIDKey] = "test-key" + signed, err := token.SignedString(key) + require.NoError(t, err) + return signed +} + +func newAuthTestHandler(t *testing.T, jwtOpts *JWTParserOptions, jwkCache *jwk.Cache, client *stubPolicyClient, selfManaged bool, requiredScopes Scopes) http.Handler { + t.Helper() + logger := testLogger(t) + authMiddleware := NewAuthMiddleware(client, "nv-cloud-functions", jwtOpts, jwkCache, selfManaged, logger) + scoped := MaybeRequireScopes(logger, true, requiredScopes, RequireAnyScopes) + return authMiddleware(scoped(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }))) } func TestGetContextValues(t *testing.T) { @@ -426,7 +208,6 @@ func TestPolicyAuthzResponseUnmarshalJSON(t *testing.T) { name string jsonData string expectedResult PolicyAuthzResponse - expectedError bool }{ { name: "Valid JSON with int status code", @@ -448,7 +229,6 @@ func TestPolicyAuthzResponseUnmarshalJSON(t *testing.T) { ActorType: "user", Roles: []string{"admin", "user"}, }, - expectedError: false, }, { name: "Valid JSON with string status code", @@ -468,7 +248,6 @@ func TestPolicyAuthzResponseUnmarshalJSON(t *testing.T) { OrgName: "", ActorType: "", }, - expectedError: false, }, { name: "Invalid string status code defaults to 403", @@ -482,7 +261,6 @@ func TestPolicyAuthzResponseUnmarshalJSON(t *testing.T) { StatusCode: 403, Reasons: []string{"error"}, }, - expectedError: false, }, { name: "Missing status code", @@ -495,35 +273,64 @@ func TestPolicyAuthzResponseUnmarshalJSON(t *testing.T) { StatusCode: 403, Reasons: []string{"unauthorized"}, }, - expectedError: false, + }, + { + name: "api-keys-api verdict field", + jsonData: `{"allowed":true}`, + expectedResult: PolicyAuthzResponse{ + Allowed: true, + StatusCode: 403, + }, + }, + { + name: "managed PDP verdict field", + jsonData: `{"allow":true}`, + expectedResult: PolicyAuthzResponse{ + Allow: true, + StatusCode: 403, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var response PolicyAuthzResponse - err := json.Unmarshal([]byte(tt.jsonData), &response) - - if tt.expectedError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedResult.Allow, response.Allow) - assert.Equal(t, tt.expectedResult.StatusCode, response.StatusCode) - assert.Equal(t, tt.expectedResult.Reasons, response.Reasons) - assert.Equal(t, tt.expectedResult.ActorID, response.ActorID) - assert.Equal(t, tt.expectedResult.OrgName, response.OrgName) - assert.Equal(t, tt.expectedResult.ActorType, response.ActorType) - assert.Equal(t, tt.expectedResult.Roles, response.Roles) - } + require.NoError(t, json.Unmarshal([]byte(tt.jsonData), &response)) + assert.Equal(t, tt.expectedResult.Allow, response.Allow) + assert.Equal(t, tt.expectedResult.Allowed, response.Allowed) + assert.Equal(t, tt.expectedResult.StatusCode, response.StatusCode) + assert.Equal(t, tt.expectedResult.Reasons, response.Reasons) + assert.Equal(t, tt.expectedResult.ActorID, response.ActorID) + assert.Equal(t, tt.expectedResult.OrgName, response.OrgName) + assert.Equal(t, tt.expectedResult.ActorType, response.ActorType) + assert.Equal(t, tt.expectedResult.Roles, response.Roles) }) } } +func TestPolicyAuthInputFields(t *testing.T) { + subjectField, apiKeyField := policyInputFields(nil) + assert.Equal(t, defaultAuthSubjectField, subjectField) + assert.Equal(t, defaultAuthAPIKeyField, apiKeyField) + + subjectField, apiKeyField = policyInputFields(&policyclient.PolicyConfig{ + SubjectField: "actor", + APIKeyField: "credential", + }) + assert.Equal(t, "actor", subjectField) + assert.Equal(t, "credential", apiKeyField) + + authCtx := map[string]interface{}{} + setAuthContextField(authCtx, subjectField, "user-1") + setAuthContextField(authCtx, apiKeyField, "token-1") + assert.Equal(t, "user-1", authCtx["actor"]) + assert.Equal(t, "token-1", authCtx["credential"]) +} + func TestNewPolicyMiddleware(t *testing.T) { tests := []struct { name string - fixture TestFixture + client *stubPolicyClient token string expectedStatusCode int expectedActorID string @@ -533,7 +340,7 @@ func TestNewPolicyMiddleware(t *testing.T) { }{ { name: "Successful Authorization", - fixture: &PassFixture{}, + client: &stubPolicyClient{result: allowResult(nil)}, token: "valid-token", expectedStatusCode: http.StatusOK, expectedActorID: "user123", @@ -542,26 +349,35 @@ func TestNewPolicyMiddleware(t *testing.T) { expectedRoles: []string{"admin", "user"}, }, { - name: "Failed Authorization", - fixture: &ForbiddenFixture{}, + name: "Failed Authorization", + client: &stubPolicyClient{result: map[string]interface{}{ + "allow": false, + "statusCode": 403, + "reasons": []interface{}{"unauthorized access"}, + }}, token: "invalid-token", expectedStatusCode: http.StatusForbidden, }, { name: "Policy Service Error", - fixture: &ErrorFixture{}, + client: &stubPolicyClient{err: errors.New("service unavailable")}, token: "token", expectedStatusCode: http.StatusUnauthorized, }, { name: "Empty Result From Policy", - fixture: &EmptyFixture{}, + client: &stubPolicyClient{empty: true}, token: "token", expectedStatusCode: http.StatusUnauthorized, }, { - name: "No Token Provided", - fixture: &AnonymousFixture{}, + name: "No Token Provided", + client: &stubPolicyClient{result: allowResult(map[string]interface{}{ + "actorId": "anonymous", + "orgName": "anonymous", + "actorType": "anonymous", + "roles": []interface{}{"guest"}, + })}, token: "", expectedStatusCode: http.StatusOK, expectedActorID: "anonymous", @@ -573,153 +389,156 @@ func TestNewPolicyMiddleware(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create a Policy client from the fixture - policyClient := tt.fixture.GetPolicyClient() - - // Create the Policy middleware using our test-specific middleware - policyMiddleware := testPolicyMiddleware(policyClient, "test-service", "", 0) - - // Create a test handler - var capturedCtx context.Context - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedCtx = r.Context() - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) - }) - - // Create a test router with the middleware - router := mux.NewRouter() - router.Use(policyMiddleware) - router.HandleFunc("/test", testHandler) - - // Create a test request - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) if tt.token != "" { req.Header.Set("Authorization", "Bearer "+tt.token) } - // Record the response - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - // Check the response + recorder, capturedCtx := servePolicy(t, tt.client, req) assert.Equal(t, tt.expectedStatusCode, recorder.Code) + assert.True(t, tt.client.called) - // If we expect success, verify the context was enriched correctly - if tt.expectedStatusCode == http.StatusOK { - assert.Equal(t, tt.expectedActorID, GetActorID(capturedCtx)) - assert.Equal(t, tt.expectedOrgName, GetOrgName(capturedCtx)) - assert.Equal(t, tt.expectedActorType, GetActorType(capturedCtx)) - assert.Equal(t, tt.expectedRoles, GetRoles(capturedCtx)) - - // Claims should contain at least these fields - claims := GetClaims(capturedCtx) - assert.NotNil(t, claims) - assert.Equal(t, tt.expectedActorID, claims["actorId"]) - assert.Equal(t, tt.expectedOrgName, claims["orgName"]) - assert.Equal(t, tt.expectedActorType, claims["actorType"]) + if tt.expectedStatusCode != http.StatusOK { + return } + + assert.Equal(t, tt.expectedActorID, GetActorID(capturedCtx)) + assert.Equal(t, tt.expectedOrgName, GetOrgName(capturedCtx)) + assert.Equal(t, tt.expectedActorType, GetActorType(capturedCtx)) + assert.Equal(t, tt.expectedRoles, GetRoles(capturedCtx)) + + claims := GetClaims(capturedCtx) + require.NotNil(t, claims) + assert.Equal(t, tt.expectedActorID, claims["actorId"]) + assert.Equal(t, tt.expectedOrgName, claims["orgName"]) + assert.Equal(t, tt.expectedActorType, claims["actorType"]) + assert.True(t, isPDPAuthorized(capturedCtx)) }) } } -func TestPolicyMiddlewareWithJWT(t *testing.T) { - // This test checks the interaction between JWT middleware and Policy middleware - policyClient := (&JWTFixture{}).GetPolicyClient() - - // Create JWT claims that would be extracted - jwtClaims := jwt.MapClaims{ - "sub": "user456", - "name": "JWT User", - "scopes": []string{"read", "write"}, - "iat": 1516239022, - "exp": 1896239022, - "email": "test@example.com", - "actorId": "jwt-user", - "orgName": "jwt-org", - "actorType": "jwt-actor-type", - "roles": []string{"jwt-admin"}, - } - - // Create a mock JWT parser to simulate JWT middleware - jwtMiddleware := func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Simulate successful JWT parsing by adding claims to context - ctx := context.WithValue(r.Context(), claimsContextKey, jwtClaims) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } +func TestNewPolicyMiddlewareNilClientReturnsServiceUnavailable(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + recorder, _ := servePolicy(t, nil, req) + assert.Equal(t, http.StatusServiceUnavailable, recorder.Code) +} - // Create the Policy middleware with mock JWT URL using our test-specific middleware - policyMiddleware := testPolicyMiddleware(policyClient, "test-service", "http://mock-jwks", 3600) +func TestNewPolicyMiddlewareSendsAPIKeyInEvaluateInput(t *testing.T) { + client := &stubPolicyClient{result: allowResult(nil)} + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer nvapi-opaque-key") - // Create a test handler - var capturedCtx context.Context - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedCtx = r.Context() - w.WriteHeader(http.StatusOK) - }) + recorder, _ := servePolicy(t, client, req) + assert.Equal(t, http.StatusOK, recorder.Code) + require.NotNil(t, client.lastReq) + assert.Equal(t, "nvapi-opaque-key", client.lastReq.Input["apiKey"].GetStringValue()) + assert.Equal(t, "test-service", client.lastReq.Input["service"].GetStringValue()) +} - // Create a test router with both middlewares - router := mux.NewRouter() - router.Use(jwtMiddleware) - router.Use(policyMiddleware) - router.HandleFunc("/test", testHandler) +func TestNewPolicyMiddlewareMergesJWTClaims(t *testing.T) { + client := &stubPolicyClient{result: allowResult(map[string]interface{}{ + "actorId": "user456", + "orgName": "org456", + "actorType": "user", + "roles": []interface{}{"admin"}, + })} - // Create a test request with JWT token - req := httptest.NewRequest("GET", "/test", nil) + jwtClaims := jwt.MapClaims{ + "sub": "user456", + "name": "JWT User", + "email": "test@example.com", + "scopes": []interface{}{"read", "write"}, + } + req := httptest.NewRequest(http.MethodGet, "/test", nil) req.Header.Set("Authorization", "Bearer test-token") + req = req.WithContext(context.WithValue(req.Context(), claimsContextKey, jwtClaims)) - // Record the response - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - // Check the response + recorder, capturedCtx := servePolicy(t, client, req) assert.Equal(t, http.StatusOK, recorder.Code) - // Verify the context contains both Policy and JWT claims claims := GetClaims(capturedCtx) - assert.NotNil(t, claims) - - // Should have Policy claims + require.NotNil(t, claims) assert.Equal(t, "user456", claims["actorId"]) assert.Equal(t, "org456", claims["orgName"]) assert.Equal(t, "user", claims["actorType"]) assert.Equal(t, []string{"admin"}, claims["roles"]) - - // Should also have JWT claims assert.Equal(t, "user456", claims["sub"]) assert.Equal(t, "JWT User", claims["name"]) assert.Equal(t, "test@example.com", claims["email"]) + assert.Equal(t, []string{"admin"}, GetRoles(capturedCtx)) - // Roles should be from Policy - roles := GetRoles(capturedCtx) - assert.Equal(t, []string{"admin"}, roles) + require.NotNil(t, client.lastReq) + assert.Equal(t, "user456", client.lastReq.Input["subject"].GetStringValue()) + scopes := client.lastReq.Input["scopes"].GetListValue().AsSlice() + assert.Equal(t, []interface{}{"read", "write"}, scopes) } -func TestPolicyAuthInputFields(t *testing.T) { - subjectField, apiKeyField := policyInputFields(nil) - assert.Equal(t, defaultAuthSubjectField, subjectField) - assert.Equal(t, defaultAuthAPIKeyField, apiKeyField) +func TestNewPolicyMiddlewareForwardsParsedJWTScopes(t *testing.T) { + tests := []struct { + name string + jwtScopes []interface{} + }{ + {name: "JWT with read scope", jwtScopes: []interface{}{"read"}}, + {name: "JWT with multiple scopes", jwtScopes: []interface{}{"read", "write", "admin"}}, + {name: "JWT with no scopes"}, + } - subjectField, apiKeyField = policyInputFields(&policyclient.PolicyConfig{ - SubjectField: "actor", - APIKeyField: "credential", - }) - assert.Equal(t, "actor", subjectField) - assert.Equal(t, "credential", apiKeyField) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &stubPolicyClient{result: allowResult(nil)} + jwtClaims := jwt.MapClaims{ + "sub": "user456", + "name": "JWT User", + } + if len(tt.jwtScopes) > 0 { + jwtClaims["scopes"] = tt.jwtScopes + } - authCtx := map[string]interface{}{} - setAuthContextField(authCtx, subjectField, "user-1") - setAuthContextField(authCtx, apiKeyField, "token-1") - assert.Equal(t, "user-1", authCtx["actor"]) - assert.Equal(t, "token-1", authCtx["credential"]) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer test-token") + req = req.WithContext(context.WithValue(req.Context(), claimsContextKey, jwtClaims)) + + recorder, _ := servePolicy(t, client, req) + assert.Equal(t, http.StatusOK, recorder.Code) + require.NotNil(t, client.lastReq) + + scopesValue, exists := client.lastReq.Input["scopes"] + if len(tt.jwtScopes) == 0 { + assert.False(t, exists) + return + } + require.True(t, exists) + assert.Equal(t, tt.jwtScopes, scopesValue.GetListValue().AsSlice()) + }) + } } -func TestNewAuthMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) { - client := &rejectingJWTPolicyClient{} - logger := otelzap.New(zaptest.NewLogger(t)) +func TestNewPolicyMiddlewareDeniesWhenEvaluatorRejectsMissingScopes(t *testing.T) { + denying := &stubPolicyClient{ + result: map[string]interface{}{ + "allow": false, + "statusCode": 403, + "reasons": []interface{}{"missing required scopes"}, + }, + } + + jwtClaims := jwt.MapClaims{ + "sub": "user456", + "name": "JWT User", + } + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer test-token") + req = req.WithContext(context.WithValue(req.Context(), claimsContextKey, jwtClaims)) + recorder, _ := servePolicy(t, denying, req) + assert.Equal(t, http.StatusForbidden, recorder.Code) + assert.Contains(t, recorder.Body.String(), http.StatusText(http.StatusForbidden)) + _, exists := denying.lastReq.Input["scopes"] + assert.False(t, exists) +} + +func TestNewAuthMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) { + client := &stubPolicyClient{result: allowResult(nil)} jwtOpts := NewJWTParserOptions( "https://issuer.test/.well-known/jwks.json", nil, @@ -727,10 +546,9 @@ func TestNewAuthMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) { &config.HTTPClientConfig{}, ) - authMiddleware := NewAuthMiddleware(client, "test-service", &jwtOpts, jwk.NewCache(context.Background()), true, logger) - + authMiddleware := NewAuthMiddleware(client, "test-service", &jwtOpts, jwk.NewCache(context.Background()), true, testLogger(t)) handlerCalled := false - handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { handlerCalled = true w.WriteHeader(http.StatusOK) })) @@ -738,7 +556,6 @@ func TestNewAuthMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/test", nil) req.Header.Set("Authorization", "Bearer not.valid.jwt") recorder := httptest.NewRecorder() - handler.ServeHTTP(recorder, req) assert.Equal(t, http.StatusUnauthorized, recorder.Code) @@ -750,411 +567,136 @@ func TestNewAuthMiddlewareRejectsRequestsWithNilClientAndLogger(t *testing.T) { authMiddleware := NewAuthMiddleware(nil, "test-service", nil, nil, true, nil) handlerCalled := false - handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { handlerCalled = true w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/test", nil) recorder := httptest.NewRecorder() - handler.ServeHTTP(recorder, req) assert.Equal(t, http.StatusServiceUnavailable, recorder.Code) assert.False(t, handlerCalled) } -func TestTestPolicyMiddlewareNilClientPassesThrough(t *testing.T) { - policyMiddleware := testPolicyMiddleware(nil, "test-service", "", 0) +func TestSelfManagedJWTNeverReachesPolicyDecisionPoint(t *testing.T) { + key, jwksURL, closeServer := newSigningKeyAndJWKS(t) + defer closeServer() - // Create a test handler - handlerCalled := false - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handlerCalled = true - w.WriteHeader(http.StatusOK) - }) + jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) + jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) - // Apply middleware - handler := policyMiddleware(testHandler) + client := &stubPolicyClient{result: allowResult(nil)} + handler := newAuthTestHandler(t, &jwtOpts, jwkCache, client, true, WriteScopes) - // Create a test request - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodPost, "/v3/ledger/cloudevents", nil) + req.Header.Set("Authorization", "Bearer "+signToken(t, key, []string{"fnds:createEvent"})) recorder := httptest.NewRecorder() - // Call the handler handler.ServeHTTP(recorder, req) - // Verify the handler was called directly (pass-through middleware) - assert.True(t, handlerCalled) assert.Equal(t, http.StatusOK, recorder.Code) + assert.False(t, client.called, "OpenBao JWT must not be sent to api-keys-api") } -// PolicyClientWithScopes is a test fixture that inspects and validates -// scopes that were passed to the Policy client -type PolicyClientWithScopes struct { - expectedScopes []string - capturedInput map[string]interface{} - t *testing.T -} +func TestSelfManagedJWTScopesEnforcedByRoute(t *testing.T) { + key, jwksURL, closeServer := newSigningKeyAndJWKS(t) + defer closeServer() -func (c *PolicyClientWithScopes) Evaluate(ctx context.Context, req *RuleRequest) (*RuleResponse, error) { - // Capture the input for later inspection - c.capturedInput = req.Input - - // Always authorize the request - resp := PolicyAuthzResponse{ - Allow: true, - StatusCode: 200, - ActorID: "user123", - OrgName: "org123", - ActorType: "user", - Roles: []string{"admin", "user"}, - Reasons: []string{"authorized"}, - } - data, _ := json.Marshal(resp) - return &RuleResponse{Result: data}, nil -} - -func (c *PolicyClientWithScopes) PolicyConfig() *PolicyConfig { - return &PolicyConfig{ - Namespace: "testns", - PolicyFQDN: "testpolicy", - } -} - -type PolicyScopeFixture struct { - expectedScopes []string - t *testing.T -} - -func (f *PolicyScopeFixture) GetPolicyClient() PolicyAuthZClientInterface { - return &PolicyClientWithScopes{ - expectedScopes: f.expectedScopes, - t: f.t, - } -} - -// PolicyClientDenyingScopes is a test fixture that denies access based on scopes -type PolicyClientDenyingScopes struct { - requiredScopes []string - capturedInput map[string]interface{} - t *testing.T -} - -func (c *PolicyClientDenyingScopes) Evaluate(ctx context.Context, req *RuleRequest) (*RuleResponse, error) { - // Capture the input for later inspection - c.capturedInput = req.Input - - // Check if the required scopes are present - inputScopes, ok := req.Input["scopes"].([]interface{}) - if !ok { - // No scopes found, deny access - resp := PolicyAuthzResponse{ - Allow: false, - StatusCode: 403, - Reasons: []string{"missing required scopes"}, - } - data, _ := json.Marshal(resp) - return &RuleResponse{Result: data}, nil - } - - // Convert input scopes to strings - inputScopeStrings := make([]string, 0, len(inputScopes)) - for _, s := range inputScopes { - if str, ok := s.(string); ok { - inputScopeStrings = append(inputScopeStrings, str) - } - } - - // Check if all required scopes are present - missingScopes := make([]string, 0) - for _, required := range c.requiredScopes { - found := false - for _, scope := range inputScopeStrings { - if scope == required { - found = true - break - } - } - if !found { - missingScopes = append(missingScopes, required) - } - } - - if len(missingScopes) > 0 { - // Missing required scopes, deny access - resp := PolicyAuthzResponse{ - Allow: false, - StatusCode: 403, - Reasons: []string{"insufficient scopes"}, - } - data, _ := json.Marshal(resp) - return &RuleResponse{Result: data}, nil - } - - // All required scopes present, authorize - resp := PolicyAuthzResponse{ - Allow: true, - StatusCode: 200, - ActorID: "user123", - OrgName: "org123", - ActorType: "user", - Roles: []string{"admin", "user"}, - Reasons: []string{"authorized"}, - } - data, _ := json.Marshal(resp) - return &RuleResponse{Result: data}, nil -} - -func (c *PolicyClientDenyingScopes) PolicyConfig() *PolicyConfig { - return &PolicyConfig{ - Namespace: "testns", - PolicyFQDN: "testpolicy", - } -} - -type PolicyDenyScopeFixture struct { - requiredScopes []string - t *testing.T -} - -func (f *PolicyDenyScopeFixture) GetPolicyClient() PolicyAuthZClientInterface { - return &PolicyClientDenyingScopes{ - requiredScopes: f.requiredScopes, - t: f.t, - } -} + jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) + jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) -func TestPolicyMiddlewareWithScopes(t *testing.T) { tests := []struct { - name string - jwtScopes []string - expectedStatusCode int - validateScopes func(t *testing.T, capturedScopes interface{}, exists bool) + name string + tokenScope []string + required Scopes + wantStatus int }{ - { - name: "JWT with read scope", - jwtScopes: []string{"read"}, - expectedStatusCode: http.StatusOK, - validateScopes: func(t *testing.T, capturedScopes interface{}, exists bool) { - // In the testPolicyMiddleware implementation, scopes may not be passed correctly - // This is a limitation of our test environment, but in real code it would work - // Just verify we got a valid response - assert.Equal(t, http.StatusOK, 200) - }, - }, - { - name: "JWT with multiple scopes", - jwtScopes: []string{"read", "write", "admin"}, - expectedStatusCode: http.StatusOK, - validateScopes: func(t *testing.T, capturedScopes interface{}, exists bool) { - // In the testPolicyMiddleware implementation, scopes may not be passed correctly - // This is a limitation of our test environment, but in real code it would work - // Just verify we got a valid response - assert.Equal(t, http.StatusOK, 200) - }, - }, - { - name: "JWT with no scopes", - jwtScopes: []string{}, - expectedStatusCode: http.StatusOK, - validateScopes: func(t *testing.T, capturedScopes interface{}, exists bool) { - // Should still authorize without scopes in our test environment - assert.Equal(t, http.StatusOK, 200) - }, - }, + {"write scope on write route", []string{"fnds:createEvent"}, WriteScopes, http.StatusOK}, + {"archive scope on archive route", []string{"fnds:archiveEvents"}, ArchiveScopes, http.StatusOK}, + {"write scope on read route", []string{"fnds:createEvent"}, ReadScopes, http.StatusForbidden}, + {"read scope on read route", []string{"fnds:getEvents"}, ReadScopes, http.StatusOK}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create Policy client that will capture the input - fixture := &PolicyScopeFixture{t: t} - policyClient := fixture.GetPolicyClient() - - // Create JWT claims with the test scopes - jwtClaims := jwt.MapClaims{ - "sub": "user456", - "name": "JWT User", - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := &stubPolicyClient{result: allowResult(nil)} + handler := newAuthTestHandler(t, &jwtOpts, jwkCache, client, true, tc.required) - // Only add scopes if we have them - if len(tt.jwtScopes) > 0 { - jwtClaims["scopes"] = tt.jwtScopes - } - - // Create a mock JWT parser to simulate JWT middleware - jwtMiddleware := func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Simulate successful JWT parsing by adding claims to context - ctx := context.WithValue(r.Context(), claimsContextKey, jwtClaims) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } - - // Create the Policy middleware with mock JWT URL - policyMiddleware := testPolicyMiddleware(policyClient, "test-service", "http://mock-jwks", 3600) - - // Create a test handler - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - // Create a test router with both middlewares - router := mux.NewRouter() - router.Use(jwtMiddleware) - router.Use(policyMiddleware) - router.HandleFunc("/test", testHandler) - - // Create a test request with JWT token - req := httptest.NewRequest("GET", "/test", nil) - req.Header.Set("Authorization", "Bearer test-token") - - // Record the response + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer "+signToken(t, key, tc.tokenScope)) recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - // Check the response - assert.Equal(t, tt.expectedStatusCode, recorder.Code) + handler.ServeHTTP(recorder, req) - // Validate scopes passed to Policy - capturedInput := policyClient.(*PolicyClientWithScopes).capturedInput - assert.NotNil(t, capturedInput, "Expected input to be captured") - - // Check if scopes were passed to Policy - scopes, exists := capturedInput["scopes"] - // Pass to validation function whether scopes exist or not - tt.validateScopes(t, scopes, exists) + assert.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String()) + assert.False(t, client.called) }) } } -func TestPolicyMiddlewareScopeBasedAuthorization(t *testing.T) { - // Create a Policy client that will deny access for missing scopes - policyClient := &PolicyClientDenyingScopes{ - requiredScopes: []string{"read"}, - t: t, - } - - // Create JWT claims without scopes - jwtClaims := jwt.MapClaims{ - "sub": "user456", - "name": "JWT User", - // No scopes provided - } - - // Create a mock JWT parser to simulate JWT middleware - jwtMiddleware := func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Simulate successful JWT parsing by adding claims to context - ctx := context.WithValue(r.Context(), claimsContextKey, jwtClaims) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } +func TestAPIKeySkipsJWTVerificationAndScopeCheck(t *testing.T) { + client := &stubPolicyClient{result: map[string]interface{}{ + "allowed": true, + "ncaId": "nca-1", + "ownerId": "owner-1", + }} + handler := newAuthTestHandler(t, nil, nil, client, true, ReadScopes) - // Create the Policy middleware with mock JWT URL - policyMiddleware := testPolicyMiddleware(policyClient, "test-service", "http://mock-jwks", 3600) + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer nvapi-opaque-key") + recorder := httptest.NewRecorder() - // Create a test handler - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) + handler.ServeHTTP(recorder, req) - // Create a test router with both middlewares - router := mux.NewRouter() - router.Use(jwtMiddleware) - router.Use(policyMiddleware) - router.HandleFunc("/test", testHandler) + assert.Equal(t, http.StatusOK, recorder.Code) + assert.True(t, client.called, "API key must be authorized by api-keys-api") +} - // Create a test request with JWT token - req := httptest.NewRequest("GET", "/test", nil) - req.Header.Set("Authorization", "Bearer test-token") +func TestAPIKeyDeniedByPolicyDecisionPoint(t *testing.T) { + client := &stubPolicyClient{result: map[string]interface{}{ + "allowed": false, + "ncaId": "nca-1", + "ownerId": "owner-1", + }} + handler := newAuthTestHandler(t, nil, nil, client, true, ReadScopes) - // Record the response + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer nvapi-opaque-key") recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - // Should be forbidden since scopes are missing + handler.ServeHTTP(recorder, req) + + // api-keys-api omits statusCode, which PolicyAuthzResponse defaults to 403. assert.Equal(t, http.StatusForbidden, recorder.Code) - assert.Contains(t, recorder.Body.String(), "missing required scopes") + assert.True(t, client.called) } -func TestDisableAuthentication_Policy(t *testing.T) { - // Test scenario: - // 1. Disabling authentication means passing a nil Policy client to NewPolicyMiddleware - // 2. Which should create a pass-through middleware that doesn't perform auth checks +func TestManagedJWTStillDelegatesToPolicyDecisionPoint(t *testing.T) { + key, jwksURL, closeServer := newSigningKeyAndJWKS(t) + defer closeServer() - // Test different requests with auth enabled vs disabled - tests := []struct { - name string - policyClientSetup func() PolicyAuthZClientInterface - pathsToTest []string - expectedStatus int - }{ - { - name: "Authentication disabled - all routes should be accessible", - policyClientSetup: func() PolicyAuthZClientInterface { - // Return nil to simulate disabled auth - return nil - }, - pathsToTest: []string{"/api/user", "/api/admin", "/api/restricted"}, - expectedStatus: http.StatusOK, - }, - { - name: "Authentication enabled - invalid tokens should be rejected", - policyClientSetup: func() PolicyAuthZClientInterface { - // Return forbidden client to simulate enabled auth - return &ForbiddenPolicyClient{} - }, - pathsToTest: []string{"/api/user", "/api/admin", "/api/restricted"}, - expectedStatus: http.StatusForbidden, - }, - } + jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) + jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Get Policy client (or nil) - client := tt.policyClientSetup() - - // Create Policy middleware - policyMiddleware := testPolicyMiddleware(client, "test-service", "", 0) - - // Create router - router := mux.NewRouter() - - // Add the middleware - router.Use(policyMiddleware) + client := &stubPolicyClient{result: allowResult(nil)} + authMiddleware := NewAuthMiddleware(client, "nv-cloud-functions", &jwtOpts, jwkCache, false, testLogger(t)) - // Register test handler for all paths being tested - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("Success")) - }) - - // Register the handler for all test paths - for _, path := range tt.pathsToTest { - router.HandleFunc(path, testHandler) - } - - // Test all paths - for _, path := range tt.pathsToTest { - t.Run(path, func(t *testing.T) { - // Create request - req := httptest.NewRequest("GET", path, nil) - req.Header.Set("Authorization", "Bearer invalid-token") + var capturedCtx context.Context + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedCtx = r.Context() + w.WriteHeader(http.StatusOK) + })) - // Record response - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, "/v3/ledger/namespace/nvcf/events", nil) + req.Header.Set("Authorization", "Bearer "+signToken(t, key, []string{"fnds:getEvents"})) + recorder := httptest.NewRecorder() - // Check status code - assert.Equal(t, tt.expectedStatus, rec.Code) + handler.ServeHTTP(recorder, req) - // If auth is disabled, should see success message - if client == nil { - assert.Equal(t, "Success", rec.Body.String()) - } - }) - } - }) - } + assert.Equal(t, http.StatusOK, recorder.Code) + assert.True(t, client.called, "managed deployments must still consult the PDP") + require.NotNil(t, capturedCtx) + assert.Equal(t, "sis-api", GetClaims(capturedCtx)["sub"]) } From 020770442f5474fd14810dbd3016349fb41357c9 Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Fri, 28 Aug 2026 16:25:25 -0700 Subject: [PATCH 4/6] fix(event-ledger): require exp claim on JWTs under the policy provider The jwt provider branch already set RequireExpiration; the policy provider branch, used by self-managed and managed alike, did not. A JWT missing an exp claim passed local verification with no expiration enforced at all, regardless of deployment mode. Signed-off-by: Shelley Shen --- .../event-ledger/cmd/api/startup/run_service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go b/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go index e6149f0e6..b1dc6aa8e 100644 --- a/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go +++ b/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go @@ -293,6 +293,7 @@ func runService(cfg config.Config) error { opts := middleware.NewJWTParserOptions(cfg.Auth.JWKSetUrl, nil, cacheDuration, &cfg.HTTP) opts.Issuer = cfg.Auth.Issuer opts.Audience = cfg.Auth.Audience + opts.RequireExpiration = true jwtOpts = &opts } From d2bf5ff9b2bb37e3a0fae66368ce537f19a1d404 Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Fri, 28 Aug 2026 16:35:57 -0700 Subject: [PATCH 5/6] Revert "fix(event-ledger): require exp claim on JWTs under the policy provider" This reverts commit 020770442f5474fd14810dbd3016349fb41357c9. Self-managed's OpenBao-issued tokens always carry exp, so this had no effect there. Managed's actual token issuer is unverified from this repo, and enabling a previously-off validation check can only reject tokens that currently pass, so this needs confirmation against managed's real JWTs before it ships. Signed-off-by: Shelley Shen --- .../event-ledger/cmd/api/startup/run_service.go | 1 - 1 file changed, 1 deletion(-) diff --git a/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go b/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go index b1dc6aa8e..e6149f0e6 100644 --- a/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go +++ b/src/control-plane-services/event-ledger/cmd/api/startup/run_service.go @@ -293,7 +293,6 @@ func runService(cfg config.Config) error { opts := middleware.NewJWTParserOptions(cfg.Auth.JWKSetUrl, nil, cacheDuration, &cfg.HTTP) opts.Issuer = cfg.Auth.Issuer opts.Audience = cfg.Auth.Audience - opts.RequireExpiration = true jwtOpts = &opts } From 88302dd1125d786fd43f28209f8e5e306da6499f Mon Sep 17 00:00:00 2001 From: Shelley Shen Date: Fri, 28 Aug 2026 16:58:31 -0700 Subject: [PATCH 6/6] test(event-ledger): guard lastReq before dereference in scope-denial test Matches the require.NotNil pattern already used elsewhere in this file, so a future change that denies before calling Evaluate fails with a clear message instead of a nil pointer panic. Signed-off-by: Shelley Shen --- .../event-ledger/internal/middleware/policy_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/control-plane-services/event-ledger/internal/middleware/policy_test.go b/src/control-plane-services/event-ledger/internal/middleware/policy_test.go index 9d83c367e..518923fa0 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/policy_test.go +++ b/src/control-plane-services/event-ledger/internal/middleware/policy_test.go @@ -533,6 +533,7 @@ func TestNewPolicyMiddlewareDeniesWhenEvaluatorRejectsMissingScopes(t *testing.T recorder, _ := servePolicy(t, denying, req) assert.Equal(t, http.StatusForbidden, recorder.Code) assert.Contains(t, recorder.Body.String(), http.StatusText(http.StatusForbidden)) + require.NotNil(t, denying.lastReq) _, exists := denying.lastReq.Input["scopes"] assert.False(t, exists) }