High severityNVD Advisory· Published Apr 16, 2024· Updated Aug 2, 2024
OpenFGA Authorization Bypass
CVE-2024-31452
Description
OpenFGA is a high-performance and flexible authorization/permission engine. Some end users of OpenFGA v1.5.0 or later are vulnerable to authorization bypass when calling Check or ListObjects APIs. You are very likely affected if your model involves exclusion (e.g. a but not b) or intersection (e.g. a and b). This vulnerability is fixed in v1.5.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/openfga/openfgaGo | >= 1.5.0, < 1.5.3 | 1.5.3 |
Affected products
1Patches
1b6a6d99b2bdbMerge pull request from GHSA-8cph-m685-6v6r
11 files changed · +1253 −281
assets/tests/consolidated_1_1_tests.yaml+361 −0 modified@@ -5295,3 +5295,364 @@ tests: relation: viewer expectation: - document:1 + - name: cycle_or_cycle_return_false + stages: + - model: | + model + schema 1.1 + type user + + type document + relations + define editor: [user, document#viewer] + define viewer: [document#editor] or editor + tuples: + - user: document:1#viewer + relation: editor + object: document:1 + - user: document:1#editor + relation: viewer + object: document:1 + checkAssertions: + - tuple: + object: document:1 + relation: viewer + user: user:jon + expectation: false + listObjectsAssertions: + - request: + user: user:jon + type: document + relation: viewer + expectation: + - name: immediate_cycle_through_computed_userset + stages: + - model: | + model + schema 1.1 + type user + + type document + relations + define editor: [user, document#viewer] + define viewer: editor + tuples: + - user: document:1#viewer + relation: editor + object: document:1 + checkAssertions: + - tuple: + object: document:1 + relation: viewer + user: user:jon + expectation: false + listObjectsAssertions: + - request: + user: user:jon + type: document + relation: viewer + expectation: + - name: immediate_cycle_through_computed_userset + stages: + - model: | + model + schema 1.1 + type user + + type document + relations + define editor: [user, document#viewer] + define viewer: editor + tuples: + - user: document:1#viewer + relation: editor + object: document:1 + checkAssertions: + - tuple: + object: document:1 + relation: viewer + user: user:jon + expectation: false + listObjectsAssertions: + - request: + user: user:jon + type: document + relation: viewer + expectation: + - name: true_butnot_cycle_return_false + stages: + - model: | + model + schema 1.1 + type user + + type document + relations + define restricted: [user, document#viewer] + define viewer: [user] but not restricted + tuples: + - user: user:jon + relation: viewer + object: document:1 + - user: document:1#viewer + relation: restricted + object: document:1 + checkAssertions: + - tuple: + object: document:1 + relation: viewer + user: user:jon + expectation: false + listObjectsAssertions: + - request: + user: user:jon + type: document + relation: viewer + expectation: + - name: cycle_and_cycle_return_false + stages: + - model: | + model + schema 1.1 + + type user + + type document + relations + define editor: [user, document#viewer] + define viewer: [user, document#editor] and editor + tuples: + - user: document:1#editor + relation: viewer + object: document:1 + - user: document:1#viewer + relation: editor + object: document:1 + checkAssertions: + - tuple: + object: document:1 + relation: viewer + user: user:jon + expectation: false + listObjectsAssertions: + - request: + user: user:jon + type: document + relation: viewer + expectation: + - name: cycle_and_true_return_false + stages: + - model: | + model + schema 1.1 + + type user + + type document + relations + define allowed: [user] + define viewer: [user, document#viewer] and allowed + tuples: + - user: user:jon + relation: allowed + object: document:1 + - user: document:1#viewer + relation: viewer + object: document:1 + checkAssertions: + - tuple: + object: document:1 + relation: viewer + user: user:jon + expectation: false + listObjectsAssertions: + - request: + user: user:jon + type: document + relation: viewer + expectation: + - name: immediate_cycle_return_false + stages: + - model: | + model + schema 1.1 + + type user + + type document + relations + define viewer: [user, document#viewer] + tuples: + - user: document:1#viewer + relation: viewer + object: document:1 + checkAssertions: + - tuple: + object: document:1 + relation: viewer + user: user:jon + expectation: false + listObjectsAssertions: + - request: + user: user:jon + type: document + relation: viewer + expectation: + - name: cycle_butnot_false_return_false + stages: + - model: | + model + schema 1.1 + + type user + + type document + relations + define restricted: [user] + define viewer: [user, document#viewer] but not restricted + tuples: + - user: document:1#viewer + relation: viewer + object: document:1 + checkAssertions: + - tuple: + object: document:1 + relation: viewer + user: user:jon + expectation: false + listObjectsAssertions: + - request: + user: user:jon + type: document + relation: viewer + expectation: + - name: false_butnot_cycle_return_false + stages: + - model: | + model + schema 1.1 + + type user + + type document + relations + define restricted: [user, document#viewer] + define viewer: [user] but not restricted + tuples: + - user: document:1#viewer + relation: restricted + object: document:1 + checkAssertions: + - tuple: + object: document:1 + relation: viewer + user: user:jon + expectation: false + listObjectsAssertions: + - request: + user: user:jon + type: document + relation: viewer + expectation: + - name: err_and_err_return_err + stages: + - model: | + model + schema 1.1 + type user + type resource + relations + define a1: a2 + define a2: a3 + define a3: a4 + define a4: a5 + define a5: a6 + define a6: a7 + define a7: a8 + define a8: a9 + define a9: a10 + define a10: a11 + define a11: a12 + define a12: a13 + define a13: a14 + define a14: a15 + define a15: a16 + define a16: a17 + define a17: a18 + define a18: a19 + define a19: a20 + define a20: a21 + define a21: a22 + define a22: a23 + define a23: a24 + define a24: a25 + define a25: a26 + define a26: [user] + define can_view: a1 and a1 + tuples: + - object: resource:abc + relation: a26 + user: user:maria + checkAssertions: + - tuple: + object: resource:abc + relation: can_view + user: user:maria + errorCode: 2002 + listObjectsAssertions: + - request: + type: resource + relation: can_view + user: user:maria + errorCode: 2002 + - name: err_and_true_return_err + stages: + - model: | + model + schema 1.1 + type user + type resource + relations + define a1: a2 + define a2: a3 + define a3: a4 + define a4: a5 + define a5: a6 + define a6: a7 + define a7: a8 + define a8: a9 + define a9: a10 + define a10: a11 + define a11: a12 + define a12: a13 + define a13: a14 + define a14: a15 + define a15: a16 + define a16: a17 + define a17: a18 + define a18: a19 + define a19: a20 + define a20: a21 + define a21: a22 + define a22: a23 + define a23: a24 + define a24: a25 + define a25: a26 + define a26: [user] + define can_view: a1 and a26 + tuples: + - object: resource:abc + relation: a26 + user: user:maria + checkAssertions: + - tuple: + object: resource:abc + relation: can_view + user: user:maria + errorCode: 2002 + listObjectsAssertions: + - request: + type: resource + relation: can_view + user: user:maria + errorCode: 2002 \ No newline at end of file
internal/graph/cached_resolver.go+12 −27 modified@@ -39,33 +39,11 @@ var ( }) ) -// CachedResolveCheckResponse is very similar to ResolveCheckResponse except we -// do not store the ResolutionData. This is due to the fact that the resolution metadata -// will be incorrect as data is served from cache instead of actual database read. -type CachedResolveCheckResponse struct { - Allowed bool -} - -func (c *CachedResolveCheckResponse) convertToResolveCheckResponse() *ResolveCheckResponse { - return &ResolveCheckResponse{ - Allowed: c.Allowed, - ResolutionMetadata: &ResolveCheckResponseMetadata{ - DatastoreQueryCount: 0, - }, - } -} - -func newCachedResolveCheckResponse(r *ResolveCheckResponse) *CachedResolveCheckResponse { - return &CachedResolveCheckResponse{ - Allowed: r.Allowed, - } -} - // CachedCheckResolver attempts to resolve check sub-problems via prior computations before // delegating the request to some underlying CheckResolver. type CachedCheckResolver struct { delegate CheckResolver - cache *ccache.Cache[*CachedResolveCheckResponse] + cache *ccache.Cache[*ResolveCheckResponse] maxCacheSize int64 cacheTTL time.Duration logger logger.Logger @@ -98,7 +76,7 @@ func WithCacheTTL(ttl time.Duration) CachedCheckResolverOpt { // WithExistingCache sets the cache to the specified cache. // Note that the original cache will not be stopped as it may still be used by others. It is up to the caller // to check whether the original cache should be stopped. -func WithExistingCache(cache *ccache.Cache[*CachedResolveCheckResponse]) CachedCheckResolverOpt { +func WithExistingCache(cache *ccache.Cache[*ResolveCheckResponse]) CachedCheckResolverOpt { return func(ccr *CachedCheckResolver) { ccr.cache = cache } @@ -131,7 +109,7 @@ func NewCachedCheckResolver(opts ...CachedCheckResolverOpt) *CachedCheckResolver if checker.cache == nil { checker.allocatedCache = true checker.cache = ccache.New( - ccache.Configure[*CachedResolveCheckResponse]().MaxSize(checker.maxCacheSize), + ccache.Configure[*ResolveCheckResponse]().MaxSize(checker.maxCacheSize), ) } @@ -179,7 +157,8 @@ func (c *CachedCheckResolver) ResolveCheck( if cachedResp != nil && !cachedResp.Expired() { checkCacheHitCounter.Inc() span.SetAttributes(attribute.Bool("is_cached", true)) - return cachedResp.Value().convertToResolveCheckResponse(), nil + + return cachedResp.Value(), nil } span.SetAttributes(attribute.Bool("is_cached", false)) @@ -189,7 +168,13 @@ func (c *CachedCheckResolver) ResolveCheck( return nil, err } - c.cache.Set(cacheKey, newCachedResolveCheckResponse(resp), c.cacheTTL) + // the cached subproblem's resolution metadata doesn't necessarily reflect + // the actual number of database reads for the inflight request, so set it + // to 0 so it doesn't bias the resolution metadata negatively + clonedResp := CloneResolveCheckResponse(resp) + clonedResp.ResolutionMetadata.DatastoreQueryCount = 0 + + c.cache.Set(cacheKey, clonedResp, c.cacheTTL) return resp, nil }
internal/graph/cached_resolver_test.go+34 −1 modified@@ -453,6 +453,39 @@ func TestResolveCheckExpired(t *testing.T) { require.NoError(t, err) } +func TestCachedCheckResolver_CycleDetected(t *testing.T) { + cachedCheckResolver := NewCachedCheckResolver() + defer cachedCheckResolver.Close() + + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + + mockCheckResolver := NewMockCheckResolver(mockCtrl) + cachedCheckResolver.SetDelegate(mockCheckResolver) + + mockCheckResolver.EXPECT(). + ResolveCheck(gomock.Any(), gomock.Any()). + Return(&ResolveCheckResponse{ + Allowed: false, + ResolutionMetadata: &ResolveCheckResponseMetadata{ + DatastoreQueryCount: 1, + CycleDetected: true, + }, + }, nil) + + resp, err := cachedCheckResolver.ResolveCheck(context.Background(), &ResolveCheckRequest{}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, uint32(1), resp.GetResolutionMetadata().DatastoreQueryCount) + require.True(t, resp.GetResolutionMetadata().CycleDetected) + + resp, err = cachedCheckResolver.ResolveCheck(context.Background(), &ResolveCheckRequest{}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, uint32(0), resp.GetResolutionMetadata().DatastoreQueryCount) + require.True(t, resp.GetResolutionMetadata().CycleDetected) +} + func TestCachedCheckDatastoreQueryCount(t *testing.T) { t.Parallel() @@ -500,7 +533,7 @@ type document ctx = storage.ContextWithRelationshipTupleReader(ctx, ds) checkCache := ccache.New( - ccache.Configure[*CachedResolveCheckResponse]().MaxSize(100), + ccache.Configure[*ResolveCheckResponse]().MaxSize(100), ) defer checkCache.Stop()
internal/graph/check.go+83 −55 modified@@ -26,10 +26,6 @@ import ( var tracer = otel.Tracer("internal/graph/check") -var ( - ErrCycleDetected = errors.New("a cycle has been detected") -) - type ResolveCheckRequest struct { StoreID string AuthorizationModelID string @@ -56,11 +52,40 @@ func clone(r *ResolveCheckRequest) *ResolveCheckRequest { } } +// CloneResolveCheckResponse clones the provided ResolveCheckResponse. +// +// If 'r' defines a nil ResolutionMetadata then this function returns +// an empty value struct for the resolution metadata instead of nil. +func CloneResolveCheckResponse(r *ResolveCheckResponse) *ResolveCheckResponse { + resolutionMetadata := &ResolveCheckResponseMetadata{ + DatastoreQueryCount: 0, + CycleDetected: false, + } + + if r.GetResolutionMetadata() != nil { + resolutionMetadata.DatastoreQueryCount = r.GetResolutionMetadata().DatastoreQueryCount + resolutionMetadata.CycleDetected = r.GetResolutionMetadata().CycleDetected + } + + return &ResolveCheckResponse{ + Allowed: r.GetAllowed(), + ResolutionMetadata: resolutionMetadata, + } +} + type ResolveCheckResponse struct { Allowed bool ResolutionMetadata *ResolveCheckResponseMetadata } +func (r *ResolveCheckResponse) GetCycleDetected() bool { + if r != nil { + return r.GetResolutionMetadata().CycleDetected + } + + return false +} + func (r *ResolveCheckResponse) GetAllowed() bool { if r != nil { return r.Allowed @@ -286,16 +311,19 @@ func union(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHandle var dbReads uint32 var err error + var cycleDetected bool for i := 0; i < len(handlers); i++ { select { case result := <-resultChan: if result.err != nil { - if errors.Is(result.err, ErrCycleDetected) { - continue - } err = result.err continue } + + if result.resp.GetCycleDetected() { + cycleDetected = true + } + dbReads += result.resp.GetResolutionMetadata().DatastoreQueryCount if result.resp.GetAllowed() { @@ -307,12 +335,17 @@ func union(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHandle } } + if err != nil { + return nil, err + } + return &ResolveCheckResponse{ Allowed: false, ResolutionMetadata: &ResolveCheckResponseMetadata{ DatastoreQueryCount: dbReads, + CycleDetected: cycleDetected, }, - }, err + }, nil } // intersection implements a CheckFuncReducer that requires all of the provided CheckHandlerFunc to resolve @@ -345,23 +378,13 @@ func intersection(ctx context.Context, concurrencyLimit uint32, handlers ...Chec case result := <-resultChan: if result.err != nil { span.RecordError(result.err) - - if errors.Is(result.err, ErrCycleDetected) { - return &ResolveCheckResponse{ - Allowed: false, - ResolutionMetadata: &ResolveCheckResponseMetadata{ - DatastoreQueryCount: dbReads, - }, - }, nil - } - err = errors.Join(err, result.err) continue } dbReads += result.resp.GetResolutionMetadata().DatastoreQueryCount - if !result.resp.GetAllowed() { + if result.resp.GetCycleDetected() || !result.resp.GetAllowed() { result.resp.GetResolutionMetadata().DatastoreQueryCount = dbReads return result.resp, nil } @@ -370,12 +393,17 @@ func intersection(ctx context.Context, concurrencyLimit uint32, handlers ...Chec } } + // all operands are either truthy or we've seen at least one error + if err != nil { + return nil, err + } + return &ResolveCheckResponse{ Allowed: true, ResolutionMetadata: &ResolveCheckResponseMetadata{ DatastoreQueryCount: dbReads, }, - }, err + }, nil } // exclusion implements a CheckFuncReducer that requires a 'base' CheckHandlerFunc to resolve to an allowed @@ -440,22 +468,22 @@ func exclusion(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHa case baseResult := <-baseChan: if baseResult.err != nil { span.RecordError(baseResult.err) - - if errors.Is(baseResult.err, ErrCycleDetected) { - return &ResolveCheckResponse{ - Allowed: false, - ResolutionMetadata: &ResolveCheckResponseMetadata{ - DatastoreQueryCount: dbReads, - }, - }, nil - } - baseErr = baseResult.err continue } dbReads += baseResult.resp.GetResolutionMetadata().DatastoreQueryCount + if baseResult.resp.GetCycleDetected() { + return &ResolveCheckResponse{ + Allowed: false, + ResolutionMetadata: &ResolveCheckResponseMetadata{ + DatastoreQueryCount: dbReads, + CycleDetected: true, + }, + }, nil + } + if !baseResult.resp.GetAllowed() { response.GetResolutionMetadata().DatastoreQueryCount = dbReads return response, nil @@ -464,16 +492,22 @@ func exclusion(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHa case subResult := <-subChan: if subResult.err != nil { span.RecordError(subResult.err) - - if !errors.Is(subResult.err, ErrCycleDetected) { - subErr = subResult.err - } - + subErr = subResult.err continue } dbReads += subResult.resp.GetResolutionMetadata().DatastoreQueryCount + if subResult.resp.GetCycleDetected() { + return &ResolveCheckResponse{ + Allowed: false, + ResolutionMetadata: &ResolveCheckResponseMetadata{ + DatastoreQueryCount: dbReads, + CycleDetected: true, + }, + }, nil + } + if subResult.resp.GetAllowed() { response.GetResolutionMetadata().DatastoreQueryCount = dbReads return response, nil @@ -483,13 +517,13 @@ func exclusion(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHa } } - if baseErr != nil && subErr != nil { - return &ResolveCheckResponse{ - Allowed: false, - ResolutionMetadata: &ResolveCheckResponseMetadata{ - DatastoreQueryCount: 0, - }, - }, errors.Join(baseErr, subErr) + // base is either (true) or error, sub is either (false) or error: + // true, false - true + // true, error - error + // error, false - error + // error, error - error + if baseErr != nil || subErr != nil { + return nil, errors.Join(baseErr, subErr) } return &ResolveCheckResponse{ @@ -515,7 +549,7 @@ func (c *LocalChecker) dispatch(_ context.Context, parentReq *ResolveCheckReques resp, err := c.delegate.ResolveCheck(ctx, childRequest) if err != nil { - return resp, err + return nil, err } return resp, nil } @@ -557,7 +591,7 @@ func (c *LocalChecker) ResolveCheck( return nil, fmt.Errorf("relation '%s' undefined for object type '%s'", relation, objectType) } - resp, err := union(ctx, c.concurrencyLimit, c.checkRewrite(ctx, req, rel.GetRewrite())) + resp, err := c.checkRewrite(ctx, req, rel.GetRewrite())(ctx) if err != nil { telemetry.TraceError(span, err) return nil, err @@ -614,7 +648,7 @@ func (c *LocalChecker) checkDirect(parentctx context.Context, req *ResolveCheckR return response, nil } - return response, err + return nil, err } // filter out invalid tuples yielded by the database query @@ -669,7 +703,7 @@ func (c *LocalChecker) checkDirect(parentctx context.Context, req *ResolveCheckR AllowedUserTypeRestrictions: directlyRelatedUsersetTypes, }) if err != nil { - return response, err + return nil, err } defer iter.Stop() @@ -689,7 +723,7 @@ func (c *LocalChecker) checkDirect(parentctx context.Context, req *ResolveCheckR break } - return response, err + return nil, err } condEvalResult, err := eval.EvaluateTupleCondition(ctx, t, typesys, req.GetContext()) @@ -833,19 +867,13 @@ func (c *LocalChecker) checkTTU(parentctx context.Context, req *ResolveCheckRequ span.SetAttributes(attribute.String("tupleset_relation", fmt.Sprintf("%s#%s", tuple.GetType(object), tuplesetRelation))) span.SetAttributes(attribute.String("computed_relation", computedRelation)) - response := &ResolveCheckResponse{ - Allowed: false, - ResolutionMetadata: &ResolveCheckResponseMetadata{ - DatastoreQueryCount: req.GetRequestMetadata().DatastoreQueryCount + 1, - }, - } iter, err := ds.Read( ctx, req.GetStoreID(), tuple.NewTupleKey(object, tuplesetRelation, ""), ) if err != nil { - return response, err + return nil, err } defer iter.Stop() @@ -865,7 +893,7 @@ func (c *LocalChecker) checkTTU(parentctx context.Context, req *ResolveCheckRequ break } - return response, err + return nil, err } condEvalResult, err := eval.EvaluateTupleCondition(ctx, t, typesys, req.GetContext())
internal/graph/check_test.go+723 −190 modified@@ -2,6 +2,7 @@ package graph import ( "context" + "errors" "fmt" "sync" "testing" @@ -46,7 +47,19 @@ var ( } cyclicErrorHandler = func(context.Context) (*ResolveCheckResponse, error) { - return nil, ErrCycleDetected + return &ResolveCheckResponse{ + Allowed: false, + ResolutionMetadata: &ResolveCheckResponseMetadata{ + DatastoreQueryCount: 1, + CycleDetected: true, + }, + }, nil + } + + simulatedDBErrorMessage = "simulated db error" + + generalErrorHandler = func(context.Context) (*ResolveCheckResponse, error) { + return nil, errors.New(simulatedDBErrorMessage) } ) @@ -77,67 +90,178 @@ func TestExclusionCheckFuncReducer(t *testing.T) { }) }) - t.Run("base_handler_is_falsy_return_allowed:false", func(t *testing.T) { + t.Run("true_butnot_true_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, trueHandler, trueHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1)) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("true_butnot_false_return_true", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, trueHandler, falseHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.GetAllowed()) + require.Equal(t, uint32(1+1), resp.GetResolutionMetadata().DatastoreQueryCount) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("false_butnot_true_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, falseHandler, trueHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1)) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("false_butnot_false_return_false", func(t *testing.T) { resp, err := exclusion(ctx, concurrencyLimit, falseHandler, falseHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1)) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("true_butnot_err_return_err", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, trueHandler, generalErrorHandler) + require.EqualError(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) + + t.Run("true_butnot_errResolutionDepth_return_errResolutionDepth", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, trueHandler, depthExceededHandler) + require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) }) - t.Run("base_handler_is_falsy_subtract_handler_with_error_return_allowed:false", func(t *testing.T) { + t.Run("true_butnot_cycle_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) + }) + + t.Run("false_butnot_err_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, falseHandler, generalErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("false_butnot_errResolutionDepth_return_false", func(t *testing.T) { resp, err := exclusion(ctx, concurrencyLimit, falseHandler, depthExceededHandler) require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("false_butnot_cycle_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2)) }) - t.Run("base_handler_with_error_subtract_handler_is_truthy_return_allowed:false", func(t *testing.T) { + t.Run("err_butnot_true_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, generalErrorHandler, trueHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("errResolutionDepth_butnot_true_return_false", func(t *testing.T) { resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, trueHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2+1)) + require.False(t, resp.GetCycleDetected()) }) - t.Run("base_handler_with_error_subtract_handler_with_error_return_error", func(t *testing.T) { - resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, depthExceededHandler) - require.ErrorIs(t, err, ErrResolutionDepthExceeded) + t.Run("cycle_butnot_true_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler) + require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.Equal(t, uint32(0), resp.GetResolutionMetadata().DatastoreQueryCount) }) - t.Run("base_handler_with_cycle_subtract_handler_is_falsy_return_allowed:false_with_a_nil_error", func(t *testing.T) { + t.Run("err_butnot_false_return_err", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, generalErrorHandler, falseHandler) + require.EqualError(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) + + t.Run("errResolutionDepth_butnot_false_return_errResolutionDepth", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, falseHandler) + require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) + }) + + t.Run("cycle_butnot_false_return_false", func(t *testing.T) { resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, falseHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2+1)) + require.True(t, resp.GetCycleDetected()) }) - t.Run("base_handler_with_cycle_subtract_handler_is_truthy_return_allowed:false_with_a_nil_error", func(t *testing.T) { - resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler) + t.Run("cycle_butnot_err_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, generalErrorHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2+1)) + require.True(t, resp.GetCycleDetected()) }) - t.Run("base_handler_is_falsy_subtract_handler_with_cycle_return_allowed:false_with_a_nil_error", func(t *testing.T) { - resp, err := exclusion(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler) + t.Run("cycle_butnot_errResolutionDepth_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, depthExceededHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2)) + require.True(t, resp.GetCycleDetected()) }) - t.Run("base_handler_is_truthy_subtract_handler_with_cycle_return_allowed:true_with_a_nil_error", func(t *testing.T) { - resp, err := exclusion(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler) + t.Run("err_butnot_cycle_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, generalErrorHandler, cyclicErrorHandler) require.NoError(t, err) - require.True(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2)) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) + }) + + t.Run("errResolutionDepth_butnot_cycle_return_false", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, cyclicErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) }) - t.Run("base_handler_with_cycle_subtract_handler_with_cycle_return_allowed:false_with_a_nil_error", func(t *testing.T) { + t.Run("cycle_butnot_cycle_return_false", func(t *testing.T) { resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, cyclicErrorHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2+2)) + require.True(t, resp.GetCycleDetected()) + }) + + t.Run("err_butnot_err_return_err", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, generalErrorHandler, generalErrorHandler) + require.ErrorContains(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) + + t.Run("errResolutionDepth_butnot_errResolutionDepth_return_errResolutionDepth", func(t *testing.T) { + resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, depthExceededHandler) + require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) }) t.Run("aggregate_truthy_and_falsy_handlers_datastore_query_count", func(t *testing.T) { @@ -176,6 +300,54 @@ func TestExclusionCheckFuncReducer(t *testing.T) { wg.Wait() // just to make sure to avoid test leaks }) + t.Run("return_allowed:false_if_sub_handler_evaluated_before_context_cancelled", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + time.Sleep(10 * time.Millisecond) + cancel() + }() + + resp, err := exclusion(ctx, concurrencyLimit, trueHandler, trueHandler) + require.NoError(t, err) + require.False(t, resp.GetAllowed()) + + wg.Wait() // just to make sure to avoid test leaks + }) + + t.Run("return_allowed:false_if_sub_handler_evaluated_before_base_cancelled", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + var wg sync.WaitGroup + + slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) { + time.Sleep(50 * time.Millisecond) + + return &ResolveCheckResponse{ + Allowed: true, + }, nil + } + + wg.Add(1) + go func() { + defer wg.Done() + time.Sleep(10 * time.Millisecond) + cancel() + }() + + resp, err := exclusion(ctx, concurrencyLimit, slowTrueHandler, trueHandler) + require.NoError(t, err) + require.False(t, resp.GetAllowed()) + + wg.Wait() // just to make sure to avoid test leaks + }) + t.Run("return_allowed:false_if_subtract_handler_evaluated_before_context_cancelled", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -200,14 +372,23 @@ func TestExclusionCheckFuncReducer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) t.Cleanup(cancel) - slowHandler := func(context.Context) (*ResolveCheckResponse, error) { + slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) { time.Sleep(50 * time.Millisecond) - return nil, nil + return &ResolveCheckResponse{ + Allowed: true, + }, nil } - resp, err := exclusion(ctx, concurrencyLimit, slowHandler, slowHandler) + slowFalseHandler := func(context.Context) (*ResolveCheckResponse, error) { + time.Sleep(50 * time.Millisecond) + return &ResolveCheckResponse{ + Allowed: false, + }, nil + } + + resp, err := exclusion(ctx, concurrencyLimit, slowTrueHandler, slowFalseHandler) require.ErrorIs(t, err, context.DeadlineExceeded) - require.False(t, resp.GetAllowed()) + require.Nil(t, resp) }) t.Run("return_error_if_context_cancelled_before_resolution", func(t *testing.T) { @@ -225,12 +406,12 @@ func TestExclusionCheckFuncReducer(t *testing.T) { slowHandler := func(context.Context) (*ResolveCheckResponse, error) { time.Sleep(50 * time.Millisecond) - return nil, nil + return &ResolveCheckResponse{}, nil } resp, err := exclusion(ctx, concurrencyLimit, slowHandler, slowHandler) require.ErrorIs(t, err, context.Canceled) - require.False(t, resp.GetAllowed()) + require.Nil(t, resp) wg.Wait() // just to make sure to avoid test leaks }) @@ -245,70 +426,198 @@ func TestIntersectionCheckFuncReducer(t *testing.T) { concurrencyLimit := uint32(10) - t.Run("no_handlers_return_allowed:false", func(t *testing.T) { + t.Run("no_handlers_return_false", func(t *testing.T) { resp, err := intersection(ctx, concurrencyLimit) require.NoError(t, err) require.False(t, resp.GetAllowed()) require.NotNil(t, resp.GetResolutionMetadata()) + require.False(t, resp.GetCycleDetected()) }) - t.Run("one_handler_is_falsy_return_allowed:false", func(t *testing.T) { + t.Run("false_return_false", func(t *testing.T) { resp, err := intersection(ctx, concurrencyLimit, falseHandler) require.NoError(t, err) require.False(t, resp.GetAllowed()) require.Equal(t, uint32(1), resp.GetResolutionMetadata().DatastoreQueryCount) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("true_and_true_return_true", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, trueHandler, trueHandler) + require.NoError(t, err) + require.True(t, resp.GetAllowed()) + require.Equal(t, uint32(2), resp.GetResolutionMetadata().DatastoreQueryCount) + require.False(t, resp.GetCycleDetected()) }) - t.Run("one_handler_is_truthy_and_others_are_falsy_return_allowed:false", func(t *testing.T) { - resp, err := intersection(ctx, concurrencyLimit, trueHandler, falseHandler, falseHandler) + t.Run("true_and_false_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, trueHandler, falseHandler) require.NoError(t, err) require.False(t, resp.GetAllowed()) require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2)) + require.False(t, resp.GetCycleDetected()) }) - t.Run("all_handlers_are_falsy_return_allowed:false", func(t *testing.T) { - resp, err := intersection(ctx, concurrencyLimit, falseHandler, falseHandler, falseHandler) + t.Run("false_and_true_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, falseHandler, trueHandler) + require.NoError(t, err) + require.False(t, resp.GetAllowed()) + require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2)) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("false_and_false_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, falseHandler, falseHandler) require.NoError(t, err) require.False(t, resp.GetAllowed()) require.Equal(t, uint32(1), resp.GetResolutionMetadata().DatastoreQueryCount) + require.False(t, resp.GetCycleDetected()) }) - t.Run("all_handlers_with_error_return_error", func(t *testing.T) { - _, err := intersection(ctx, concurrencyLimit, depthExceededHandler, depthExceededHandler) + t.Run("true_and_err_return_err", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, trueHandler, generalErrorHandler) + require.EqualError(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) + + t.Run("true_and_errResolutionDepth_return_err", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, trueHandler, depthExceededHandler) require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) + }) + + t.Run("true_and_cycle_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) + }) + + t.Run("false_and_err_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, falseHandler, generalErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("false_and_errResolutionDepth_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, falseHandler, depthExceededHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("false_and_cycle_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + }) + + t.Run("err_and_true_return_err", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, generalErrorHandler, trueHandler) + require.EqualError(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) }) - t.Run("one_handler_returns_error_but_other_handler_is_truthy_return_error_and_allowed:false", func(t *testing.T) { - _, err := intersection(ctx, concurrencyLimit, depthExceededHandler, trueHandler) + t.Run("errResolutionDepth_and_true_return_err", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, depthExceededHandler, trueHandler) require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) }) - t.Run("one_handler_returns_error_but_other_handler_is_falsy_return_allowed:false_with_a_nil_error", func(t *testing.T) { + t.Run("cycle_and_true_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) + }) + + t.Run("err_and_false_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, generalErrorHandler, falseHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("errResolutionDepth_and_false_return_false", func(t *testing.T) { resp, err := intersection(ctx, concurrencyLimit, depthExceededHandler, falseHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2)) + require.False(t, resp.GetCycleDetected()) }) - t.Run("one_handler_errors_with_cycle_but_other_handler_is_falsy_return_allowed:false_with_a_nil_error", func(t *testing.T) { + t.Run("cycle_and_false_return_false", func(t *testing.T) { resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, falseHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2)) }) - t.Run("one_handler_errors_with_cycle_but_other_handler_is_truthy_return_allowed:false_with_a_nil_error", func(t *testing.T) { - resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler) + t.Run("cycle_and_err_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, generalErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) + }) + + t.Run("cycle_and_errResolutionDepth_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, depthExceededHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) + }) + + t.Run("err_and_cycle_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, generalErrorHandler, cyclicErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) + }) + + t.Run("errResolutionDepth_and_cycle_return_false", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, depthExceededHandler, cyclicErrorHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1)) + require.True(t, resp.GetCycleDetected()) }) - t.Run("both_handlers_errors_with_cycle_return_allowed:false_with_a_nil_error", func(t *testing.T) { + t.Run("cycle_and_cycle_return_false", func(t *testing.T) { resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, cyclicErrorHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) - require.Equal(t, uint32(0), resp.GetResolutionMetadata().DatastoreQueryCount) + require.True(t, resp.GetCycleDetected()) + }) + + t.Run("err_and_err_return_err", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, generalErrorHandler, generalErrorHandler) + require.ErrorContains(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) + + t.Run("errResolutionDepth_and_errResolutionDepth_return_errResolutionDepth", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, depthExceededHandler, depthExceededHandler) + require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) + }) + + t.Run("true_and_cycle_and_err_return_err", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler, generalErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) }) t.Run("aggregate_truthy_and_falsy_handlers_datastore_query_count", func(t *testing.T) { @@ -318,6 +627,13 @@ func TestIntersectionCheckFuncReducer(t *testing.T) { require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1)) }) + t.Run("cycle_and_false_reports_correct_datastore_query_count", func(t *testing.T) { + resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, falseHandler) + require.NoError(t, err) + require.False(t, resp.GetAllowed()) + require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1)) + }) + t.Run("return_allowed:false_if_falsy_handler_evaluated_before_context_deadline", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) t.Cleanup(cancel) @@ -327,6 +643,31 @@ func TestIntersectionCheckFuncReducer(t *testing.T) { require.False(t, resp.GetAllowed()) }) + t.Run("return_true_if_truthy_handler_evaluated_before_context_deadline", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + t.Cleanup(cancel) + + resp, err := intersection(ctx, concurrencyLimit, trueHandler) + require.NoError(t, err) + require.True(t, resp.GetAllowed()) + }) + + t.Run("return_error_if_context_deadline_before_truthy_handler", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + t.Cleanup(cancel) + + slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) { + time.Sleep(50 * time.Millisecond) + return &ResolveCheckResponse{ + Allowed: true, + }, nil + } + + resp, err := intersection(ctx, concurrencyLimit, slowTrueHandler) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, resp) + }) + t.Run("return_allowed:false_if_falsy_handler_evaluated_before_context_cancelled", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -351,14 +692,16 @@ func TestIntersectionCheckFuncReducer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) t.Cleanup(cancel) - slowHandler := func(context.Context) (*ResolveCheckResponse, error) { + slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) { time.Sleep(50 * time.Millisecond) - return nil, nil + return &ResolveCheckResponse{ + Allowed: true, + }, nil } - resp, err := intersection(ctx, concurrencyLimit, slowHandler) + resp, err := intersection(ctx, concurrencyLimit, slowTrueHandler) require.ErrorIs(t, err, context.DeadlineExceeded) - require.False(t, resp.GetAllowed()) + require.Nil(t, resp) }) t.Run("return_error_if_context_cancelled_before_resolution", func(t *testing.T) { @@ -374,19 +717,105 @@ func TestIntersectionCheckFuncReducer(t *testing.T) { cancel() }() - slowHandler := func(context.Context) (*ResolveCheckResponse, error) { + slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) { time.Sleep(50 * time.Millisecond) - return nil, nil + return &ResolveCheckResponse{ + Allowed: true, + }, nil } - resp, err := intersection(ctx, concurrencyLimit, slowHandler) + resp, err := intersection(ctx, concurrencyLimit, slowTrueHandler) require.ErrorIs(t, err, context.Canceled) - require.False(t, resp.GetAllowed()) + require.Nil(t, resp) wg.Wait() // just to make sure to avoid test leaks }) } +func TestNonStratifiableCheckQueries(t *testing.T) { + t.Run("example_1", func(t *testing.T) { + ds := memory.New() + + storeID := ulid.Make().String() + + err := ds.Write(context.Background(), storeID, nil, []*openfgav1.TupleKey{ + tuple.NewTupleKey("document:1", "viewer", "user:jon"), + tuple.NewTupleKey("document:1", "restricted", "document:1#viewer"), + }) + require.NoError(t, err) + + checker := NewLocalCheckerWithCycleDetection() + t.Cleanup(checker.Close) + + model := testutils.MustTransformDSLToProtoWithID(`model + schema 1.1 + type user + + + type document + relations + define viewer: [user] but not restricted + define restricted: [user, document#viewer]`) + + ctx := typesystem.ContextWithTypesystem( + context.Background(), + typesystem.New(model), + ) + + ctx = storage.ContextWithRelationshipTupleReader(ctx, ds) + + resp, err := checker.ResolveCheck(ctx, &ResolveCheckRequest{ + StoreID: storeID, + TupleKey: tuple.NewTupleKey("document:1", "viewer", "user:jon"), + RequestMetadata: NewCheckRequestMetadata(10), + }) + require.NoError(t, err) + require.False(t, resp.GetAllowed()) + }) + + t.Run("example_2", func(t *testing.T) { + ds := memory.New() + + storeID := ulid.Make().String() + + err := ds.Write(context.Background(), storeID, nil, []*openfgav1.TupleKey{ + tuple.NewTupleKey("document:1", "viewer", "user:jon"), + tuple.NewTupleKey("document:1", "restrictedb", "document:1#viewer"), + }) + require.NoError(t, err) + + checker := NewLocalCheckerWithCycleDetection() + t.Cleanup(checker.Close) + + model := testutils.MustTransformDSLToProtoWithID(`model + schema 1.1 + type user + + + type document + relations + define viewer: [user] but not restricteda + define restricteda: restrictedb + define restrictedb: [user, document#viewer] + `) + + ctx := typesystem.ContextWithTypesystem( + context.Background(), + typesystem.New(model), + ) + + ctx = storage.ContextWithRelationshipTupleReader(ctx, ds) + + resp, err := checker.ResolveCheck(ctx, &ResolveCheckRequest{ + StoreID: storeID, + TupleKey: tuple.NewTupleKey("document:1", "viewer", "user:jon"), + RequestMetadata: NewCheckRequestMetadata(10), + }) + require.NoError(t, err) + require.False(t, resp.GetAllowed()) + }) +} + func TestResolveCheckDeterministic(t *testing.T) { t.Run("resolution_depth_resolves_deterministically", func(t *testing.T) { t.Parallel() @@ -859,101 +1288,6 @@ type document } } -// TestCheckWithUnexpectedCycle tests the LocalChecker to make sure that if a model includes a cycle -// that should have otherwise been invalid according to the typesystem, then the check resolution will -// consider the cycle a falsey allowed result and not bubble-up a cycle detected error. -func TestCheckWithUnexpectedCycle(t *testing.T) { - ds := memory.New() - defer ds.Close() - - storeID := ulid.Make().String() - - err := ds.Write(context.Background(), storeID, nil, []*openfgav1.TupleKey{ - tuple.NewTupleKey("resource:1", "parent", "resource:1"), - }) - require.NoError(t, err) - - tests := []struct { - name string - model string - tupleKey *openfgav1.TupleKey - }{ - { - name: "test_1", - model: `model - schema 1.1 -type user - -type resource - relations - define x: [user] but not y - define y: [user] but not z - define z: [user] or x`, - tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"), - }, - { - name: "test_2", - model: `model - schema 1.1 -type user - -type resource - relations - define x: [user] and y - define y: [user] and z - define z: [user] or x`, - tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"), - }, - { - name: "test_3", - model: `model - schema 1.1 -type resource - relations - define x: y - define y: x`, - tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"), - }, - { - name: "test_4", - model: `model - schema 1.1 -type resource - relations - define parent: [resource] - define x: [user] or x from parent`, - tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"), - }, - } - - checker := NewLocalCheckerWithCycleDetection() - t.Cleanup(checker.Close) - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - model := testutils.MustTransformDSLToProtoWithID(test.model) - - ctx := typesystem.ContextWithTypesystem( - context.Background(), - typesystem.New(model), - ) - ctx = storage.ContextWithRelationshipTupleReader(ctx, ds) - - resp, err := checker.ResolveCheck(ctx, &ResolveCheckRequest{ - StoreID: storeID, - TupleKey: test.tupleKey, - RequestMetadata: NewCheckRequestMetadata(25), - }) - - require.NoError(t, err) - require.False(t, resp.GetAllowed()) - - require.GreaterOrEqual(t, resp.ResolutionMetadata.DatastoreQueryCount, uint32(0)) // min of 0 (x) if x is cycle. TODO: accurately report datastore query count of cycle branches - require.LessOrEqual(t, resp.ResolutionMetadata.DatastoreQueryCount, uint32(3)) // max of 3 (x, y, z) before the cycle - }) - } -} - func TestCheckConditions(t *testing.T) { ds := memory.New() @@ -1122,9 +1456,9 @@ type doc model := parser.MustTransformDSLToProto(`model schema 1.1 - + type user - + type group relations define member: [user, group#member] @@ -1187,7 +1521,7 @@ type doc schema 1.1 type user - + type document relations define owner: [user] @@ -1270,75 +1604,177 @@ func TestUnionCheckFuncReducer(t *testing.T) { }, nil } - t.Run("if_no_handlers_return_allowed_false", func(t *testing.T) { + t.Run("no_handlers_return_allowed_false", func(t *testing.T) { resp, err := union(ctx, concurrencyLimit) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) }) - t.Run("if_one_handler_is_truthy_and_others_are_falsey_return_allowed_true", func(t *testing.T) { - resp, err := union(ctx, concurrencyLimit, trueHandler, falseHandler, falseHandler) + t.Run("true_or_true_return_true", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, trueHandler, trueHandler) require.NoError(t, err) + require.NotNil(t, resp) require.True(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) }) - t.Run("if_all_handlers_are_falsey_return_allowed_false", func(t *testing.T) { - resp, err := union(ctx, concurrencyLimit, falseHandler, falseHandler, falseHandler) + t.Run("true_or_false_return_true", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, trueHandler, falseHandler) require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("false_or_true_return_true", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, falseHandler, trueHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("false_or_false_return_false", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, falseHandler, falseHandler) + require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) }) - t.Run("if_a_handler_errors_but_other_handler_are_truthy_return_allowed_true", func(t *testing.T) { - depthExceededHandler := func(context.Context) (*ResolveCheckResponse, error) { - return nil, ErrResolutionDepthExceeded - } + t.Run("true_or_err_return_true", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, trueHandler, generalErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) - resp, err := union(ctx, concurrencyLimit, depthExceededHandler, trueHandler) + t.Run("true_or_errResolutionDepth_return_true", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, trueHandler, depthExceededHandler) require.NoError(t, err) + require.NotNil(t, resp) require.True(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) }) - t.Run("if_a_handler_errors_but_other_handler_is_falsey_return_error_and_allowed_false", func(t *testing.T) { - depthExceededHandler := func(context.Context) (*ResolveCheckResponse, error) { - return nil, ErrResolutionDepthExceeded - } + t.Run("true_or_cycle_return_true", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) - resp, err := union(ctx, concurrencyLimit, depthExceededHandler, falseHandler) - require.ErrorIs(t, ErrResolutionDepthExceeded, err) + t.Run("false_or_err_return_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, falseHandler, generalErrorHandler) + require.EqualError(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) + + t.Run("false_or_errResolutionDepth_return_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, falseHandler, depthExceededHandler) + require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) + }) + + t.Run("false_or_cycle_return_false", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler) + require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) }) - t.Run("if_a_handler_errors_with_cycle_but_other_handler_is_falsey_return_allowed_false_with_a_nil_error", func(t *testing.T) { - cyclicErrorHandler := func(context.Context) (*ResolveCheckResponse, error) { - return nil, ErrCycleDetected - } + t.Run("err_or_true_return_true", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, generalErrorHandler, trueHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("cycle_or_true_return_true", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler) + require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.GetAllowed()) + require.False(t, resp.GetCycleDetected()) + }) + + t.Run("err_or_false_return_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, generalErrorHandler, falseHandler) + require.EqualError(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) + + t.Run("errResolutionDepth_or_false_return_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, depthExceededHandler, falseHandler) + require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) + }) + t.Run("cycle_or_false_return_false", func(t *testing.T) { resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, falseHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) }) - t.Run("both_handlers_with_cycle_return_allowed_false_with_nil_error", func(t *testing.T) { - cyclicErrorHandler := func(context.Context) (*ResolveCheckResponse, error) { - return nil, ErrCycleDetected - } + t.Run("cycle_or_err_return_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, generalErrorHandler) + require.EqualError(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) + + t.Run("cycle_or_errResolutionDepth_return_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, depthExceededHandler) + require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) + }) + + t.Run("err_or_cycle_return_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, generalErrorHandler, cyclicErrorHandler) + require.EqualError(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) + + t.Run("errResolutionDepth_or_cycle_return_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, depthExceededHandler, cyclicErrorHandler) + require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) + }) + t.Run("cycle_or_cycle_return_false", func(t *testing.T) { resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, cyclicErrorHandler) require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) }) - t.Run("if_a_handler_errors_with_cycle_but_other_handler_is_truthy_return_allowed_true_with_a_nil_error", func(t *testing.T) { - cyclicErrorHandler := func(context.Context) (*ResolveCheckResponse, error) { - return nil, ErrCycleDetected - } + t.Run("false_or_cycle_or_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler, generalErrorHandler) + require.EqualError(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) + }) - resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler) - require.NoError(t, err) - require.True(t, resp.GetAllowed()) + t.Run("err_or_err_return_err", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, falseHandler, generalErrorHandler, generalErrorHandler) + require.ErrorContains(t, err, simulatedDBErrorMessage) + require.Nil(t, resp) }) - t.Run("should_aggregate_DatastoreQueryCount_of_non_error handlers", func(t *testing.T) { + t.Run("errResolutionDepth_or_errResolutionDepth_return_errResolutionDepth", func(t *testing.T) { + resp, err := union(ctx, concurrencyLimit, falseHandler, depthExceededHandler, depthExceededHandler) + require.ErrorIs(t, err, ErrResolutionDepthExceeded) + require.Nil(t, resp) + }) + + t.Run("should_aggregate_DatastoreQueryCount_of_non_error_handlers", func(t *testing.T) { trueHandler := func(context.Context) (*ResolveCheckResponse, error) { time.Sleep(5 * time.Millisecond) // forces `trueHandler` to be resolved after `falseHandler` return &ResolveCheckResponse{ @@ -1402,12 +1838,12 @@ func TestUnionCheckFuncReducer(t *testing.T) { slowHandler := func(context.Context) (*ResolveCheckResponse, error) { time.Sleep(50 * time.Millisecond) - return nil, nil + return &ResolveCheckResponse{}, nil } resp, err := union(ctx, concurrencyLimit, slowHandler, falseHandler) require.ErrorIs(t, err, context.DeadlineExceeded) - require.False(t, resp.GetAllowed()) + require.Nil(t, resp) }) t.Run("should_handle_context_timeouts_even_with_eventual_truthy_handler", func(t *testing.T) { @@ -1423,6 +1859,103 @@ func TestUnionCheckFuncReducer(t *testing.T) { resp, err := union(ctx, concurrencyLimit, trueHandler, falseHandler) require.ErrorIs(t, err, context.DeadlineExceeded) - require.False(t, resp.GetAllowed()) + require.Nil(t, resp) }) + + t.Run("should_return_true_with_slow_falsey_handler", func(t *testing.T) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Millisecond) + t.Cleanup(cancel) + + falseSlowHandler := func(context.Context) (*ResolveCheckResponse, error) { + time.Sleep(25 * time.Millisecond) + return &ResolveCheckResponse{ + Allowed: false, + }, nil + } + + resp, err := union(ctx, concurrencyLimit, trueHandler, falseSlowHandler) + require.NoError(t, err) + require.True(t, resp.GetAllowed()) + }) + + t.Run("return_error_if_context_cancelled_before_resolution", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + time.Sleep(10 * time.Millisecond) + cancel() + }() + + slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) { + time.Sleep(50 * time.Millisecond) + return &ResolveCheckResponse{ + Allowed: true, + }, nil + } + + resp, err := union(ctx, concurrencyLimit, slowTrueHandler) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, resp) + + wg.Wait() // just to make sure to avoid test leaks + }) + + t.Run("return_allowed:true_if_truthy_handler_evaluated_before_context_cancelled", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + time.Sleep(10 * time.Millisecond) + cancel() + }() + + resp, err := intersection(ctx, concurrencyLimit, trueHandler) + require.NoError(t, err) + require.True(t, resp.GetAllowed()) + + wg.Wait() // just to make sure to avoid test leaks + }) +} + +func TestCloneResolveCheckResponse(t *testing.T) { + resp1 := &ResolveCheckResponse{ + Allowed: true, + ResolutionMetadata: &ResolveCheckResponseMetadata{ + DatastoreQueryCount: 1, + CycleDetected: false, + }, + } + clonedResp1 := CloneResolveCheckResponse(resp1) + + require.Equal(t, resp1, clonedResp1) + require.NotSame(t, resp1, clonedResp1) + + // mutate the clone and ensure the original reference is + // unchanged + clonedResp1.Allowed = false + clonedResp1.ResolutionMetadata.DatastoreQueryCount = 2 + clonedResp1.ResolutionMetadata.CycleDetected = true + require.True(t, resp1.GetAllowed()) + require.Equal(t, uint32(1), resp1.GetResolutionMetadata().DatastoreQueryCount) + require.False(t, resp1.GetResolutionMetadata().CycleDetected) + + resp2 := &ResolveCheckResponse{ + Allowed: true, + } + clonedResp2 := CloneResolveCheckResponse(resp2) + + require.NotSame(t, resp2, clonedResp2) + require.Equal(t, resp2.GetAllowed(), clonedResp2.GetAllowed()) + require.NotNil(t, clonedResp2.ResolutionMetadata) + require.Equal(t, uint32(0), clonedResp2.GetResolutionMetadata().DatastoreQueryCount) + require.False(t, clonedResp2.GetResolutionMetadata().CycleDetected) }
internal/graph/cycle_check_resolver.go+6 −1 modified@@ -43,7 +43,12 @@ func (c *CycleDetectionCheckResolver) ResolveCheck( _, cycleDetected := req.VisitedPaths[key] span.SetAttributes(attribute.Bool("cycle_detected", cycleDetected)) if cycleDetected { - return nil, ErrCycleDetected + return &ResolveCheckResponse{ + Allowed: false, + ResolutionMetadata: &ResolveCheckResponseMetadata{ + CycleDetected: true, + }, + }, nil } req.VisitedPaths[key] = struct{}{}
internal/graph/cycle_check_resolver_test.go+6 −2 modified@@ -42,8 +42,11 @@ func TestCycleDetectionCheckResolver(t *testing.T) { VisitedPaths: visitedPaths, }) - require.ErrorIs(t, err, ErrCycleDetected) + require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) + require.True(t, resp.GetCycleDetected()) + require.NotNil(t, resp.ResolutionMetadata) }) t.Run("no_cycle_detected_delegates_request", func(t *testing.T) { @@ -119,5 +122,6 @@ func TestIntegrationWithLocalChecker(t *testing.T) { RequestMetadata: NewCheckRequestMetadata(25), }) require.NoError(t, err) - require.True(t, resp.GetAllowed()) + require.NotNil(t, resp) + require.False(t, resp.GetAllowed()) }
internal/graph/graph.go+4 −0 modified@@ -73,6 +73,10 @@ type ResolveCheckResponseMetadata struct { // evaluated and potentially discarded // If the solution is "allowed=false", no paths were found. This is the sum of all the reads in all the paths that had to be evaluated DatastoreQueryCount uint32 + + // Indicates if the ResolveCheck subproblem that was evaluated involved + // a cycle in the evaluation. + CycleDetected bool } type RelationshipEdgeType int
pkg/server/commands/list_objects.go+2 −2 modified@@ -333,7 +333,7 @@ func (q *ListObjectsQuery) evaluate( RequestMetadata: checkRequestMetadata, }) if err != nil { - if errors.Is(err, graph.ErrResolutionDepthExceeded) || errors.Is(err, graph.ErrCycleDetected) { + if errors.Is(err, graph.ErrResolutionDepthExceeded) { resultsChan <- ListObjectsResult{Err: serverErrors.AuthorizationModelResolutionTooComplex} return } @@ -350,7 +350,7 @@ func (q *ListObjectsQuery) evaluate( }(res) case err := <-errChan: - if errors.Is(err, graph.ErrResolutionDepthExceeded) || errors.Is(err, graph.ErrCycleDetected) { + if errors.Is(err, graph.ErrResolutionDepthExceeded) { err = serverErrors.AuthorizationModelResolutionTooComplex }
pkg/server/server.go+1 −1 modified@@ -774,7 +774,7 @@ func (s *Server) Check(ctx context.Context, req *openfgav1.CheckRequest) (*openf }) if err != nil { telemetry.TraceError(span, err) - if errors.Is(err, graph.ErrResolutionDepthExceeded) || errors.Is(err, graph.ErrCycleDetected) { + if errors.Is(err, graph.ErrResolutionDepthExceeded) { return nil, serverErrors.AuthorizationModelResolutionTooComplex }
pkg/server/server_test.go+21 −2 modified@@ -314,7 +314,7 @@ func TestAvoidDeadlockAcrossCheckRequests(t *testing.T) { var wg sync.WaitGroup - wg.Add(2) + wg.Add(3) var resp1 *openfgav1.CheckResponse var err1 error @@ -340,13 +340,30 @@ func TestAvoidDeadlockAcrossCheckRequests(t *testing.T) { }) }() + var resp3 *openfgav1.CheckResponse + var err3 error + go func() { + defer wg.Done() + + resp3, err3 = s.Check(context.Background(), &openfgav1.CheckRequest{ + StoreId: storeID, + AuthorizationModelId: modelID, + TupleKey: tuple.NewCheckRequestTupleKey("document:1", "viewer", "user:andres"), + }) + }() + wg.Wait() require.NoError(t, err1) + require.NotNil(t, resp1) require.False(t, resp1.GetAllowed()) require.NoError(t, err2) - require.False(t, resp2.GetAllowed()) + require.NotNil(t, resp2) + + require.NoError(t, err3) + require.NotNil(t, resp3) + require.True(t, resp3.GetAllowed()) } func TestAvoidDeadlockWithinSingleCheckRequest(t *testing.T) { @@ -405,7 +422,9 @@ func TestAvoidDeadlockWithinSingleCheckRequest(t *testing.T) { AuthorizationModelId: modelID, TupleKey: tuple.NewCheckRequestTupleKey("document:1", "can_view", "user:jon"), }) + require.NoError(t, err) + require.NotNil(t, resp) require.False(t, resp.GetAllowed()) }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-8cph-m685-6v6rghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-31452ghsaADVISORY
- github.com/openfga/openfga/commit/b6a6d99b2bdbf8c3781503989576076289f48ed2ghsax_refsource_MISCWEB
- github.com/openfga/openfga/security/advisories/GHSA-8cph-m685-6v6rghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.