VYPR
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.

PackageAffected versionsPatched versions
github.com/openfga/openfgaGo
>= 1.5.0, < 1.5.31.5.3

Affected products

1

Patches

1
b6a6d99b2bdb

Merge pull request from GHSA-8cph-m685-6v6r

https://github.com/openfga/openfgaJonathan WhitakerApr 16, 2024via ghsa
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

News mentions

0

No linked articles in our index yet.