CVE-2023-43645 (published 2023-09-27T15:19Z)

OpenFGA is an authorization/permission engine built for developers and inspired by Google Zanzibar. OpenFGA is vulnerable to a denial of service attack when certain Check calls are executed against authorization models that contain circular relationship definitions. When the call is made, it's possible for the server to exhaust resources and die. Users are advised to upgrade to v1.3.2 and update any offending models. There are no known workarounds for this vulnerability. Note that for models which contained cycles or a relation definition that has the relation itself in its evaluation path, checks and queries that require evaluation will no longer be evaluated on v1.3.2+ and will return errors instead. Users who do not have cyclic models are unaffected.

CVSS v3 base score: 5.9, MEDIUM (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H)

Weakness information:
cwe_id: CWE-835
cwe_name: Loop with Unreachable Exit Condition ('Infinite Loop')
description: The product contains an iteration or loop with an exit condition that cannot be reached, i.e., an infinite loop.
extended_description: If the loop can be influenced by an attacker, this weakness could allow attackers to consume excessive resources such as CPU or memory.
         

Fix information:
repo_url: https://github.com/openfga/openfga
commit_details: repo_url: https://github.com/openfga/openfga
author: Jonathan Whitaker
author_date: 2023-09-26 12:12:49-06:00
msg: Merge pull request from GHSA-2hm9-h873-pgqh

* fix: avoid revisiting computed relations in cycles

* Revert "fix: avoid revisiting computed relations in cycles"

This reverts commit 5d51d4fdcca1eb240310037c92d9085381866ed4.

* fix: validate against cyclical model definitions more accurately

* chore: drop unused code and wire up visited paths betweeen calls

* chore: touch up tests and restructure visited evaluation paths

* docs(release): add `v1.3.2` release notes to CHANGELOG

* Update CHANGELOG.md

Co-authored-by: Adrian Tam <adrian.tam@okta.com>

* docs(release): add additional contributions to v1.3.2 release notes

* docs(release): drop extra link added erroneously

* docs(release): add breaking compability notice

---------

Co-authored-by: Adrian Tam <adrian.tam@okta.com>
repository: repo_url: https://github.com/openfga/openfga
repo_name: openfga/openfga
description: A high performance and flexible authorization/permission engine built for developers and inspired by Google Zanzibar
repo_language: Go
owner: openfga
file_changes: filename: CHANGELOG.md
change_type: ModificationType.MODIFY
diff: @@ -8,6 +8,28 @@ Try to keep listed changes to a concise bulleted list of simple explanations of
 
 ## [Unreleased]
 
