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 65d97a4f43..ab78dc2423 100644 --- a/src/control-plane-services/event-ledger/internal/middleware/policy.go +++ b/src/control-plane-services/event-ledger/internal/middleware/policy.go @@ -209,11 +209,23 @@ func newPolicyMiddleware(policyClient policy.Authorizer, serviceName string, log // 1. Extract the token (simple bearer token extraction) token := "" authHeader := r.Header.Get("Authorization") + _, hasJWTClaims := r.Context().Value(claimsContextKey).(jwt.MapClaims) if strings.HasPrefix(authHeader, "Bearer ") { - token = strings.TrimPrefix(authHeader, "Bearer ") - logger.InfoContext(traceCtx, "policy: token extracted", zap.String("token_length", strconv.Itoa(len(token)))) - } else { + token = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + } + if token == "" && !hasJWTClaims { + // No usable credential (header absent, not Bearer-prefixed, or + // an empty/whitespace-only Bearer value): fail fast instead of + // asking the policy evaluator to deny an empty API key. + // hasJWTClaims covers the managed-mode JWT-then-policy chain, + // where jwtVerify already consumed and cleared this header + // after a successful local verification. logger.WarnContext(traceCtx, "policy: no bearer token found in authorization header") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + if token != "" { + logger.InfoContext(traceCtx, "policy: token extracted", zap.String("token_length", strconv.Itoa(len(token)))) } authCtx := map[string]interface{}{ 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 864af39c9e..56b27902fa 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 @@ -335,24 +335,29 @@ func TestPolicyAuthInputFields(t *testing.T) { func TestNewPolicyMiddleware(t *testing.T) { tests := []struct { - name string - client *stubPolicyClient - token string - expectedStatusCode int - expectedActorID string - expectedOrgName string - expectedActorType string - expectedRoles []string + name string + client *stubPolicyClient + token string + rawAuthHeader string + setRawAuthHeader bool + claims jwt.MapClaims + expectedStatusCode int + expectedClientCalled bool + expectedActorID string + expectedOrgName string + expectedActorType string + expectedRoles []string }{ { - name: "Successful Authorization", - client: &stubPolicyClient{result: allowResult(nil)}, - token: "valid-token", - expectedStatusCode: http.StatusOK, - expectedActorID: "user123", - expectedOrgName: "org123", - expectedActorType: "user", - expectedRoles: []string{"admin", "user"}, + name: "Successful Authorization", + client: &stubPolicyClient{result: allowResult(nil)}, + token: "valid-token", + expectedStatusCode: http.StatusOK, + expectedClientCalled: true, + expectedActorID: "user123", + expectedOrgName: "org123", + expectedActorType: "user", + expectedRoles: []string{"admin", "user"}, }, { name: "Failed Authorization", @@ -361,48 +366,92 @@ func TestNewPolicyMiddleware(t *testing.T) { "statusCode": 403, "reasons": []interface{}{"unauthorized access"}, }}, - token: "invalid-token", - expectedStatusCode: http.StatusForbidden, + token: "invalid-token", + expectedStatusCode: http.StatusForbidden, + expectedClientCalled: true, }, { - name: "Policy Service Error", - client: &stubPolicyClient{err: errors.New("service unavailable")}, - token: "token", - expectedStatusCode: http.StatusUnauthorized, + name: "Policy Service Error", + client: &stubPolicyClient{err: errors.New("service unavailable")}, + token: "token", + expectedStatusCode: http.StatusUnauthorized, + expectedClientCalled: true, }, { - name: "Empty Result From Policy", - client: &stubPolicyClient{empty: true}, - token: "token", - expectedStatusCode: http.StatusUnauthorized, + name: "Empty Result From Policy", + client: &stubPolicyClient{empty: true}, + token: "token", + expectedStatusCode: http.StatusUnauthorized, + expectedClientCalled: true, }, { - name: "No Token Provided", + // No Authorization header and no prior JWT verification: fail + // fast locally instead of sending an empty API key to the + // policy evaluator. + name: "No Token Provided", + client: &stubPolicyClient{result: allowResult(nil)}, + token: "", + expectedStatusCode: http.StatusUnauthorized, + expectedClientCalled: false, + }, + { + // "Bearer " with nothing after it is Bearer-prefixed, so it must + // not slip past the fail-fast check via the happy-path branch. + name: "Empty Bearer Token", + client: &stubPolicyClient{result: allowResult(nil)}, + rawAuthHeader: "Bearer ", + setRawAuthHeader: true, + expectedStatusCode: http.StatusUnauthorized, + expectedClientCalled: false, + }, + { + // Whitespace-only value after "Bearer " must be treated the same + // as an empty token, not forwarded to the policy evaluator. + name: "Whitespace-Only Bearer Token", + client: &stubPolicyClient{result: allowResult(nil)}, + rawAuthHeader: "Bearer ", + setRawAuthHeader: true, + expectedStatusCode: http.StatusUnauthorized, + expectedClientCalled: false, + }, + { + // Simulates the managed-mode JWT-then-policy chain: jwtVerify + // already validated the token and cleared the Authorization + // header, but stashed claims in the request context. The policy + // evaluator must still be consulted using those claims. + name: "No Header, But Already-Verified JWT Claims", client: &stubPolicyClient{result: allowResult(map[string]interface{}{ - "actorId": "anonymous", - "orgName": "anonymous", - "actorType": "anonymous", - "roles": []interface{}{"guest"}, + "actorId": "user456", + "orgName": "org456", + "actorType": "user", + "roles": []interface{}{"user"}, })}, - token: "", - expectedStatusCode: http.StatusOK, - expectedActorID: "anonymous", - expectedOrgName: "anonymous", - expectedActorType: "anonymous", - expectedRoles: []string{"guest"}, + claims: jwt.MapClaims{"sub": "user456"}, + token: "", + expectedStatusCode: http.StatusOK, + expectedClientCalled: true, + expectedActorID: "user456", + expectedOrgName: "org456", + expectedActorType: "user", + expectedRoles: []string{"user"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/test", nil) - if tt.token != "" { + if tt.setRawAuthHeader { + req.Header.Set("Authorization", tt.rawAuthHeader) + } else if tt.token != "" { req.Header.Set("Authorization", "Bearer "+tt.token) } + if tt.claims != nil { + req = req.WithContext(context.WithValue(req.Context(), claimsContextKey, tt.claims)) + } recorder, capturedCtx := servePolicy(t, tt.client, req) assert.Equal(t, tt.expectedStatusCode, recorder.Code) - assert.True(t, tt.client.called) + assert.Equal(t, tt.expectedClientCalled, tt.client.called) if tt.expectedStatusCode != http.StatusOK { return