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..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 @@ -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 { @@ -290,19 +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( + var jwtOpts *middleware.JWTParserOptions + if cfg.Auth.JWKSetUrl != "" { + opts := middleware.NewJWTParserOptions(cfg.Auth.JWKSetUrl, nil, cacheDuration, &cfg.HTTP) + opts.Issuer = cfg.Auth.Issuer + opts.Audience = cfg.Auth.Audience + jwtOpts = &opts + } + + requireLocalScopeCheck = cfg.SelfManaged + + authRouter.Use(middleware.NewAuthMiddleware( policyClient, "nv-cloud-functions", - cfg.Auth.JWKSetUrl, - cacheDuration, + jwtOpts, jwkCache, - &cfg.HTTP, + cfg.SelfManaged, logger, - ) - - authRouter.Use(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..5e9fef892 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel +++ b/src/control-plane-services/event-ledger/internal/middleware/BUILD.bazel @@ -58,12 +58,13 @@ 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", "@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/jwt.go b/src/control-plane-services/event-ledger/internal/middleware/jwt.go index dc8b33534..30be7be9e 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..65d97a4f4 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/policy.go +++ b/src/control-plane-services/event-ledger/internal/middleware/policy.go @@ -23,7 +23,6 @@ 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" @@ -34,8 +33,6 @@ import ( "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 +50,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 +183,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 +195,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 +222,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) + } + if scopes, ok := claims["scopes"].([]interface{}); ok && len(scopes) > 0 { + authCtx["scopes"] = scopes } - } else if token != "" { - logger.InfoContext(traceCtx, "policy: token appears to be an api key, not a jwt") + } else { setAuthContextField(authCtx, apiKeyField, token) } @@ -344,8 +300,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 +323,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) @@ -397,20 +353,69 @@ 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 +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 (d *dummyResponseWriter) Header() http.Header { - return d.header +func bearerToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + return "" + } + return strings.TrimPrefix(authHeader, "Bearer ") } -func (d *dummyResponseWriter) Write([]byte) (int, error) { - return 0, nil +func chainMiddleware(first, second mux.MiddlewareFunc) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return first(second(next)) + } } -func (d *dummyResponseWriter) WriteHeader(statusCode int) { - // Do nothing +// 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 024eeb0fd..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 @@ -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,164 +389,167 @@ 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 TestNewPolicyMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) { - client := &rejectingJWTPolicyClient{} - logger := otelzap.New(zaptest.NewLogger(t)) - policyMiddleware := NewPolicyMiddleware( - client, - "test-service", +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)) + require.NotNil(t, denying.lastReq) + _, 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, time.Minute, - jwk.NewCache(context.Background()), &config.HTTPClientConfig{}, - logger, ) + authMiddleware := NewAuthMiddleware(client, "test-service", &jwtOpts, jwk.NewCache(context.Background()), true, testLogger(t)) handlerCalled := false - handler := policyMiddleware(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 +557,6 @@ func TestNewPolicyMiddlewareRejectsJWTShapedTokenWhenParsingFails(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) @@ -746,423 +564,140 @@ func TestNewPolicyMiddlewareRejectsJWTShapedTokenWhenParsingFails(t *testing.T) assert.False(t, handlerCalled) } -func TestNewPolicyMiddlewareRejectsRequestsWithNilClientAndLogger(t *testing.T) { - policyMiddleware := NewPolicyMiddleware( - nil, - "test-service", - "", - 0, - nil, - &config.HTTPClientConfig{}, - 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, _ *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 -} + jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) + jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) -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, - } -} - -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() + 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) - // Create JWT claims with the test scopes - jwtClaims := jwt.MapClaims{ - "sub": "user456", - "name": "JWT User", - } - - // 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) - // Validate scopes passed to Policy - capturedInput := policyClient.(*PolicyClientWithScopes).capturedInput - assert.NotNil(t, capturedInput, "Expected input to be captured") + handler.ServeHTTP(recorder, req) - // 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, - }, - } - - 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) + jwtOpts := NewJWTParserOptions(jwksURL, nil, time.Minute, &config.HTTPClientConfig{}) + jwkCache := jwk.NewCache(context.Background(), jwk.WithRefreshWindow(time.Minute)) - // Create router - router := mux.NewRouter() + client := &stubPolicyClient{result: allowResult(nil)} + authMiddleware := NewAuthMiddleware(client, "nv-cloud-functions", &jwtOpts, jwkCache, false, testLogger(t)) - // Add the middleware - router.Use(policyMiddleware) - - // 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"]) } 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 {