+## [1.3.2] - 2023-08-25
+### Added
+* Support TLS for OTLP trace endpoint ([#885](https://github.com/openfga/openfga/pull/885)) - thanks @matoous
+* Configurable limits to database reads per ListObjects query ([#967](https://github.com/openfga/openfga/pull/967))
+* Datastore query count labels to traces and query latency histogram in ListObjects ([#959](https://github.com/openfga/openfga/pull/959))
+* Github workflow to check markdown links ([#1016](https://github.com/openfga/openfga/pull/1016)) - thanks @sanketrai1
+
+### Fixed
+* Change response code to internal error for concurrency conflicts ([#1011](https://github.com/openfga/openfga/pull/1011))
+
+### Changed
+* Use slices and maps packages from go1.21 ([#969](https://github.com/openfga/openfga/pull/969)) - thanks @tranngoclam
+* Moved request validations to RPC handlers so library integrations benefit ([#975](https://github.com/openfga/openfga/pull/975), [#998](https://github.com/openfga/openfga/pull/998))
+* Refactored internal usages of ConnectedObjects to ReverseExpand ([#968](https://github.com/openfga/openfga/pull/968))
+* Expose validation middleware ([#1005](https://github.com/openfga/openfga/pull/1005))
+* Upgrade grpc validator middleware to the latest v2 package ([#1019](https://github.com/openfga/openfga/pull/1019)) - thanks @tranngoclam
+
+### Security
+* Patches [CVE-2023-43645](https://github.com/openfga/openfga/security/advisories/GHSA-2hm9-h873-pgqh) - see the CVE for more details
+
+  **[BREAKING]** If your model contained cycles or a relation definition that has the relation itself in its evaluation path, then Checks and queries that require evaluation will no longer be evaluated on v1.3.2+ and will return errors instead. You will need to update your models to remove the cycles.
+
 ## [1.3.1] - 2023-08-23
 
 ### Added
@@ -626,7 +648,8 @@ no tuple key instead.
 * Memory storage adapter implementation
 * Early support for preshared key or OIDC authentication methods
 
-[Unreleased]: https://github.com/openfga/openfga/compare/v1.3.1...HEAD
+[Unreleased]: https://github.com/openfga/openfga/compare/v1.3.2...HEAD
+[1.3.2]: https://github.com/openfga/openfga/releases/tag/v1.3.2
 [1.3.1]: https://github.com/openfga/openfga/releases/tag/v1.3.1
 [1.3.0]: https://github.com/openfga/openfga/releases/tag/v1.3.0
 [1.2.0]: https://github.com/openfga/openfga/releases/tag/v1.2.0

programming_language: Markdown
filename: check.go
change_type: ModificationType.MODIFY
diff: @@ -16,6 +16,7 @@ import (
 	"go.opentelemetry.io/otel"
 	"go.opentelemetry.io/otel/attribute"
 	"go.opentelemetry.io/otel/trace"
+	"golang.org/x/exp/maps"
 )
 
 var tracer = otel.Tracer("internal/graph/check")
@@ -26,12 +27,21 @@ const (
 	defaultMaxConcurrentReadsForCheck = math.MaxUint32
 )
 
+var (
+	ErrCycleDetected = errors.New("a cycle has been detected")
+)
+
+var cycleDetectedCheckHandler = func(ctx context.Context) (*ResolveCheckResponse, error) {
+	return nil, ErrCycleDetected
+}
+
 type ResolveCheckRequest struct {
 	StoreID              string
 	AuthorizationModelID string
 	TupleKey             *openfgav1.TupleKey
 	ContextualTuples     []*openfgav1.TupleKey
 	ResolutionMetadata   *ResolutionMetadata
+	VisitedPaths         map[string]struct{}
 }
 
 type ResolveCheckResponse struct {
@@ -459,6 +469,19 @@ func (c *LocalChecker) ResolveCheck(
 		return nil, fmt.Errorf("relation '%s' undefined for object type '%s'", relation, objectType)
 	}
 
+	if req.VisitedPaths != nil {
+
+		if _, visited := req.VisitedPaths[tuple.TupleKeyToString(req.GetTupleKey())]; visited {
+			return nil, ErrCycleDetected
+		}
+
+		req.VisitedPaths[tuple.TupleKeyToString(req.GetTupleKey())] = struct{}{}
+	} else {
+		req.VisitedPaths = map[string]struct{}{
+			tuple.TupleKeyToString(req.GetTupleKey()): {},
+		}
+	}
+
 	resp, err := union(ctx, c.concurrencyLimit, c.checkRewrite(ctx, req, rel.GetRewrite()))
 	if err != nil {
 		return nil, err
@@ -585,16 +608,23 @@ func (c *LocalChecker) checkDirect(parentctx context.Context, req *ResolveCheckR
 				}
 
 				if usersetRelation != "" {
+					tupleKey := tuple.NewTupleKey(usersetObject, usersetRelation, tk.GetUser())
+
+					if _, visited := req.VisitedPaths[tuple.TupleKeyToString(tupleKey)]; visited {
+						return nil, ErrCycleDetected
+					}
+
 					handlers = append(handlers, c.dispatch(
 						ctx,
 						&ResolveCheckRequest{
 							StoreID:              storeID,
 							AuthorizationModelID: req.GetAuthorizationModelID(),
-							TupleKey:             tuple.NewTupleKey(usersetObject, usersetRelation, tk.GetUser()),
+							TupleKey:             tupleKey,
 							ResolutionMetadata: &ResolutionMetadata{
 								Depth:               req.GetResolutionMetadata().Depth - 1,
 								DatastoreQueryCount: response.GetResolutionMetadata().DatastoreQueryCount,
 							},
+							VisitedPaths: maps.Clone(req.VisitedPaths),
 						}))
 				}
 			}
@@ -628,24 +658,32 @@ func (c *LocalChecker) checkDirect(parentctx context.Context, req *ResolveCheckR
 
 // checkComputedUserset evaluates the Check request with the rewritten relation (e.g. the computed userset relation).
 func (c *LocalChecker) checkComputedUserset(parentctx context.Context, req *ResolveCheckRequest, rewrite *openfgav1.Userset_ComputedUserset) CheckHandlerFunc {
+
 	return func(ctx context.Context) (*ResolveCheckResponse, error) {
 		ctx, span := tracer.Start(ctx, "checkComputedUserset")
 		defer span.End()
 
+		rewrittenTupleKey := tuple.NewTupleKey(
+			req.TupleKey.GetObject(),
+			rewrite.ComputedUserset.GetRelation(),
+			req.TupleKey.GetUser(),
+		)
+
+		if _, visited := req.VisitedPaths[tuple.TupleKeyToString(rewrittenTupleKey)]; visited {
+			return nil, ErrCycleDetected
+		}
+
 		return c.dispatch(
 			ctx,
 			&ResolveCheckRequest{
 				StoreID:              req.GetStoreID(),
 				AuthorizationModelID: req.GetAuthorizationModelID(),
-				TupleKey: tuple.NewTupleKey(
-					req.TupleKey.GetObject(),
-					rewrite.ComputedUserset.GetRelation(),
-					req.TupleKey.GetUser(),
-				),
+				TupleKey:             rewrittenTupleKey,
 				ResolutionMetadata: &ResolutionMetadata{
 					Depth:               req.GetResolutionMetadata().Depth - 1,
 					DatastoreQueryCount: req.GetResolutionMetadata().DatastoreQueryCount,
 				},
+				VisitedPaths: maps.Clone(req.VisitedPaths),
 			})(ctx)
 	}
 }
@@ -722,6 +760,10 @@ func (c *LocalChecker) checkTTU(parentctx context.Context, req *ResolveCheckRequ
 				}
 			}
 
+			if _, visited := req.VisitedPaths[tuple.TupleKeyToString(tupleKey)]; visited {
+				return nil, ErrCycleDetected
+			}
+
 			handlers = append(handlers, c.dispatch(
 				ctx,
 				&ResolveCheckRequest{
@@ -732,6 +774,7 @@ func (c *LocalChecker) checkTTU(parentctx context.Context, req *ResolveCheckRequ
 						Depth:               req.GetResolutionMetadata().Depth - 1,
 						DatastoreQueryCount: req.GetResolutionMetadata().DatastoreQueryCount, // add TTU read below
 					},
+					VisitedPaths: maps.Clone(req.VisitedPaths),
 				}))
 		}
 

programming_language: Go
filename: check_test.go
change_type: ModificationType.MODIFY
diff: @@ -322,3 +322,101 @@ func TestCheckDatastoreQueryCount(t *testing.T) {
 		})
 	}
 }
+
+// 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
+// avoid the cycle and return an error indicating a cycle was detected.
+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: `
+			type user
+
+			type resource
+			  relations
+				define x: [user] as self but not y
+				define y: [user] as self but not z
+				define z: [user] as self or x
+			`,
+			tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"),
+		},
+		{
+			name: "test_2",
+			model: `
+			type user
+
+			type resource
+			  relations
+				define x: [user] as self and y
+				define y: [user] as self and z
+				define z: [user] as self or x
+			`,
+			tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"),
+		},
+		{
+			name: "test_3",
+			model: `
+			type resource
+			  relations
+				define x as y
+				define y as x
+			`,
+			tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"),
+		},
+		{
+			name: "test_4",
+			model: `
+			type resource
+			  relations
+			    define parent: [resource] as self
+				define x: [user] as self or x from parent
+			`,
+			tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"),
+		},
+	}
+
+	checker := NewLocalChecker(ds)
+
+	for _, test := range tests {
+		typedefs := parser.MustParse(test.model)
+
+		ctx := typesystem.ContextWithTypesystem(context.Background(), typesystem.New(
+			&openfgav1.AuthorizationModel{
+				Id:              ulid.Make().String(),
+				TypeDefinitions: typedefs,
+				SchemaVersion:   typesystem.SchemaVersion1_1,
+			},
+		))
+
+		resp, err := checker.ResolveCheck(ctx, &ResolveCheckRequest{
+			StoreID:            storeID,
+			TupleKey:           test.tupleKey,
+			ResolutionMetadata: &ResolutionMetadata{Depth: 25},
+		})
+
+		// if the branch producing the cycle is reached first, then an error is returned, otherwise
+		// a result is returned if some other terminal path of evaluation was reached before the cycle
+		if err != nil {
+			require.ErrorIs(t, err, ErrCycleDetected)
+		} else {
+			require.False(t, resp.GetAllowed())
+			require.GreaterOrEqual(t, resp.ResolutionMetadata.DatastoreQueryCount, uint32(1)) // min of 1 (x) if x isn't found and it returns quickly
+			require.LessOrEqual(t, resp.ResolutionMetadata.DatastoreQueryCount, uint32(3))    // max of 3 (x, y, z) before the cycle
+		}
+	}
+}

programming_language: Go
filename: list_objects.go
change_type: ModificationType.MODIFY
diff: @@ -234,6 +234,11 @@ func (q *ListObjectsQuery) evaluate(
 				ContextualTuples: req.GetContextualTuples().GetTupleKeys(),
 			}, reverseExpandResultsChan, resolutionMetadata)
 			if err != nil {
+				if errors.Is(err, graph.ErrResolutionDepthExceeded) || errors.Is(err, graph.ErrCycleDetected) {
+					resultsChan <- ListObjectsResult{Err: serverErrors.AuthorizationModelResolutionTooComplex}
+					return
+				}
+
 				resultsChan <- ListObjectsResult{Err: err}
 			}
 
@@ -282,6 +287,11 @@ func (q *ListObjectsQuery) evaluate(
 					},
 				})
 				if err != nil {
+					if errors.Is(err, graph.ErrResolutionDepthExceeded) || errors.Is(err, graph.ErrCycleDetected) {
+						resultsChan <- ListObjectsResult{Err: serverErrors.AuthorizationModelResolutionTooComplex}
+						return
+					}
+
 					resultsChan <- ListObjectsResult{Err: err}
 					return
 				}
@@ -356,6 +366,7 @@ func (q *ListObjectsQuery) Execute(
 				if errors.Is(result.Err, serverErrors.AuthorizationModelResolutionTooComplex) {
 					return nil, result.Err
 				}
+
 				return nil, serverErrors.HandleError("", result.Err)
 			}
 

programming_language: Go
filename: reverse_expand.go
change_type: ModificationType.MODIFY
diff: @@ -10,7 +10,6 @@ import (
 
 	openfgav1 "github.com/openfga/api/proto/openfga/v1"
 	"github.com/openfga/openfga/internal/graph"
-	serverErrors "github.com/openfga/openfga/pkg/server/errors"
 	"github.com/openfga/openfga/pkg/storage"
 	"github.com/openfga/openfga/pkg/storage/storagewrappers"
 	"github.com/openfga/openfga/pkg/tuple"
@@ -209,7 +208,7 @@ func (c *ReverseExpandQuery) execute(
 		ctx = graph.ContextWithResolutionDepth(ctx, 0)
 	} else {
 		if depth >= c.resolveNodeLimit {
-			return serverErrors.AuthorizationModelResolutionTooComplex
+			return graph.ErrResolutionDepthExceeded
 		}
 
 		ctx = graph.ContextWithResolutionDepth(ctx, depth+1)

programming_language: Go
filename: server.go
change_type: ModificationType.MODIFY
diff: @@ -586,7 +586,7 @@ func (s *Server) Check(ctx context.Context, req *openfgav1.CheckRequest) (*openf
 		},
 	})
 	if err != nil {
-		if errors.Is(err, graph.ErrResolutionDepthExceeded) {
+		if errors.Is(err, graph.ErrResolutionDepthExceeded) || errors.Is(err, graph.ErrCycleDetected) {
 			return nil, serverErrors.AuthorizationModelResolutionTooComplex
 		}
 

programming_language: Go
filename: server_test.go
change_type: ModificationType.MODIFY
diff: @@ -556,257 +556,128 @@ func BenchmarkListObjectsNoRaceCondition(b *testing.B) {
 	}
 }
 
-// This test ensures that when the data storage fails, ListObjects v0 throws an error
-func TestListObjects_Unoptimized_UnhappyPaths(t *testing.T) {
+func TestListObjects_ErrorCases(t *testing.T) {
 	ctx := context.Background()
 	store := ulid.Make().String()
-	modelID := ulid.Make().String()
 
 	mockController := gomock.NewController(t)
 	defer mockController.Finish()
 
-	mockDatastore := mockstorage.NewMockOpenFGADatastore(mockController)
-
-	mockDatastore.EXPECT().ReadAuthorizationModel(gomock.Any(), store, modelID).AnyTimes().Return(&openfgav1.AuthorizationModel{
-		SchemaVersion: typesystem.SchemaVersion1_1,
-		TypeDefinitions: parser.MustParse(`
-		type user
-
-		type repo
-		  relations
-		    define allowed: [user] as self
-		    define viewer: [user] as self and allowed
-		`),
-	}, nil)
-	mockDatastore.EXPECT().ReadStartingWithUser(gomock.Any(), store, gomock.Any()).AnyTimes().Return(nil, errors.New("error reading from storage"))
-
-	s := MustNewServerWithOpts(
-		WithDatastore(mockDatastore),
-	)
-
-	t.Run("error_listing_objects_from_storage_in_non-streaming_version", func(t *testing.T) {
-		res, err := s.ListObjects(ctx, &openfgav1.ListObjectsRequest{
-			StoreId:              store,
-			AuthorizationModelId: modelID,
-			Type:                 "repo",
-			Relation:             "viewer",
-			User:                 "user:bob",
-		})
-
-		require.Nil(t, res)
-		require.ErrorIs(t, err, serverErrors.NewInternalError("", errors.New("error reading from storage")))
-	})
-
-	t.Run("error_listing_objects_from_storage_in_streaming_version", func(t *testing.T) {
-		err := s.StreamedListObjects(&openfgav1.StreamedListObjectsRequest{
-			StoreId:              store,
-			AuthorizationModelId: modelID,
-			Type:                 "repo",
-			Relation:             "viewer",
-			User:                 "user:bob",
-		}, NewMockStreamServer())
-
-		require.ErrorIs(t, err, serverErrors.NewInternalError("", errors.New("error reading from storage")))
-	})
-}
-
-// This test ensures that when the data storage fails for known eror, ListObjects v0 throws the correct error
-func TestListObjects_Unoptimized_UnhappyPaths_Known_Error(t *testing.T) {
-	ctx := context.Background()
-	store := ulid.Make().String()
-	modelID := ulid.Make().String()
+	t.Run("database_errors", func(t *testing.T) {
+		mockDatastore := mockstorage.NewMockOpenFGADatastore(mockController)
 
-	mockController := gomock.NewController(t)
-	defer mockController.Finish()
+		s := MustNewServerWithOpts(
+			WithDatastore(mockDatastore),
+		)
 
-	mockDatastore := mockstorage.NewMockOpenFGADatastore(mockController)
+		modelID := ulid.Make().String()
 
-	mockDatastore.EXPECT().ReadAuthorizationModel(gomock.Any(), store, modelID).AnyTimes().Return(&openfgav1.AuthorizationModel{
-		SchemaVersion: typesystem.SchemaVersion1_1,
-		TypeDefinitions: parser.MustParse(`
-		type user
-
-		type repo
-		  relations
-		    define allowed: [user] as self
-		    define viewer: [user] as self and allowed
-		`),
-	}, nil)
-	mockDatastore.EXPECT().ReadStartingWithUser(gomock.Any(), store, gomock.Any()).AnyTimes().Return(nil, serverErrors.AuthorizationModelResolutionTooComplex)
+		mockDatastore.EXPECT().ReadAuthorizationModel(gomock.Any(), store, modelID).AnyTimes().Return(&openfgav1.AuthorizationModel{
+			SchemaVersion: typesystem.SchemaVersion1_1,
+			TypeDefinitions: parser.MustParse(`
+			type user
+	
+			type document
+			  relations
+				define viewer: [user, user:*] as self
+			`),
+		}, nil)
 
-	s := MustNewServerWithOpts(
-		WithDatastore(mockDatastore),
-	)
+		mockDatastore.EXPECT().ReadStartingWithUser(gomock.Any(), store, storage.ReadStartingWithUserFilter{
+			ObjectType: "document",
+			Relation:   "viewer",
+			UserFilter: []*openfgav1.ObjectRelation{
+				{Object: "user:*"},
+				{Object: "user:bob"},
+			}}).AnyTimes().Return(nil, errors.New("error reading from storage"))
+
+		t.Run("error_listing_objects_from_storage_in_non-streaming_version", func(t *testing.T) {
+			res, err := s.ListObjects(ctx, &openfgav1.ListObjectsRequest{
+				StoreId:              store,
+				AuthorizationModelId: modelID,
+				Type:                 "document",
+				Relation:             "viewer",
+				User:                 "user:bob",
+			})
 
-	t.Run("error_listing_objects_from_storage_in_non-streaming_version", func(t *testing.T) {
-		res, err := s.ListObjects(ctx, &openfgav1.ListObjectsRequest{
-			StoreId:              store,
-			AuthorizationModelId: modelID,
-			Type:                 "repo",
-			Relation:             "viewer",
-			User:                 "user:bob",
+			require.Nil(t, res)
+			require.ErrorIs(t, err, serverErrors.NewInternalError("", errors.New("error reading from storage")))
 		})
 
-		require.Nil(t, res)
-		require.ErrorIs(t, err, serverErrors.AuthorizationModelResolutionTooComplex)
-	})
-
-	t.Run("error_listing_objects_from_storage_in_streaming_version", func(t *testing.T) {
-		err := s.StreamedListObjects(&openfgav1.StreamedListObjectsRequest{
-			StoreId:              store,
-			AuthorizationModelId: modelID,
-			Type:                 "repo",
-			Relation:             "viewer",
-			User:                 "user:bob",
-		}, NewMockStreamServer())
-
-		require.ErrorIs(t, err, serverErrors.AuthorizationModelResolutionTooComplex)
-	})
-}
-
-// This test ensures that when the data storage fails, ListObjects v1 throws an error
-func TestListObjects_UnhappyPaths(t *testing.T) {
-	ctx := context.Background()
-	store := ulid.Make().String()
-	modelID := ulid.Make().String()
-
-	mockController := gomock.NewController(t)
-	defer mockController.Finish()
-
-	mockDatastore := mockstorage.NewMockOpenFGADatastore(mockController)
-
-	mockDatastore.EXPECT().ReadAuthorizationModel(gomock.Any(), store, modelID).AnyTimes().Return(&openfgav1.AuthorizationModel{
-		SchemaVersion: typesystem.SchemaVersion1_1,
-		TypeDefinitions: []*openfgav1.TypeDefinition{
-			{
-				Type: "user",
-			},
-			{
-				Type: "document",
-				Relations: map[string]*openfgav1.Userset{
-					"viewer": typesystem.This(),
-				},
-				Metadata: &openfgav1.Metadata{
-					Relations: map[string]*openfgav1.RelationMetadata{
-						"viewer": {
-							DirectlyRelatedUserTypes: []*openfgav1.RelationReference{
-								typesystem.DirectRelationReference("user", ""),
-								typesystem.WildcardRelationReference("user"),
-							},
-						},
-					},
-				},
-			},
-		},
-	}, nil)
-	mockDatastore.EXPECT().ReadStartingWithUser(gomock.Any(), store, storage.ReadStartingWithUserFilter{
-		ObjectType: "document",
-		Relation:   "viewer",
-		UserFilter: []*openfgav1.ObjectRelation{
-			{Object: "user:*"},
-			{Object: "user:bob"},
-		}}).AnyTimes().Return(nil, errors.New("error reading from storage"))
-
-	s := MustNewServerWithOpts(
-		WithDatastore(mockDatastore),
-	)
+		t.Run("error_listing_objects_from_storage_in_streaming_version", func(t *testing.T) {
+			err := s.StreamedListObjects(&openfgav1.StreamedListObjectsRequest{
+				StoreId:              store,
+				AuthorizationModelId: modelID,
+				Type:                 "document",
+				Relation:             "viewer",
+				User:                 "user:bob",
+			}, NewMockStreamServer())
 
-	t.Run("error_listing_objects_from_storage_in_non-streaming_version", func(t *testing.T) {
-		res, err := s.ListObjects(ctx, &openfgav1.ListObjectsRequest{
-			StoreId:              store,
-			AuthorizationModelId: modelID,
-			Type:                 "document",
-			Relation:             "viewer",
-			User:                 "user:bob",
+			require.ErrorIs(t, err, serverErrors.NewInternalError("", errors.New("error reading from storage")))
 		})
-
-		require.Nil(t, res)
-		require.ErrorIs(t, err, serverErrors.NewInternalError("", errors.New("error reading from storage")))
 	})
 
-	t.Run("error_listing_objects_from_storage_in_streaming_version", func(t *testing.T) {
-		err := s.StreamedListObjects(&openfgav1.StreamedListObjectsRequest{
-			StoreId:              store,
-			AuthorizationModelId: modelID,
-			Type:                 "document",
-			Relation:             "viewer",
-			User:                 "user:bob",
-		}, NewMockStreamServer())
+	t.Run("graph_resolution_errors", func(t *testing.T) {
 
-		require.ErrorIs(t, err, serverErrors.NewInternalError("", errors.New("error reading from storage")))
-	})
-}
+		s := MustNewServerWithOpts(
+			WithDatastore(memory.New()),
+			WithResolveNodeLimit(2),
+		)
 
-// This test ensures that when the data storage fails with known errors, ListObjects v1 throws an error
-func TestListObjects_UnhappyPaths_Known_Error(t *testing.T) {
-	ctx := context.Background()
-	store := ulid.Make().String()
-	modelID := ulid.Make().String()
+		writeModelResp, err := s.WriteAuthorizationModel(ctx, &openfgav1.WriteAuthorizationModelRequest{
+			StoreId:       store,
+			SchemaVersion: typesystem.SchemaVersion1_1,
+			TypeDefinitions: parser.MustParse(`
+			type user
 
-	mockController := gomock.NewController(t)
-	defer mockController.Finish()
+			type group
+			  relations
+			    define member: [user, group#member] as self
 
-	mockDatastore := mockstorage.NewMockOpenFGADatastore(mockController)
+			type document
+			  relations
+				define viewer: [group#member] as self
+			`),
+		})
+		require.NoError(t, err)
 
-	mockDatastore.EXPECT().ReadAuthorizationModel(gomock.Any(), store, modelID).AnyTimes().Return(&openfgav1.AuthorizationModel{
-		SchemaVersion: typesystem.SchemaVersion1_1,
-		TypeDefinitions: []*openfgav1.TypeDefinition{
-			{
-				Type: "user",
-			},
-			{
-				Type: "document",
-				Relations: map[string]*openfgav1.Userset{
-					"viewer": typesystem.This(),
-				},
-				Metadata: &openfgav1.Metadata{
-					Relations: map[string]*openfgav1.RelationMetadata{
-						"viewer": {
-							DirectlyRelatedUserTypes: []*openfgav1.RelationReference{
-								typesystem.DirectRelationReference("user", ""),
-								typesystem.WildcardRelationReference("user"),
-							},
-						},
-					},
+		_, err = s.Write(ctx, &openfgav1.WriteRequest{
+			StoreId: store,
+			Writes: &openfgav1.TupleKeys{
+				TupleKeys: []*openfgav1.TupleKey{
+					tuple.NewTupleKey("document:1", "viewer", "group:1#member"),
+					tuple.NewTupleKey("group:1", "member", "group:2#member"),
+					tuple.NewTupleKey("group:2", "member", "group:3#member"),
+					tuple.NewTupleKey("group:3", "member", "user:jon"),
 				},
 			},
-		},
-	}, nil)
-	mockDatastore.EXPECT().ReadStartingWithUser(gomock.Any(), store, storage.ReadStartingWithUserFilter{
-		ObjectType: "document",
-		Relation:   "viewer",
-		UserFilter: []*openfgav1.ObjectRelation{
-			{Object: "user:*"},
-			{Object: "user:bob"},
-		}}).AnyTimes().Return(nil, serverErrors.AuthorizationModelResolutionTooComplex)
+		})
+		require.NoError(t, err)
 
-	s := MustNewServerWithOpts(
-		WithDatastore(mockDatastore),
-	)
+		t.Run("resolution_depth_exceeded_error_unary", func(t *testing.T) {
+			res, err := s.ListObjects(ctx, &openfgav1.ListObjectsRequest{
+				StoreId:              store,
+				AuthorizationModelId: writeModelResp.GetAuthorizationModelId(),
+				Type:                 "document",
+				Relation:             "viewer",
+				User:                 "user:jon",
+			})
 
-	t.Run("error_listing_objects_from_storage_in_non-streaming_version", func(t *testing.T) {
-		res, err := s.ListObjects(ctx, &openfgav1.ListObjectsRequest{
-			StoreId:              store,
-			AuthorizationModelId: modelID,
-			Type:                 "document",
-			Relation:             "viewer",
-			User:                 "user:bob",
+			require.Nil(t, res)
+			require.ErrorIs(t, err, serverErrors.AuthorizationModelResolutionTooComplex)
 		})
 
-		require.Nil(t, res)
-		require.ErrorIs(t, err, serverErrors.AuthorizationModelResolutionTooComplex)
-	})
+		t.Run("resolution_depth_exceeded_error_streaming", func(t *testing.T) {
+			err := s.StreamedListObjects(&openfgav1.StreamedListObjectsRequest{
+				StoreId:              store,
+				AuthorizationModelId: writeModelResp.GetAuthorizationModelId(),
+				Type:                 "document",
+				Relation:             "viewer",
+				User:                 "user:jon",
+			}, NewMockStreamServer())
 
-	t.Run("error_listing_objects_from_storage_in_streaming_version", func(t *testing.T) {
-		err := s.StreamedListObjects(&openfgav1.StreamedListObjectsRequest{
-			StoreId:              store,
-			AuthorizationModelId: modelID,
-			Type:                 "document",
-			Relation:             "viewer",
-			User:                 "user:bob",
-		}, NewMockStreamServer())
-
-		require.ErrorIs(t, err, serverErrors.AuthorizationModelResolutionTooComplex)
+			require.ErrorIs(t, err, serverErrors.AuthorizationModelResolutionTooComplex)
+		})
 	})
 }
 

programming_language: Go
filename: reverse_expand.go
change_type: ModificationType.MODIFY
diff: @@ -8,8 +8,8 @@ import (
 	parser "github.com/craigpastro/openfga-dsl-parser/v2"
 	"github.com/oklog/ulid/v2"
 	openfgav1 "github.com/openfga/api/proto/openfga/v1"
+	"github.com/openfga/openfga/internal/graph"
 	"github.com/openfga/openfga/pkg/server/commands/reverseexpand"
-	serverErrors "github.com/openfga/openfga/pkg/server/errors"
 	"github.com/openfga/openfga/pkg/storage"
 	"github.com/openfga/openfga/pkg/tuple"
 	"github.com/openfga/openfga/pkg/typesystem"
@@ -357,7 +357,7 @@ func TestReverseExpand(t *testing.T, ds storage.OpenFGADatastore) {
 				tuple.NewTupleKey("folder:folder2", "parent", "folder:folder1"),
 				tuple.NewTupleKey("folder:folder3", "parent", "folder:folder2"),
 			},
-			expectedError:        serverErrors.AuthorizationModelResolutionTooComplex,
+			expectedError:        graph.ErrResolutionDepthExceeded,
 			expectedDSQueryCount: 0,
 		},
 		{

programming_language: Go
filename: write_authzmodel.go
change_type: ModificationType.MODIFY
diff: @@ -2,7 +2,6 @@ package test
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"testing"
 
@@ -11,10 +10,11 @@ import (
 	openfgav1 "github.com/openfga/api/proto/openfga/v1"
 	"github.com/openfga/openfga/pkg/logger"
 	"github.com/openfga/openfga/pkg/server/commands"
-	serverErrors "github.com/openfga/openfga/pkg/server/errors"
 	"github.com/openfga/openfga/pkg/storage"
 	"github.com/openfga/openfga/pkg/typesystem"
 	"github.com/stretchr/testify/require"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
 )
 
 func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastore) {
@@ -46,7 +46,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 		name          string
 		request       *openfgav1.WriteAuthorizationModelRequest
 		allowSchema10 bool
-		err           error
+		errCode       codes.Code
 	}{
 		{
 			name: "fails_if_too_many_types",
@@ -56,7 +56,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				SchemaVersion:   typesystem.SchemaVersion1_1,
 			},
 			allowSchema10: false,
-			err:           serverErrors.ExceededEntityLimit("type definitions in an authorization model", datastore.MaxTypesPerAuthorizationModel()),
+			errCode:       codes.Code(openfgav1.ErrorCode_exceeded_entity_limit),
 		},
 		{
 			name: "fails_if_a_relation_is_not_defined",
@@ -73,7 +73,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				SchemaVersion: typesystem.SchemaVersion1_1,
 			},
 			allowSchema10: false,
-			err:           serverErrors.InvalidAuthorizationModelInput(&typesystem.InvalidRelationError{ObjectType: "repo", Relation: "owner", Cause: typesystem.ErrInvalidUsersetRewrite}),
+			errCode:       codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "Fails_if_type_info_metadata_is_omitted_in_1.1_model",
@@ -90,9 +90,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				},
 			},
 			allowSchema10: false,
-			err: serverErrors.InvalidAuthorizationModelInput(
-				errors.New("the assignable relation 'reader' in object type 'document' must contain at least one relation type"),
-			),
+			errCode:       codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "Fails_if_writing_1_0_model_because_it_will_be_interpreted_as_1_1",
@@ -108,7 +106,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				},
 			},
 			allowSchema10: true,
-			err:           serverErrors.InvalidAuthorizationModelInput(typesystem.AssignableRelationError("document", "reader")),
+			errCode:       codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "Works_if_no_schema_version",
@@ -163,11 +161,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				`),
 				SchemaVersion: typesystem.SchemaVersion1_1,
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(&typesystem.InvalidRelationError{
-				ObjectType: "document",
-				Relation:   "viewer",
-				Cause:      typesystem.ErrNoEntrypoints},
-			),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "self_referencing_type_restriction_without_entrypoint_2",
@@ -182,11 +176,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				`),
 				SchemaVersion: typesystem.SchemaVersion1_1,
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(&typesystem.InvalidRelationError{
-				ObjectType: "document",
-				Relation:   "viewer",
-				Cause:      typesystem.ErrNoEntrypoints,
-			}),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "self_referencing_type_restriction_without_entrypoint_3",
@@ -201,11 +191,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				`),
 				SchemaVersion: typesystem.SchemaVersion1_1,
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(&typesystem.InvalidRelationError{
-				ObjectType: "document",
-				Relation:   "viewer",
-				Cause:      typesystem.ErrNoEntrypoints,
-			}),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "rewritten_relation_in_intersection_unresolvable",
@@ -223,11 +209,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				`),
 				SchemaVersion: typesystem.SchemaVersion1_1,
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(&typesystem.InvalidRelationError{
-				ObjectType: "document",
-				Relation:   "action1",
-				Cause:      typesystem.ErrNoEntryPointsLoop,
-			}),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "direct_relationship_with_entrypoint",
@@ -256,7 +238,6 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				`),
 			},
 		},
-
 		{
 			name: "rewritten_relation_in_exclusion_unresolvable",
 			request: &openfgav1.WriteAuthorizationModelRequest{
@@ -272,11 +253,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				    define action3 as admin but not action1
 				`),
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(&typesystem.InvalidRelationError{
-				ObjectType: "document",
-				Relation:   "action1",
-				Cause:      typesystem.ErrNoEntryPointsLoop,
-			}),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "no_entrypoint_3a",
@@ -291,13 +268,8 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				    define editor: [user] as self
 				`),
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(&typesystem.InvalidRelationError{
-				ObjectType: "document",
-				Relation:   "viewer",
-				Cause:      typesystem.ErrNoEntrypoints,
-			}),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
-
 		{
 			name: "no_entrypoint_3b",
 			request: &openfgav1.WriteAuthorizationModelRequest{
@@ -311,11 +283,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				    define editor: [user] as self
 				`),
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(&typesystem.InvalidRelationError{
-				ObjectType: "document",
-				Relation:   "viewer",
-				Cause:      typesystem.ErrNoEntrypoints,
-			}),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "no_entrypoint_4",
@@ -336,11 +304,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 				    define viewer as editor from parent
 				`),
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(&typesystem.InvalidRelationError{
-				ObjectType: "document",
-				Relation:   "editor",
-				Cause:      typesystem.ErrNoEntrypoints,
-			}),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "self_referencing_type_restriction_with_entrypoint_1",
@@ -404,9 +368,7 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 					},
 				},
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(
-				fmt.Errorf("the type name of a type definition cannot be an empty string"),
-			),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 		{
 			name: "relation_name_is_empty_string",
@@ -424,9 +386,63 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 					},
 				},
 			},
-			err: serverErrors.InvalidAuthorizationModelInput(
-				fmt.Errorf("type 'user' defines a relation with an empty string for a name"),
-			),
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
+		},
+		{
+			name: "many_circular_computed_relations",
+			request: &openfgav1.WriteAuthorizationModelRequest{
+				StoreId: storeID,
+				TypeDefinitions: parser.MustParse(`
+				type user
+
+				type canvas
+				  relations
+					define can_edit as editor or owner
+					define editor: [user, account#member] as self
+					define owner: [user] as self
+					define viewer: [user, account#member] as self
+	  
+				type account
+				  relations
+					define admin: [user] as self or member or super_admin or owner
+					define member: [user] as self or owner or admin or super_admin
+					define owner: [user] as self
+					define super_admin: [user] as self or admin or member
+				`),
+			},
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
+		},
+		{
+			name: "circular_relations_involving_intersection",
+			request: &openfgav1.WriteAuthorizationModelRequest{
+				StoreId: storeID,
+				TypeDefinitions: parser.MustParse(`
+				type user
+
+				type other
+				  relations
+					define x: [user] as self and y
+					define y: [user] as self and z
+					define z: [user] as self or x
+				`),
+			},
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
+		},
+		{
+			name: "circular_relations_involving_exclusion",
+			request: &openfgav1.WriteAuthorizationModelRequest{
+				StoreId: storeID,
+				TypeDefinitions: parser.MustParse(`
+				type user
+
+				type other
+				  relations
+					define x: [user] as self but not y
+					define y: [user] as self but not z
+					define z: [user] as self or x
+				`),
+			},
+			errCode: codes.Code(openfgav1.ErrorCode_invalid_authorization_model),
 		},
 	}
 
@@ -437,7 +453,9 @@ func WriteAuthorizationModelTest(t *testing.T, datastore storage.OpenFGADatastor
 		t.Run(test.name, func(t *testing.T) {
 			cmd := commands.NewWriteAuthorizationModelCommand(datastore, logger)
 			resp, err := cmd.Execute(ctx, test.request)
-			require.ErrorIs(t, err, test.err)
+			status, ok := status.FromError(err)
+			require.True(t, ok)
+			require.Equal(t, test.errCode, status.Code())
 
 			if err == nil {
 				_, err = ulid.Parse(resp.AuthorizationModelId)

programming_language: Go
filename: typesystem.go
change_type: ModificationType.MODIFY
diff: @@ -893,6 +893,19 @@ func (t *TypeSystem) validateRelation(typeName, relationName string, relationMap
 		}
 	}
 
+	hasCycle, err := t.HasCycle(typeName, relationName)
+	if err != nil {
+		return err
+	}
+
+	if hasCycle {
+		return &InvalidRelationError{
+			ObjectType: typeName,
+			Relation:   relationName,
+			Cause:      ErrCycle,
+		}
+	}
+
 	return nil
 }
 
@@ -1197,6 +1210,70 @@ func InvalidRelationTypeError(objectType, relation, relatedObjectType, relatedRe
 	return fmt.Errorf("the relation type '%s' on '%s' in object type '%s' is not valid", relationType, relation, objectType)
 }
 
+func (t *TypeSystem) hasCycle(
+	objectType, relationName string,
+	rewrite *openfgav1.Userset,
+	visited map[string]struct{},
+) (bool, error) {
+
+	visited[fmt.Sprintf("%s#%s", objectType, relationName)] = struct{}{}
+
+	visitedCopy := maps.Clone(visited)
+
+	var children []*openfgav1.Userset
+
+	switch rw := rewrite.Userset.(type) {
+	case *openfgav1.Userset_This, *openfgav1.Userset_TupleToUserset:
+		return false, nil
+	case *openfgav1.Userset_ComputedUserset:
+		rewrittenRelation := rw.ComputedUserset.Relation
+
+		if _, ok := visited[fmt.Sprintf("%s#%s", objectType, rewrittenRelation)]; ok {
+			return true, nil
+		}
+
+		rewrittenRewrite, err := t.GetRelation(objectType, rewrittenRelation)
+		if err != nil {
+			return false, err
+		}
+
+		return t.hasCycle(objectType, rewrittenRelation, rewrittenRewrite.GetRewrite(), visitedCopy)
+	case *openfgav1.Userset_Union:
+		children = append(children, rw.Union.GetChild()...)
+	case *openfgav1.Userset_Intersection:
+		children = append(children, rw.Intersection.GetChild()...)
+	case *openfgav1.Userset_Difference:
+		children = append(children, rw.Difference.GetBase(), rw.Difference.GetSubtract())
+	}
+
+	for _, child := range children {
+
+		hasCycle, err := t.hasCycle(objectType, relationName, child, visitedCopy)
+		if err != nil {
+			return false, err
+		}
+
+		if hasCycle {
+			return true, nil
+		}
+	}
+
+	return false, nil
+}
+
+// HasCycle runs a cycle detection test on the provided `objectType#relation` to see if the relation
+// defines a rewrite rule that is self-referencing in any way (through computed relationships).
+func (t *TypeSystem) HasCycle(objectType, relationName string) (bool, error) {
+	visited := map[string]struct{}{}
+
+	relation, err := t.GetRelation(objectType, relationName)
+	if err != nil {
+		return false, err
+	}
+
+	return t.hasCycle(objectType, relationName, relation.GetRewrite(), visited)
+}
+
 // getAllTupleToUsersetsDefinitions returns a map where the key is the object type and the value
 // is another map where key=relationName, value=list of tuple to usersets declared in that relation
 func (t *TypeSystem) getAllTupleToUsersetsDefinitions() map[string]map[string][]*openfgav1.TupleToUserset {

programming_language: Go
filename: typesystem_test.go
change_type: ModificationType.MODIFY
diff: @@ -9,6 +9,164 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func TestHasCycle(t *testing.T) {
+
+	tests := []struct {
+		name       string
+		model      string
+		objectType string
+		relation   string
+		expected   bool
+	}{
+		{
+			name: "test_1",
+			model: `
+			type resource
+			  relations
+			    define x as y
+			    define y as x
+			`,
+			objectType: "resource",
+			relation:   "x",
+			expected:   true,
+		},
+		{
+			name: "test_2",
+			model: `
+			type resource
+			  relations
+			    define x as y
+			    define y as z
+				define z as x
+			`,
+			objectType: "resource",
+			relation:   "y",
+			expected:   true,
+		},
+		{
+			name: "test_3",
+			model: `
+			type user
+
+			type resource
+			  relations
+			    define x: [user] as self or y
+			    define y: [user] as self or z
+				define z: [user] as self or x
+			`,
+			objectType: "resource",
+			relation:   "z",
+			expected:   true,
+		},
+		{
+			name: "test_4",
+			model: `
+			type user
+
+			type resource
+			  relations
+			    define x: [user] as self or y
+			    define y: [user] as self or z
+				define z: [user] as self or x
+			`,
+			objectType: "resource",
+			relation:   "z",
+			expected:   true,
+		},
+		{
+			name: "test_5",
+			model: `
+			type user
+
+			type resource
+			  relations
+				define x: [user] as self but not y
+				define y: [user] as self but not z
+				define z: [user] as self or x
+			`,
+			objectType: "resource",
+			relation:   "x",
+			expected:   true,
+		},
+		{
+			name: "test_6",
+			model: `
+			type user
+
+			type group
+			  relations
+				define member: [user] as self or memberA or memberB or memberC
+				define memberA: [user] as self or member or memberB or memberC
+				define memberB: [user] as self or member or memberA or memberC
+				define memberC: [user] as self or member or memberA or memberB
+			`,
+			objectType: "group",
+			relation:   "member",
+			expected:   true,
+		},
+		{
+			name: "test_7",
+			model: `
+			type user
+
+			type account
+			relations
+				define admin: [user] as self or member or super_admin or owner
+				define member: [user] as self or owner or admin or super_admin
+				define super_admin: [user] as self or admin or member or owner
+				define owner: [user] as self
+			`,
+			objectType: "account",
+			relation:   "member",
+			expected:   true,
+		},
+		{
+			name: "test_8",
+			model: `
+			type user
+
+			type account
+			relations
+				define admin: [user] as self or member or super_admin or owner
+				define member: [user] as self or owner or admin or super_admin
+				define super_admin: [user] as self or admin or member or owner
+				define owner: [user] as self
+			`,
+			objectType: "account",
+			relation:   "owner",
+			expected:   false,
+		},
+		{
+			name: "test_9",
+			model: `
+			type user
+
+			type document
+			  relations
+				define editor: [user] as self
+				define viewer: [document#viewer] as self or editor
+			`,
+			objectType: "document",
+			relation:   "viewer",
+			expected:   false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+
+			typesys := New(&openfgav1.AuthorizationModel{
+				SchemaVersion:   SchemaVersion1_1,
+				TypeDefinitions: parser.MustParse(test.model),
+			})
+
+			hasCycle, err := typesys.HasCycle(test.objectType, test.relation)
+			require.Equal(t, test.expected, hasCycle)
+			require.NoError(t, err)
+		})
+	}
+}
+
 func TestNewAndValidate(t *testing.T) {
 
 	tests := []struct {

programming_language: Go