Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
go.uber.org/mock v0.5.2
go.uber.org/zap v1.25.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.22.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -666,8 +666,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
Expand Down
57 changes: 37 additions & 20 deletions pkg/evaluators/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"fmt"

"golang.org/x/sync/singleflight"

"github.com/kuadrant/authorino/pkg/auth"
"github.com/kuadrant/authorino/pkg/evaluators/authorization"
"github.com/kuadrant/authorino/pkg/jsonexp"
Expand All @@ -23,6 +25,7 @@ type AuthorizationConfig struct {
Conditions jsonexp.Expression `yaml:"conditions"`
Metrics bool `yaml:"metrics"`
Cache EvaluatorCache
flight singleflight.Group

OPA *authorization.OPA `yaml:"opa,omitempty"`
JSON *authorization.JSONPatternMatching `yaml:"json,omitempty"`
Expand All @@ -48,35 +51,49 @@ func (config *AuthorizationConfig) GetAuthConfigEvaluator() auth.AuthConfigEvalu
// impl:AuthConfigEvaluator

func (config *AuthorizationConfig) Call(pipeline auth.AuthPipeline, ctx context.Context) (interface{}, error) {
if evaluator := config.GetAuthConfigEvaluator(); evaluator == nil {
evaluator := config.GetAuthConfigEvaluator()
if evaluator == nil {
return nil, fmt.Errorf("invalid authorization config")
} else {
logger := log.FromContext(ctx).WithName("authorization")
}

cache := config.Cache
var cacheKey interface{}
logger := log.FromContext(ctx).WithName("authorization")

if cache != nil {
cacheKey, _ = cache.ResolveKeyFor(pipeline.GetAuthorizationJSON())
if cacheKey != nil {
if cachedObj, err := cache.Get(cacheKey); err != nil {
logger.V(1).Error(err, "failed to retrieve data from the cache")
} else if cachedObj != nil {
return cachedObj, nil
}
cache := config.Cache
var cacheKey interface{}

if cache != nil {
cacheKey, _ = cache.ResolveKeyFor(pipeline.GetAuthorizationJSON())
if cacheKey != nil {
if cachedObj, err := cache.Get(cacheKey); err != nil {
logger.V(1).Error(err, "failed to retrieve data from the cache")
} else if cachedObj != nil {
return cachedObj, nil
}
}
}

obj, err := evaluator.Call(pipeline, log.IntoContext(ctx, logger))

if err == nil && cacheKey != nil {
if err := cache.Set(cacheKey, obj); err != nil {
logger.V(1).Info("unable to store data in the cache", "err", err)
if cache != nil && cacheKey != nil {
flightKey := fmt.Sprintf("%s/%v", config.Name, cacheKey)
result, err, shared := config.flight.Do(flightKey, func() (interface{}, error) {
if cachedObj, _ := cache.Get(cacheKey); cachedObj != nil {
return cachedObj, nil
}
obj, err := evaluator.Call(pipeline, log.IntoContext(ctx, logger))
if err == nil {
if setErr := cache.Set(cacheKey, obj); setErr != nil {
logger.V(1).Info("unable to store data in the cache", "err", setErr)
}
}
return obj, err
})
if shared {
logger.V(1).Info("singleflight: coalesced duplicate authorization call", "key", flightKey)
}

return obj, err
return result, err
Comment on lines +77 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== files ==\n'
git ls-files 'pkg/evaluators/*.go' | sed -n '1,120p'

printf '\n== evaluation snippets ==\n'
for f in pkg/evaluators/authorization.go pkg/evaluators/metadata.go; do
  if [ -f "$f" ]; then
    echo "--- $f"
    nl -ba "$f" | sed -n '1,130p'
  fi
done

printf '\n== singleflight call sites/context related search ==\n'
rg -n "singleflight|flight:|Do\\(|WithContext|evaluator.Call|cache\\.Get|cache\\.Set|context\\.WithCancel|context\\.WithTimeout" pkg/evaluators || true

printf '\n== module deps for singleflight package ==\n'
rg -n "golang.org/x/sync|singleflight" go.mod go.sum 2>/dev/null || true

printf '\n== local golang.org/x/sync/source check if present in vendor/cache? ==\n'
go env GOPATH GOMODCACHE 2>/dev/null || true
find "$HOME/go/pkg/mod/golang.org/x/sync"* -name 'singleflight*.go' 2>/dev/null | head -20 || true

Repository: Kuadrant/authorino

Length of output: 2395


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== authorization.go relevant lines ==\n'
awk '{printf "%6d\t%s\n", NR, $0}' pkg/evaluators/authorization.go | sed -n '1,130p'

printf '\n== metadata.go relevant lines ==\n'
awk '{printf "%6d\t%s\n", NR, $0}' pkg/evaluators/metadata.go | sed -n '1,120p'

printf '\n== singleflight import and call sites ==\n'
rg -n "singleflight|flight:|Do\\(|WithContext|evaluator.Call|cache\\.Get|cache\\.Set|context\\.WithCancel|context\\.WithTimeout" pkg/evaluators || true

printf '\n== go.mod relevant deps ==\n'
awk '{if($0 ~ /golang.org|x\/sync|singleflight/ || NR<=200) print}' go.mod 2>/dev/null || true

printf '\n== candidate singleflight source if cached ==\n'
gopath=$(go env GOPATH 2>/dev/null || true)
modcache=$(go env GOMODCACHE 2>/dev/null || true)
printf "GOPATH=%s\nGOMODCACHE=%s\n" "$gopath" "$modcache"
find "${gopath:-/tmp/gopath}/pkg/mod/golang.org/x/sync"* "${modcache:-/tmp/modcache}/golang.org/x/sync"* -type f \( -name 'singleflight*.go' -o -name 'go.mod' \) 2>/dev/null | head -20 || true

Repository: Kuadrant/authorino

Length of output: 20298


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== cached singleflight implementation ==\n'
awk '{printf "%6d\t%s\n", NR, $0}' /home/jailuser/go/pkg/mod/golang.org/x/sync@v0.22.0/singleflight/singleflight.go | sed -n '1,220p'

printf '\n== cached singleflight tests around cancellation/DuplicateCalls ==\n'
rg -n "Cancel|Context|WithCancel|DuplicateCalls|shared|Wait|Do(" /home/jailuser/go/pkg/mod/golang.org/x/sync@v0.22.0/singleflight/singleflight_test.go -C 4 || true

Repository: Kuadrant/authorino

Length of output: 7291


🌐 Web query:

golang.org/x/sync/singleflight Group Do Wait duplicate calls context cancellation

💡 Result:

The standard golang.org/x/sync/singleflight package does not provide native, integrated support for context cancellation within its Group.Do method [1][2]. Key behavior regarding duplicate calls and context: 1. Blocking Nature: The Do method is synchronous and blocks until the primary execution finishes [3][4]. If a caller's context is cancelled while it is waiting for a result, the Do method itself does not observe or react to that cancellation; the caller will remain blocked until the underlying function completes or panics [1][5]. 2. Risk of Propagation: If the function passed to Do uses a context derived from one of the callers, the cancellation of that specific caller can cause the entire shared operation to fail for all other waiting callers [1][5]. It is a common best practice to use context.WithoutCancel(ctx) or a background context to detach the shared work from the lifetime of any individual request [1][5]. 3. Alternatives for Cancellation: - DoChan: If you need to respect context cancellation for the waiting period, you can use DoChan [1][6]. This returns a channel, allowing you to use a select statement to wait for either the result or the cancellation of your context [1]: ch:= group.DoChan(key, fn) select { case <-ctx.Done: return nil, ctx.Err case res:= <-ch: return res.Val, res.Err } - External Libraries: If your application requires more sophisticated, context-aware duplicate suppression (e.g., where the underlying work's context is tied to the collective state of waiting callers), consider third-party implementations like resenje.org/singleflight, which are designed to handle context propagation and cancellation more gracefully [7][8][9].

Citations:


Preserve each caller context while joining a flight.

singleflight.Group.Do does not accept a context, so callers wait unconditionally while an in-flight evaluator.Call can be tied to the first caller’s request context. Use a context-aware flight path and a shared evaluation context with an explicit timeout policy in both:

  • pkg/evaluators/authorization.go:77-92
  • pkg/evaluators/metadata.go:73-88
📍 Affects 2 files
  • pkg/evaluators/authorization.go#L77-L92 (this comment)
  • pkg/evaluators/metadata.go#L73-L88
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/evaluators/authorization.go` around lines 77 - 92, The flight paths in
pkg/evaluators/authorization.go:77-92 and pkg/evaluators/metadata.go:73-88 must
preserve each caller’s context while coalescing requests. Update the
authorization and metadata evaluator flows to use a context-aware singleflight
mechanism, derive a shared evaluation context with an explicit timeout policy
for the in-flight evaluator call, and ensure each caller can stop waiting when
its own context is canceled.

}

obj, err := evaluator.Call(pipeline, log.IntoContext(ctx, logger))
return obj, err
}

// impl:NamedEvaluator
Expand Down
61 changes: 39 additions & 22 deletions pkg/evaluators/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"fmt"

"golang.org/x/sync/singleflight"

"github.com/kuadrant/authorino/pkg/auth"
"github.com/kuadrant/authorino/pkg/evaluators/metadata"
"github.com/kuadrant/authorino/pkg/jsonexp"
Expand All @@ -22,6 +24,7 @@ type MetadataConfig struct {
Conditions jsonexp.Expression `yaml:"conditions"`
Metrics bool `yaml:"metrics"`
Cache EvaluatorCache
flight singleflight.Group

UserInfo *metadata.UserInfo `yaml:"userinfo,omitempty"`
UMA *metadata.UMA `yaml:"uma,omitempty"`
Expand All @@ -44,35 +47,49 @@ func (config *MetadataConfig) GetAuthConfigEvaluator() auth.AuthConfigEvaluator
// impl:AuthConfigEvaluator

func (config *MetadataConfig) Call(pipeline auth.AuthPipeline, ctx context.Context) (interface{}, error) {
if evaluator := config.GetAuthConfigEvaluator(); evaluator == nil {
evaluator := config.GetAuthConfigEvaluator()
if evaluator == nil {
return nil, fmt.Errorf("invalid metadata config")
} else {
logger := log.FromContext(ctx).WithName("metadata").WithValues("config", config.Name)

cache := config.Cache
var cacheKey interface{}

if cache != nil {
cacheKey, _ = cache.ResolveKeyFor(pipeline.GetAuthorizationJSON())
if cacheKey != nil {
if cachedObj, err := cache.Get(cacheKey); err != nil {
logger.V(1).Error(err, "failed to retrieve data from the cache")
} else if cachedObj != nil {
return cachedObj, nil
}
}
}
}

logger := log.FromContext(ctx).WithName("metadata").WithValues("config", config.Name)

obj, err := evaluator.Call(pipeline, log.IntoContext(ctx, logger))
cache := config.Cache
var cacheKey interface{}

if err == nil && cacheKey != nil {
if err := cache.Set(cacheKey, obj); err != nil {
logger.V(1).Info("unable to store data in the cache", "err", err)
if cache != nil {
cacheKey, _ = cache.ResolveKeyFor(pipeline.GetAuthorizationJSON())
if cacheKey != nil {
if cachedObj, err := cache.Get(cacheKey); err != nil {
logger.V(1).Error(err, "failed to retrieve data from the cache")
} else if cachedObj != nil {
return cachedObj, nil
}
}
}

return obj, err
if cache != nil && cacheKey != nil {
flightKey := fmt.Sprintf("%s/%v", config.Name, cacheKey)
result, err, shared := config.flight.Do(flightKey, func() (interface{}, error) {
if cachedObj, _ := cache.Get(cacheKey); cachedObj != nil {
return cachedObj, nil
}
obj, err := evaluator.Call(pipeline, log.IntoContext(ctx, logger))
if err == nil {
if setErr := cache.Set(cacheKey, obj); setErr != nil {
logger.V(1).Info("unable to store data in the cache", "err", setErr)
}
}
return obj, err
})
if shared {
logger.V(1).Info("singleflight: coalesced duplicate metadata call", "key", flightKey)
}
return result, err
}

obj, err := evaluator.Call(pipeline, log.IntoContext(ctx, logger))
return obj, err
}

// impl:NamedEvaluator
Expand Down
193 changes: 193 additions & 0 deletions pkg/evaluators/metadata_singleflight_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package evaluators

import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"

"github.com/kuadrant/authorino/pkg/auth"
mock_auth "github.com/kuadrant/authorino/pkg/auth/mocks"
"github.com/kuadrant/authorino/pkg/evaluators/metadata"
"github.com/kuadrant/authorino/pkg/httptest"
"github.com/kuadrant/authorino/pkg/json"

"go.uber.org/mock/gomock"
"gotest.tools/assert"
)

const singleflightTestHost = "127.0.0.1:9018"

func TestMetadataSingleflight_ConcurrentMissesCoalesce(t *testing.T) {
var callCount atomic.Int32

server := httptest.NewHttpServerMock(singleflightTestHost, map[string]httptest.HttpServerMockResponseFunc{
"/metadata": func() httptest.HttpServerMockResponse {
callCount.Add(1)
return httptest.HttpServerMockResponse{
Status: 200,
Headers: map[string]string{"Content-Type": "application/json"},
Body: `{"valid":true}`,
}
},
})
defer server.Close()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

evaluator := &metadata.GenericHttp{
Endpoint: fmt.Sprintf("http://%s/metadata", singleflightTestHost),
Method: "GET",
AuthCredentials: auth.NewAuthCredential("", "authorization_header"),
}

cache := NewEvaluatorCache(&json.JSONValue{Static: "test-key"}, 60)

config := MetadataConfig{
Name: "test-metadata",
Cache: cache,
GenericHTTP: evaluator,
}
defer config.Clean(context.Background())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Handle each returned error.

errcheck reports every discarded Clean and Call result here. A failed concurrent Call can currently pass TestMetadataSingleflight_DifferentKeysDoNotCoalesce because it asserts only the HTTP call count.

Use t.Cleanup to assert cleanup errors. Store each concurrent call error and assert it after wg.Wait.

Also applies to: 123-124, 136-140, 181-181

🧰 Tools
🪛 golangci-lint (2.12.2)

[error] 53-53: Error return value of config.Clean is not checked

(errcheck)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/evaluators/metadata_singleflight_test.go` at line 53, Update the tests
around config cleanup and concurrent calls: register cleanup through t.Cleanup
and assert any returned Clean error, and capture each Call error from the
goroutines before waiting on the WaitGroup, then assert all captured errors
after wg.Wait. Apply the same error handling to the additional Clean and Call
sites identified in the diff, including the different-keys singleflight test.

Source: Linters/SAST tools


const concurrency = 50
var wg sync.WaitGroup
wg.Add(concurrency)
errors := make([]error, concurrency)
results := make([]interface{}, concurrency)

pipelineMock := mock_auth.NewMockAuthPipeline(ctrl)
pipelineMock.EXPECT().GetAuthorizationJSON().AnyTimes().Return(`{}`)

for i := 0; i < concurrency; i++ {
go func(idx int) {
defer wg.Done()
results[idx], errors[idx] = config.Call(pipelineMock, context.Background())
}(i)
}

wg.Wait()

for i, err := range errors {
assert.NilError(t, err, "goroutine %d returned error", i)
}

for i, res := range results {
assert.Assert(t, res != nil, "goroutine %d returned nil result", i)
}

actual := callCount.Load()
assert.Assert(t, actual == 1, "expected 1 HTTP call, got %d (singleflight did not coalesce)", actual)
Comment on lines +55 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make the tests force overlapping cache misses.

These tests can pass if singleflight is removed. The first test does not synchronise concurrent cache misses. The second test uses separate MetadataConfig instances, so each call uses a separate flight. The third test has no concurrent call.

Use a barrier-controlled cache miss. Test two keys through one MetadataConfig. Assert one evaluation for the same key and two evaluations for different keys.

Also applies to: 110-146, 186-192

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/evaluators/metadata_singleflight_test.go` around lines 55 - 82, Update
the metadata singleflight tests to synchronize goroutines at a barrier before
invoking concurrent cache misses. Use one shared MetadataConfig for same-key and
different-key cases, asserting one evaluation for repeated keys and two
evaluations for distinct keys; ensure the third test also performs overlapping
calls rather than a sequential invocation.

}

func TestMetadataSingleflight_DifferentKeysDoNotCoalesce(t *testing.T) {
var callCount atomic.Int32

const testHost2 = "127.0.0.1:9019"
server := httptest.NewHttpServerMock(testHost2, map[string]httptest.HttpServerMockResponseFunc{
"/metadata": func() httptest.HttpServerMockResponse {
callCount.Add(1)
return httptest.HttpServerMockResponse{
Status: 200,
Headers: map[string]string{"Content-Type": "application/json"},
Body: `{"valid":true}`,
}
},
})
defer server.Close()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

evaluator := &metadata.GenericHttp{
Endpoint: fmt.Sprintf("http://%s/metadata", testHost2),
Method: "GET",
AuthCredentials: auth.NewAuthCredential("", "authorization_header"),
}

cacheA := NewEvaluatorCache(&json.JSONValue{Static: "key-A"}, 60)
cacheB := NewEvaluatorCache(&json.JSONValue{Static: "key-B"}, 60)

configA := MetadataConfig{
Name: "test-metadata",
Cache: cacheA,
GenericHTTP: evaluator,
}
configB := MetadataConfig{
Name: "test-metadata",
Cache: cacheB,
GenericHTTP: evaluator,
}
defer configA.Clean(context.Background())
defer configB.Clean(context.Background())

pipelineMockA := mock_auth.NewMockAuthPipeline(ctrl)
pipelineMockA.EXPECT().GetAuthorizationJSON().AnyTimes().Return(`{}`)
pipelineMockB := mock_auth.NewMockAuthPipeline(ctrl)
pipelineMockB.EXPECT().GetAuthorizationJSON().AnyTimes().Return(`{}`)

var wg sync.WaitGroup
wg.Add(2)

go func() {
defer wg.Done()
configA.Call(pipelineMockA, context.Background())
}()
go func() {
defer wg.Done()
configB.Call(pipelineMockB, context.Background())
}()

wg.Wait()

actual := callCount.Load()
assert.Assert(t, actual == 2, "expected 2 HTTP calls (one per distinct cache), got %d", actual)
}

func TestMetadataSingleflight_CachePopulatedAfterCoalescedCall(t *testing.T) {
var callCount atomic.Int32

const testHost3 = "127.0.0.1:9020"
server := httptest.NewHttpServerMock(testHost3, map[string]httptest.HttpServerMockResponseFunc{
"/metadata": func() httptest.HttpServerMockResponse {
callCount.Add(1)
return httptest.HttpServerMockResponse{
Status: 200,
Headers: map[string]string{"Content-Type": "application/json"},
Body: `{"cached":"value"}`,
}
},
})
defer server.Close()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

evaluator := &metadata.GenericHttp{
Endpoint: fmt.Sprintf("http://%s/metadata", testHost3),
Method: "GET",
AuthCredentials: auth.NewAuthCredential("", "authorization_header"),
}

cache := NewEvaluatorCache(&json.JSONValue{Static: "populate-key"}, 60)

config := MetadataConfig{
Name: "test-metadata-populate",
Cache: cache,
GenericHTTP: evaluator,
}
defer config.Clean(context.Background())

pipelineMock := mock_auth.NewMockAuthPipeline(ctrl)
pipelineMock.EXPECT().GetAuthorizationJSON().AnyTimes().Return(`{}`)

_, err := config.Call(pipelineMock, context.Background())
assert.NilError(t, err)
assert.Assert(t, callCount.Load() == 1, "first call should invoke evaluator")

_, err = config.Call(pipelineMock, context.Background())
assert.NilError(t, err)
assert.Assert(t, callCount.Load() == 1, "second call should hit cache, not invoke evaluator again")
}