From 58dd7a201575ce87ffb4ea41bf0c2e885004a26b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Sanz=20G=C3=B3miz?= Date: Tue, 11 Aug 2026 14:22:43 +0200 Subject: [PATCH 1/2] feat: enrich Check span with auth decision attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add domain-specific span attributes to the existing Check span so traces carry actionable auth metadata instead of only authorino.request_id. New attributes: authorino.auth.result (ALLOW/DENY), authorino.auth.response_code, authorino.auth.denial_reason, authorino.auth_config.name, authorino.auth_config.namespace, authorino.identity.source, and authorino.identity.type. Closes https://github.com/Kuadrant/authorino/issues/669 Signed-off-by: Adrian Sanz Gomiz Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Adrián Sanz Gómiz --- pkg/service/auth.go | 34 ++++++++ pkg/service/auth_test.go | 172 +++++++++++++++++++++++++++++++++++++++ pkg/trace/trace.go | 8 ++ 3 files changed, 214 insertions(+) diff --git a/pkg/service/auth.go b/pkg/service/auth.go index 77f52c658..c388b7378 100644 --- a/pkg/service/auth.go +++ b/pkg/service/auth.go @@ -13,6 +13,7 @@ import ( "github.com/kuadrant/authorino/pkg/auth" "github.com/kuadrant/authorino/pkg/context" + "github.com/kuadrant/authorino/pkg/evaluators" "github.com/kuadrant/authorino/pkg/index" "github.com/kuadrant/authorino/pkg/log" "github.com/kuadrant/authorino/pkg/metrics" @@ -23,7 +24,9 @@ import ( envoy_type "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/gogo/googleapis/google/rpc" "github.com/google/uuid" + otel_attr "go.opentelemetry.io/otel/attribute" otel_codes "go.opentelemetry.io/otel/codes" + otel_trace "go.opentelemetry.io/otel/trace" rpcstatus "google.golang.org/genproto/googleapis/rpc/status" "google.golang.org/protobuf/types/known/structpb" v1 "k8s.io/api/admission/v1" @@ -251,6 +254,7 @@ func (a *AuthService) Check(parentContext gocontext.Context, req *envoy_auth.Che span.RecordError(err) span.SetStatus(otel_codes.Error, err.Error()) result := auth.AuthResult{Code: rpc.INVALID_ARGUMENT, Message: RESPONSE_MESSAGE_INVALID_REQUEST} + setAuthResultSpanAttrs(span, result) return a.deniedResponse(result), nil } @@ -284,12 +288,19 @@ func (a *AuthService) Check(parentContext gocontext.Context, req *envoy_auth.Che // If we couldn't find the AuthConfig in the config, we return and deny. if authConfig == nil { result := auth.AuthResult{Code: rpc.NOT_FOUND, Message: RESPONSE_MESSAGE_SERVICE_NOT_FOUND} + setAuthResultSpanAttrs(span, result) a.logAuthResult(result, ctx) return a.deniedResponse(result), nil } + span.SetAttributes( + otel_attr.String(trace.AuthConfigNameAttr, authConfig.Labels["authconfig"]), + otel_attr.String(trace.AuthConfigNamespaceAttr, authConfig.Labels["namespace"]), + ) + if err := context.CheckContext(ctx); err != nil { result := auth.AuthResult{Code: rpc.UNAVAILABLE} + setAuthResultSpanAttrs(span, result) a.logAuthResult(result, ctx) context.Cancel(ctx) span.RecordError(err) @@ -308,6 +319,17 @@ func (a *AuthService) Check(parentContext gocontext.Context, req *envoy_auth.Che span.SetStatus(otel_codes.Error, err.Error()) } + setAuthResultSpanAttrs(span, result) + + if idConfig, _ := pipeline.GetResolvedIdentity(); idConfig != nil { + if ic, ok := idConfig.(*evaluators.IdentityConfig); ok { + span.SetAttributes( + otel_attr.String(trace.IdentitySourceAttr, ic.GetName()), + otel_attr.String(trace.IdentityTypeAttr, ic.GetType()), + ) + } + } + a.logAuthResult(result, ctx) if result.Success() { @@ -499,6 +521,18 @@ func closeWithStatus(respStatusCode envoy_type.StatusCode, response http.Respons context.Cancel(ctx) } +func setAuthResultSpanAttrs(span otel_trace.Span, result auth.AuthResult) { + if result.Success() { + span.SetAttributes(otel_attr.String(trace.AuthResultAttr, "ALLOW")) + } else { + span.SetAttributes(otel_attr.String(trace.AuthResultAttr, "DENY")) + if result.Message != "" { + span.SetAttributes(otel_attr.String(trace.AuthDenialReasonAttr, result.Message)) + } + } + span.SetAttributes(otel_attr.String(trace.AuthResponseCodeAttr, result.Code.String())) +} + func ensureRequestId(requestIdCandidates ...string) string { for _, requestId := range requestIdCandidates { if requestId != "" { diff --git a/pkg/service/auth_test.go b/pkg/service/auth_test.go index e855329cf..47103b143 100644 --- a/pkg/service/auth_test.go +++ b/pkg/service/auth_test.go @@ -27,8 +27,14 @@ import ( envoy_type "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/gogo/googleapis/google/rpc" opaParser "github.com/open-policy-agent/opa/v1/ast" + "go.opentelemetry.io/otel" + otel_attr "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" "go.uber.org/mock/gomock" "k8s.io/apimachinery/pkg/runtime" + + "github.com/kuadrant/authorino/pkg/trace" ) const ( @@ -375,6 +381,172 @@ func TestCheckFailsClosedOnContextTimeout(t *testing.T) { assert.Check(t, denied != nil, "Expected denied response") } +func setupTestTracer(t *testing.T) *tracetest.InMemoryExporter { + t.Helper() + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter)) + prev := otel.GetTracerProvider() + otel.SetTracerProvider(tp) + t.Cleanup(func() { + otel.SetTracerProvider(prev) + tp.Shutdown(context.Background()) + }) + return exporter +} + +func findSpanAttr(spans tracetest.SpanStubs, attrKey string) (otel_attr.Value, bool) { + for _, s := range spans { + for _, a := range s.Attributes { + if string(a.Key) == attrKey { + return a.Value, true + } + } + } + return otel_attr.Value{}, false +} + +func TestCheckSpanAttributes_AllowedRequest(t *testing.T) { + exporter := setupTestTracer(t) + mockController := gomock.NewController(t) + defer mockController.Finish() + + authConfig := mockAnonymousAccessAuthConfig() + authConfig.Labels = map[string]string{"authconfig": "my-config", "namespace": "my-ns"} + + indexMock := mock_index.NewMockIndex(mockController) + indexMock.EXPECT().Get("myapp.io").Return(authConfig) + + service := &AuthService{Index: indexMock} + _, err := service.Check(context.Background(), &envoy_auth.CheckRequest{ + Attributes: &envoy_auth.AttributeContext{ + Request: &envoy_auth.AttributeContext_Request{ + Http: &envoy_auth.AttributeContext_HttpRequest{Host: "myapp.io", Method: "GET", Path: "/"}, + }, + }, + }) + assert.NilError(t, err) + + spans := exporter.GetSpans() + val, ok := findSpanAttr(spans, trace.AuthResultAttr) + assert.Assert(t, ok, "expected authorino.auth.result attribute") + assert.Equal(t, val.AsString(), "ALLOW") + + val, ok = findSpanAttr(spans, trace.AuthResponseCodeAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "OK") + + val, ok = findSpanAttr(spans, trace.AuthConfigNameAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "my-config") + + val, ok = findSpanAttr(spans, trace.AuthConfigNamespaceAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "my-ns") + + val, ok = findSpanAttr(spans, trace.IdentitySourceAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "anonymous") + + _, ok = findSpanAttr(spans, trace.AuthDenialReasonAttr) + assert.Assert(t, !ok, "denial reason should not be set on allowed request") +} + +func TestCheckSpanAttributes_DeniedRequest(t *testing.T) { + exporter := setupTestTracer(t) + mockController := gomock.NewController(t) + defer mockController.Finish() + + authCred := auth.NewAuthCredential("", "") + identityConfig := &evaluators.IdentityConfig{Name: "anonymous", Noop: &identity.Noop{AuthCredentials: authCred}} + authorizationPolicy, _ := authorization.NewOPAAuthorization("deny-policy", `allow := false`, nil, false, opaParser.RegoV1, 0, context.TODO()) + authorizationConfig := &evaluators.AuthorizationConfig{Name: "always-deny", OPA: authorizationPolicy} + authConfig := &evaluators.AuthConfig{ + Labels: map[string]string{"authconfig": "protected-api", "namespace": "prod"}, + IdentityConfigs: []auth.AuthConfigEvaluator{identityConfig}, + AuthorizationConfigs: []auth.AuthConfigEvaluator{authorizationConfig}, + } + + indexMock := mock_index.NewMockIndex(mockController) + indexMock.EXPECT().Get("myapp.io").Return(authConfig) + + service := &AuthService{Index: indexMock} + resp, err := service.Check(context.Background(), &envoy_auth.CheckRequest{ + Attributes: &envoy_auth.AttributeContext{ + Request: &envoy_auth.AttributeContext_Request{ + Http: &envoy_auth.AttributeContext_HttpRequest{Host: "myapp.io", Method: "GET", Path: "/"}, + }, + }, + }) + assert.NilError(t, err) + assert.Equal(t, resp.Status.Code, int32(rpc.PERMISSION_DENIED)) + + spans := exporter.GetSpans() + val, ok := findSpanAttr(spans, trace.AuthResultAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "DENY") + + val, ok = findSpanAttr(spans, trace.AuthResponseCodeAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "PERMISSION_DENIED") + + _, ok = findSpanAttr(spans, trace.AuthDenialReasonAttr) + assert.Assert(t, ok, "denial reason should be set on denied request") + + val, ok = findSpanAttr(spans, trace.AuthConfigNameAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "protected-api") +} + +func TestCheckSpanAttributes_ServiceNotFound(t *testing.T) { + exporter := setupTestTracer(t) + mockController := gomock.NewController(t) + defer mockController.Finish() + + indexMock := mock_index.NewMockIndex(mockController) + indexMock.EXPECT().Get("unknown.io").Return(nil) + + service := &AuthService{Index: indexMock} + resp, err := service.Check(context.Background(), &envoy_auth.CheckRequest{ + Attributes: &envoy_auth.AttributeContext{ + Request: &envoy_auth.AttributeContext_Request{ + Http: &envoy_auth.AttributeContext_HttpRequest{Host: "unknown.io", Method: "GET", Path: "/"}, + }, + }, + }) + assert.NilError(t, err) + assert.Equal(t, resp.Status.Code, int32(rpc.NOT_FOUND)) + + spans := exporter.GetSpans() + val, ok := findSpanAttr(spans, trace.AuthResultAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "DENY") + + val, ok = findSpanAttr(spans, trace.AuthDenialReasonAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "Service not found") + + _, ok = findSpanAttr(spans, trace.AuthConfigNameAttr) + assert.Assert(t, !ok, "auth_config.name should not be set when service not found") +} + +func TestCheckSpanAttributes_InvalidRequest(t *testing.T) { + exporter := setupTestTracer(t) + + service := &AuthService{Index: index.NewIndex()} + resp, err := service.Check(context.Background(), &envoy_auth.CheckRequest{}) + assert.NilError(t, err) + assert.Equal(t, resp.Status.Code, int32(rpc.INVALID_ARGUMENT)) + + spans := exporter.GetSpans() + val, ok := findSpanAttr(spans, trace.AuthResultAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "DENY") + + val, ok = findSpanAttr(spans, trace.AuthResponseCodeAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "INVALID_ARGUMENT") +} + func mockAnonymousAccessAuthConfig() *evaluators.AuthConfig { authCred := auth.NewAuthCredential("", "") identityConfig := &evaluators.IdentityConfig{Name: "anonymous", Noop: &identity.Noop{AuthCredentials: authCred}} diff --git a/pkg/trace/trace.go b/pkg/trace/trace.go index 7a43b7c50..ef51189aa 100644 --- a/pkg/trace/trace.go +++ b/pkg/trace/trace.go @@ -11,6 +11,14 @@ import ( const ( AuthorinoRequestIdAttr = "authorino.request_id" PropagationRequestIdAttr = "guid:x-request-id" + + AuthResultAttr = "authorino.auth.result" + AuthResponseCodeAttr = "authorino.auth.response_code" + AuthDenialReasonAttr = "authorino.auth.denial_reason" + AuthConfigNameAttr = "authorino.auth_config.name" + AuthConfigNamespaceAttr = "authorino.auth_config.namespace" + IdentitySourceAttr = "authorino.identity.source" + IdentityTypeAttr = "authorino.identity.type" ) func NewSpan(parentContext context.Context, tracerName, spanName string, options ...otel_trace.SpanStartOption) (context.Context, otel_trace.Span) { From ea4a1f8a1b35e5cdd8960ba939a4a43a13511b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Sanz=20G=C3=B3miz?= Date: Tue, 11 Aug 2026 15:27:42 +0200 Subject: [PATCH 2/2] test: complete span attribute assertions and handle shutdown error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add missing assertions to the four span attribute tests: IdentityTypeAttr in AllowedRequest, AuthConfigNamespaceAttr in DeniedRequest, AuthResponseCodeAttr in ServiceNotFound, and AuthDenialReasonAttr in InvalidRequest. Handle the error returned by TracerProvider.Shutdown in the test cleanup callback. Signed-off-by: Adrian Sanz Gomiz Signed-off-by: Adrián Sanz Gómiz --- pkg/service/auth_test.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/service/auth_test.go b/pkg/service/auth_test.go index 47103b143..d8e73d41f 100644 --- a/pkg/service/auth_test.go +++ b/pkg/service/auth_test.go @@ -389,7 +389,9 @@ func setupTestTracer(t *testing.T) *tracetest.InMemoryExporter { otel.SetTracerProvider(tp) t.Cleanup(func() { otel.SetTracerProvider(prev) - tp.Shutdown(context.Background()) + if err := tp.Shutdown(context.Background()); err != nil { + t.Errorf("failed to shutdown TracerProvider: %v", err) + } }) return exporter } @@ -447,6 +449,10 @@ func TestCheckSpanAttributes_AllowedRequest(t *testing.T) { assert.Assert(t, ok) assert.Equal(t, val.AsString(), "anonymous") + val, ok = findSpanAttr(spans, trace.IdentityTypeAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "IDENTITY_NOOP") + _, ok = findSpanAttr(spans, trace.AuthDenialReasonAttr) assert.Assert(t, !ok, "denial reason should not be set on allowed request") } @@ -495,6 +501,10 @@ func TestCheckSpanAttributes_DeniedRequest(t *testing.T) { val, ok = findSpanAttr(spans, trace.AuthConfigNameAttr) assert.Assert(t, ok) assert.Equal(t, val.AsString(), "protected-api") + + val, ok = findSpanAttr(spans, trace.AuthConfigNamespaceAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "prod") } func TestCheckSpanAttributes_ServiceNotFound(t *testing.T) { @@ -521,6 +531,10 @@ func TestCheckSpanAttributes_ServiceNotFound(t *testing.T) { assert.Assert(t, ok) assert.Equal(t, val.AsString(), "DENY") + val, ok = findSpanAttr(spans, trace.AuthResponseCodeAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "NOT_FOUND") + val, ok = findSpanAttr(spans, trace.AuthDenialReasonAttr) assert.Assert(t, ok) assert.Equal(t, val.AsString(), "Service not found") @@ -545,6 +559,10 @@ func TestCheckSpanAttributes_InvalidRequest(t *testing.T) { val, ok = findSpanAttr(spans, trace.AuthResponseCodeAttr) assert.Assert(t, ok) assert.Equal(t, val.AsString(), "INVALID_ARGUMENT") + + val, ok = findSpanAttr(spans, trace.AuthDenialReasonAttr) + assert.Assert(t, ok) + assert.Equal(t, val.AsString(), "Invalid request") } func mockAnonymousAccessAuthConfig() *evaluators.AuthConfig {