diff --git a/internal/engines/check_test.go b/internal/engines/check_test.go index eea6b229b..2e164803e 100644 --- a/internal/engines/check_test.go +++ b/internal/engines/check_test.go @@ -2372,4 +2372,221 @@ var _ = Describe("check-engine", func() { } }) }) + + Context("Recursive Attribute Permissions", func() { + It("should allow same-type recursive attribute permissions", func() { + schema := ` + entity user {} + + entity resource { + relation parent @resource + attribute is_public boolean + permission view = is_public or parent.view + } + ` + + db, err := factories.DatabaseFactory(config.Database{Engine: "memory"}) + Expect(err).ShouldNot(HaveOccurred()) + + conf, err := newSchema(schema) + Expect(err).ShouldNot(HaveOccurred()) + + schemaWriter := factories.SchemaWriterFactory(db) + err = schemaWriter.WriteSchema(context.Background(), conf) + Expect(err).ShouldNot(HaveOccurred()) + + schemaReader := factories.SchemaReaderFactory(db) + dataReader := factories.DataReaderFactory(db) + dataWriter := factories.DataWriterFactory(db) + + checkEngine := NewCheckEngine(schemaReader, dataReader) + lookupEngine := NewLookupEngine(checkEngine, schemaReader, dataReader) + invoker := invoke.NewDirectInvoker(schemaReader, dataReader, checkEngine, nil, lookupEngine, nil) + checkEngine.SetInvoker(invoker) + + relationships := []string{ + "resource:r1#parent@resource:default", + } + + var tuples []*base.Tuple + for _, relationship := range relationships { + t, err := tuple.Tuple(relationship) + Expect(err).ShouldNot(HaveOccurred()) + tuples = append(tuples, t) + } + + publicAttr, err := attribute.Attribute("resource:default$is_public|boolean:true") + Expect(err).ShouldNot(HaveOccurred()) + + _, err = dataWriter.Write( + context.Background(), + "t1", + database.NewTupleCollection(tuples...), + database.NewAttributeCollection(publicAttr), + ) + Expect(err).ShouldNot(HaveOccurred()) + + resp, err := invoker.Check(context.Background(), &base.PermissionCheckRequest{ + TenantId: "t1", + Entity: &base.Entity{Type: "resource", Id: "r1"}, + Permission: "view", + Subject: &base.Subject{Type: "user", Id: "u1"}, + Metadata: &base.PermissionCheckRequestMetadata{ + SnapToken: token.NewNoopToken().Encode().String(), + SchemaVersion: "", + Depth: 20, + }, + }) + Expect(err).ShouldNot(HaveOccurred()) + Expect(resp.GetCan()).To(Equal(base.CheckResult_CHECK_RESULT_ALLOWED)) + }) + + It("should allow cross-type recursive attribute permissions", func() { + schema := ` + entity user {} + + entity org { + attribute is_public boolean + permission view = is_public + } + + entity folder { + relation parent @org + attribute is_public boolean + permission view = is_public or parent.view + } + + entity resource { + relation parent @folder + permission view = parent.view + } + ` + + db, err := factories.DatabaseFactory(config.Database{Engine: "memory"}) + Expect(err).ShouldNot(HaveOccurred()) + + conf, err := newSchema(schema) + Expect(err).ShouldNot(HaveOccurred()) + + schemaWriter := factories.SchemaWriterFactory(db) + err = schemaWriter.WriteSchema(context.Background(), conf) + Expect(err).ShouldNot(HaveOccurred()) + + schemaReader := factories.SchemaReaderFactory(db) + dataReader := factories.DataReaderFactory(db) + dataWriter := factories.DataWriterFactory(db) + + checkEngine := NewCheckEngine(schemaReader, dataReader) + lookupEngine := NewLookupEngine(checkEngine, schemaReader, dataReader) + invoker := invoke.NewDirectInvoker(schemaReader, dataReader, checkEngine, nil, lookupEngine, nil) + checkEngine.SetInvoker(invoker) + + relationships := []string{ + "folder:f1#parent@org:o1", + "resource:r1#parent@folder:f1", + } + + var tuples []*base.Tuple + for _, relationship := range relationships { + t, err := tuple.Tuple(relationship) + Expect(err).ShouldNot(HaveOccurred()) + tuples = append(tuples, t) + } + + publicAttr, err := attribute.Attribute("org:o1$is_public|boolean:true") + Expect(err).ShouldNot(HaveOccurred()) + + _, err = dataWriter.Write( + context.Background(), + "t1", + database.NewTupleCollection(tuples...), + database.NewAttributeCollection(publicAttr), + ) + Expect(err).ShouldNot(HaveOccurred()) + + resp, err := invoker.Check(context.Background(), &base.PermissionCheckRequest{ + TenantId: "t1", + Entity: &base.Entity{Type: "resource", Id: "r1"}, + Permission: "view", + Subject: &base.Subject{Type: "user", Id: "u1"}, + Metadata: &base.PermissionCheckRequestMetadata{ + SnapToken: token.NewNoopToken().Encode().String(), + SchemaVersion: "", + Depth: 20, + }, + }) + Expect(err).ShouldNot(HaveOccurred()) + Expect(resp.GetCan()).To(Equal(base.CheckResult_CHECK_RESULT_ALLOWED)) + }) + + It("should allow mixed-entrance recursive attribute permissions", func() { + schema := ` + entity user {} + + entity resource { + relation viewer @user + relation parent @resource + attribute is_public boolean + permission view = viewer or is_public or parent.view + } + ` + + db, err := factories.DatabaseFactory(config.Database{Engine: "memory"}) + Expect(err).ShouldNot(HaveOccurred()) + + conf, err := newSchema(schema) + Expect(err).ShouldNot(HaveOccurred()) + + schemaWriter := factories.SchemaWriterFactory(db) + err = schemaWriter.WriteSchema(context.Background(), conf) + Expect(err).ShouldNot(HaveOccurred()) + + schemaReader := factories.SchemaReaderFactory(db) + dataReader := factories.DataReaderFactory(db) + dataWriter := factories.DataWriterFactory(db) + + checkEngine := NewCheckEngine(schemaReader, dataReader) + lookupEngine := NewLookupEngine(checkEngine, schemaReader, dataReader) + invoker := invoke.NewDirectInvoker(schemaReader, dataReader, checkEngine, nil, lookupEngine, nil) + checkEngine.SetInvoker(invoker) + + relationships := []string{ + "resource:za#viewer@user:u1", + "resource:zb#parent@resource:za", + "resource:zc#parent@resource:zb", + } + + var tuples []*base.Tuple + for _, relationship := range relationships { + t, err := tuple.Tuple(relationship) + Expect(err).ShouldNot(HaveOccurred()) + tuples = append(tuples, t) + } + + publicAttr, err := attribute.Attribute("resource:za$is_public|boolean:true") + Expect(err).ShouldNot(HaveOccurred()) + + _, err = dataWriter.Write( + context.Background(), + "t1", + database.NewTupleCollection(tuples...), + database.NewAttributeCollection(publicAttr), + ) + Expect(err).ShouldNot(HaveOccurred()) + + resp, err := invoker.Check(context.Background(), &base.PermissionCheckRequest{ + TenantId: "t1", + Entity: &base.Entity{Type: "resource", Id: "zc"}, + Permission: "view", + Subject: &base.Subject{Type: "user", Id: "u1"}, + Metadata: &base.PermissionCheckRequestMetadata{ + SnapToken: token.NewNoopToken().Encode().String(), + SchemaVersion: "", + Depth: 20, + }, + }) + Expect(err).ShouldNot(HaveOccurred()) + Expect(resp.GetCan()).To(Equal(base.CheckResult_CHECK_RESULT_ALLOWED)) + }) + }) }) diff --git a/internal/engines/entity_filter.go b/internal/engines/entity_filter.go index e3593847f..ef0af4cfb 100644 --- a/internal/engines/entity_filter.go +++ b/internal/engines/entity_filter.go @@ -3,16 +3,21 @@ package engines import ( "context" "errors" + "fmt" "golang.org/x/sync/errgroup" "github.com/Permify/permify/internal/schema" "github.com/Permify/permify/internal/storage" storageContext "github.com/Permify/permify/internal/storage/context" + tokenutils "github.com/Permify/permify/internal/storage/context/utils" "github.com/Permify/permify/pkg/database" base "github.com/Permify/permify/pkg/pb/base/v1" ) +// _maxBFSDepth bounds same-type recursive relation expansion in entity lookup. +const _maxBFSDepth = 100 + // EntityFilter is a struct that performs permission checks on a set of entities type EntityFilter struct { // dataReader is responsible for reading relationship information @@ -148,6 +153,14 @@ func (engine *EntityFilter) attributeEntrance( Attributes: []string{entrance.TargetEntrance.GetValue()}, } + selfCycleRelations := engine.graph.SelfCycleRelationsForPermission( + request.GetEntrance().GetType(), + request.GetEntrance().GetValue(), + ) + + expandRecursive := request.GetEntrance().GetType() == entrance.TargetEntrance.GetType() && + len(selfCycleRelations) > 0 + pagination := database.NewCursorPagination(database.Cursor(request.GetCursor()), database.Sort("entity_id")) cti, err := storageContext.NewContextualAttributes(request.GetContext().GetAttributes()...).QueryAttributes(filter, pagination) @@ -162,6 +175,9 @@ func (engine *EntityFilter) attributeEntrance( it := database.NewUniqueAttributeIterator(rit, cti) + var attributeEntityIDs []string + attributeEntityIDSet := make(map[string]struct{}) + // Publish entities directly for regular case for it.HasNext() { current, ok := it.GetNext() @@ -174,6 +190,13 @@ func (engine *EntityFilter) attributeEntrance( Id: current.GetEntity().GetId(), } + if expandRecursive { + if _, ok := attributeEntityIDSet[entity.GetId()]; !ok { + attributeEntityIDSet[entity.GetId()] = struct{}{} + attributeEntityIDs = append(attributeEntityIDs, entity.GetId()) + } + } + if !visits.AddPublished(entity) { continue } @@ -185,6 +208,172 @@ func (engine *EntityFilter) attributeEntrance( }, request.GetContext(), base.CheckResult_CHECK_RESULT_UNSPECIFIED) } + // For same-type recursive permissions, collect recursion seeds + // without cursor filtering so descendant entities remain reachable on later pages. + if expandRecursive && request.GetCursor() != "" { + seedPagination := database.NewCursorPagination(database.Sort("entity_id")) + seedCTI, err := storageContext.NewContextualAttributes(request.GetContext().GetAttributes()...).QueryAttributes(filter, seedPagination) + if err != nil { + return err + } + + seedRIT, err := engine.dataReader.QueryAttributes(ctx, request.GetTenantId(), filter, request.GetMetadata().GetSnapToken(), seedPagination) + if err != nil { + return err + } + + seedIt := database.NewUniqueAttributeIterator(seedRIT, seedCTI) + for seedIt.HasNext() { + current, ok := seedIt.GetNext() + if !ok { + break + } + + id := current.GetEntity().GetId() + if _, ok := attributeEntityIDSet[id]; ok { + continue + } + attributeEntityIDSet[id] = struct{}{} + attributeEntityIDs = append(attributeEntityIDs, id) + } + } + + // Expand recursive relations for same-type attribute permissions + if expandRecursive && len(attributeEntityIDs) > 0 { + for _, relation := range selfCycleRelations { + err := engine.expandRecursiveRelation(ctx, request, entrance.TargetEntrance.GetType(), relation, attributeEntityIDs, visits, publisher) + if err != nil { + return err + } + } + } + + return nil +} + +// decodeCursorValue decodes a cursor token and returns its underlying value. +// It returns an empty string when the cursor is empty. +// If decoding fails, the error is returned. +// If the decoded token is not a ContinuousToken, it returns an empty string. +func decodeCursorValue(cursor string) (string, error) { + if cursor == "" { + return "", nil + } + t, err := tokenutils.EncodedContinuousToken{Value: cursor}.Decode() + if err != nil { + return "", err + } + decoded, ok := t.(tokenutils.ContinuousToken) + if !ok { + return "", nil + } + return decoded.Value, nil +} + +// expandRecursiveRelation publishes all entities reachable from seed subjects via a relation, +// walking the relation transitively (self-recursive permissions). +func (engine *EntityFilter) expandRecursiveRelation( + ctx context.Context, + request *base.PermissionEntityFilterRequest, + entityType string, + relation string, + seedSubjectIDs []string, + visits *VisitsMap, + publisher *BulkEntityPublisher, +) error { + if len(seedSubjectIDs) == 0 { + return nil + } + + cursorValue := "" + if request.GetEntrance().GetType() == entityType && request.GetCursor() != "" { + var err error + cursorValue, err = decodeCursorValue(request.GetCursor()) + if err != nil { + return err + } + } + + scope, exists := request.GetScope()[entityType] + var data []string + if exists { + data = scope.GetData() + } + + seen := make(map[string]struct{}, len(seedSubjectIDs)) + queue := make([]string, 0, len(seedSubjectIDs)) + for _, id := range seedSubjectIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + queue = append(queue, id) + } + + hops := 0 + for len(queue) > 0 { + if hops >= _maxBFSDepth { + return fmt.Errorf("recursive relation expansion exceeded maximum depth (%d)", _maxBFSDepth) + } + hops++ + + currentIDs := queue + queue = nil + + filter := &base.TupleFilter{ + Entity: &base.EntityFilter{ + Type: entityType, + Ids: data, + }, + Relation: relation, + Subject: &base.SubjectFilter{ + Type: entityType, + Ids: currentIDs, + Relation: "", + }, + } + + pagination := database.NewCursorPagination() + cti, err := storageContext.NewContextualTuples(request.GetContext().GetTuples()...).QueryRelationships(filter, pagination) + if err != nil { + return err + } + + rit, err := engine.dataReader.QueryRelationships(ctx, request.GetTenantId(), filter, request.GetMetadata().GetSnapToken(), pagination) + if err != nil { + return err + } + + it := database.NewUniqueTupleIterator(rit, cti) + for it.HasNext() { + current, ok := it.GetNext() + if !ok { + break + } + + entity := &base.Entity{ + Type: current.GetEntity().GetType(), + Id: current.GetEntity().GetId(), + } + + if cursorValue == "" || entity.GetId() >= cursorValue { + if visits.AddPublished(entity) { + publisher.Publish(entity, &base.PermissionCheckRequestMetadata{ + SnapToken: request.GetMetadata().GetSnapToken(), + SchemaVersion: request.GetMetadata().GetSchemaVersion(), + Depth: request.GetMetadata().GetDepth(), + }, request.GetContext(), base.CheckResult_CHECK_RESULT_UNSPECIFIED) + } + } + + if _, ok := seen[entity.GetId()]; ok { + continue + } + seen[entity.GetId()] = struct{}{} + queue = append(queue, entity.GetId()) + } + } + return nil } @@ -528,7 +717,7 @@ func (engine *EntityFilter) pathChainEntrance( Subject: &base.SubjectFilter{ Type: currentType, Ids: currentIds, - Relation: subjectRelation, // Fixed: Use correct subject relation for complex cases + Relation: subjectRelation, // Preserve subject relation for references like @group#member. }, } diff --git a/internal/engines/lookup_test.go b/internal/engines/lookup_test.go index 560c7821c..adb0a74fd 100644 --- a/internal/engines/lookup_test.go +++ b/internal/engines/lookup_test.go @@ -6189,4 +6189,400 @@ entity group_perms { Expect(resp.GetEntityIds()).Should(ContainElements("alice", "bob")) }) }) + + Context("Recursive Attribute Lookup", func() { + It("should include same-type recursive attribute permissions", func() { + schema := ` + entity user {} + + entity resource { + relation parent @resource + attribute is_public boolean + permission view = is_public or parent.view + } + ` + + db, err := factories.DatabaseFactory(config.Database{Engine: "memory"}) + Expect(err).ShouldNot(HaveOccurred()) + + conf, err := newSchema(schema) + Expect(err).ShouldNot(HaveOccurred()) + + schemaWriter := factories.SchemaWriterFactory(db) + err = schemaWriter.WriteSchema(context.Background(), conf) + Expect(err).ShouldNot(HaveOccurred()) + + schemaReader := factories.SchemaReaderFactory(db) + dataReader := factories.DataReaderFactory(db) + dataWriter := factories.DataWriterFactory(db) + + relationships := []string{ + "resource:r1#parent@resource:default", + } + + var tuples []*base.Tuple + for _, relationship := range relationships { + t, err := tuple.Tuple(relationship) + Expect(err).ShouldNot(HaveOccurred()) + tuples = append(tuples, t) + } + + publicAttr, err := attribute.Attribute("resource:default$is_public|boolean:true") + Expect(err).ShouldNot(HaveOccurred()) + + _, err = dataWriter.Write( + context.Background(), + "t1", + database.NewTupleCollection(tuples...), + database.NewAttributeCollection(publicAttr), + ) + Expect(err).ShouldNot(HaveOccurred()) + + checkEngine := NewCheckEngine(schemaReader, dataReader) + lookupEngine := NewLookupEngine(checkEngine, schemaReader, dataReader) + invoker := invoke.NewDirectInvoker(schemaReader, dataReader, checkEngine, nil, lookupEngine, nil) + checkEngine.SetInvoker(invoker) + + resp, err := invoker.LookupEntity(context.Background(), &base.PermissionLookupEntityRequest{ + TenantId: "t1", + EntityType: "resource", + Subject: &base.Subject{ + Type: "user", + Id: "u1", + }, + Permission: "view", + Metadata: &base.PermissionLookupEntityRequestMetadata{ + SnapToken: token.NewNoopToken().Encode().String(), + SchemaVersion: "", + Depth: 20, + }, + }) + Expect(err).ShouldNot(HaveOccurred()) + Expect(resp.GetEntityIds()).To(ConsistOf("default", "r1")) + }) + + It("should include cross-type recursive attribute permissions", func() { + schema := ` + entity user {} + + entity org { + attribute is_public boolean + permission view = is_public + } + + entity folder { + relation parent @org + attribute is_public boolean + permission view = is_public or parent.view + } + + entity resource { + relation parent @folder + permission view = parent.view + } + ` + + db, err := factories.DatabaseFactory(config.Database{Engine: "memory"}) + Expect(err).ShouldNot(HaveOccurred()) + + conf, err := newSchema(schema) + Expect(err).ShouldNot(HaveOccurred()) + + schemaWriter := factories.SchemaWriterFactory(db) + err = schemaWriter.WriteSchema(context.Background(), conf) + Expect(err).ShouldNot(HaveOccurred()) + + schemaReader := factories.SchemaReaderFactory(db) + dataReader := factories.DataReaderFactory(db) + dataWriter := factories.DataWriterFactory(db) + + relationships := []string{ + "folder:f1#parent@org:o1", + "resource:r1#parent@folder:f1", + } + + var tuples []*base.Tuple + for _, relationship := range relationships { + t, err := tuple.Tuple(relationship) + Expect(err).ShouldNot(HaveOccurred()) + tuples = append(tuples, t) + } + + publicAttr, err := attribute.Attribute("org:o1$is_public|boolean:true") + Expect(err).ShouldNot(HaveOccurred()) + + _, err = dataWriter.Write( + context.Background(), + "t1", + database.NewTupleCollection(tuples...), + database.NewAttributeCollection(publicAttr), + ) + Expect(err).ShouldNot(HaveOccurred()) + + checkEngine := NewCheckEngine(schemaReader, dataReader) + lookupEngine := NewLookupEngine(checkEngine, schemaReader, dataReader) + invoker := invoke.NewDirectInvoker(schemaReader, dataReader, checkEngine, nil, lookupEngine, nil) + checkEngine.SetInvoker(invoker) + + resp, err := invoker.LookupEntity(context.Background(), &base.PermissionLookupEntityRequest{ + TenantId: "t1", + EntityType: "resource", + Subject: &base.Subject{ + Type: "user", + Id: "u1", + }, + Permission: "view", + Metadata: &base.PermissionLookupEntityRequestMetadata{ + SnapToken: token.NewNoopToken().Encode().String(), + SchemaVersion: "", + Depth: 20, + }, + }) + Expect(err).ShouldNot(HaveOccurred()) + Expect(resp.GetEntityIds()).To(ConsistOf("r1")) + }) + + It("should paginate same-type recursive attribute permissions across pages", func() { + schema := ` + entity user {} + + entity resource { + relation parent @resource + attribute is_public boolean + permission view = is_public or parent.view + } + ` + + db, err := factories.DatabaseFactory(config.Database{Engine: "memory"}) + Expect(err).ShouldNot(HaveOccurred()) + + conf, err := newSchema(schema) + Expect(err).ShouldNot(HaveOccurred()) + + schemaWriter := factories.SchemaWriterFactory(db) + err = schemaWriter.WriteSchema(context.Background(), conf) + Expect(err).ShouldNot(HaveOccurred()) + + schemaReader := factories.SchemaReaderFactory(db) + dataReader := factories.DataReaderFactory(db) + dataWriter := factories.DataWriterFactory(db) + + relationships := []string{ + "resource:zb#parent@resource:za", + "resource:zc#parent@resource:zb", + } + + var tuples []*base.Tuple + for _, relationship := range relationships { + t, err := tuple.Tuple(relationship) + Expect(err).ShouldNot(HaveOccurred()) + tuples = append(tuples, t) + } + + publicAttr, err := attribute.Attribute("resource:za$is_public|boolean:true") + Expect(err).ShouldNot(HaveOccurred()) + + _, err = dataWriter.Write( + context.Background(), + "t1", + database.NewTupleCollection(tuples...), + database.NewAttributeCollection(publicAttr), + ) + Expect(err).ShouldNot(HaveOccurred()) + + checkEngine := NewCheckEngine(schemaReader, dataReader) + lookupEngine := NewLookupEngine(checkEngine, schemaReader, dataReader) + invoker := invoke.NewDirectInvoker(schemaReader, dataReader, checkEngine, nil, lookupEngine, nil) + checkEngine.SetInvoker(invoker) + + ct := "" + var ids []string + + for { + resp, err := invoker.LookupEntity(context.Background(), &base.PermissionLookupEntityRequest{ + TenantId: "t1", + EntityType: "resource", + Subject: &base.Subject{ + Type: "user", + Id: "u1", + }, + Permission: "view", + Metadata: &base.PermissionLookupEntityRequestMetadata{ + SnapToken: token.NewNoopToken().Encode().String(), + SchemaVersion: "", + Depth: 20, + }, + PageSize: 1, + ContinuousToken: ct, + }) + Expect(err).ShouldNot(HaveOccurred()) + ids = append(ids, resp.GetEntityIds()...) + ct = resp.GetContinuousToken() + if ct == "" { + break + } + } + + Expect(ids).To(ConsistOf("za", "zb", "zc")) + Expect(ids).To(HaveLen(3)) + }) + + It("should expand recursion when root is already allowed via another entrance", func() { + schema := ` + entity user {} + + entity resource { + relation viewer @user + relation parent @resource + attribute is_public boolean + permission view = viewer or is_public or parent.view + } + ` + + db, err := factories.DatabaseFactory(config.Database{Engine: "memory"}) + Expect(err).ShouldNot(HaveOccurred()) + + conf, err := newSchema(schema) + Expect(err).ShouldNot(HaveOccurred()) + + schemaWriter := factories.SchemaWriterFactory(db) + err = schemaWriter.WriteSchema(context.Background(), conf) + Expect(err).ShouldNot(HaveOccurred()) + + schemaReader := factories.SchemaReaderFactory(db) + dataReader := factories.DataReaderFactory(db) + dataWriter := factories.DataWriterFactory(db) + + relationships := []string{ + "resource:za#viewer@user:u1", + "resource:zb#parent@resource:za", + "resource:zc#parent@resource:zb", + } + + var tuples []*base.Tuple + for _, relationship := range relationships { + t, err := tuple.Tuple(relationship) + Expect(err).ShouldNot(HaveOccurred()) + tuples = append(tuples, t) + } + + publicAttr, err := attribute.Attribute("resource:za$is_public|boolean:true") + Expect(err).ShouldNot(HaveOccurred()) + + _, err = dataWriter.Write( + context.Background(), + "t1", + database.NewTupleCollection(tuples...), + database.NewAttributeCollection(publicAttr), + ) + Expect(err).ShouldNot(HaveOccurred()) + + checkEngine := NewCheckEngine(schemaReader, dataReader) + lookupEngine := NewLookupEngine(checkEngine, schemaReader, dataReader) + invoker := invoke.NewDirectInvoker(schemaReader, dataReader, checkEngine, nil, lookupEngine, nil) + checkEngine.SetInvoker(invoker) + + resp, err := invoker.LookupEntity(context.Background(), &base.PermissionLookupEntityRequest{ + TenantId: "t1", + EntityType: "resource", + Subject: &base.Subject{ + Type: "user", + Id: "u1", + }, + Permission: "view", + Metadata: &base.PermissionLookupEntityRequestMetadata{ + SnapToken: token.NewNoopToken().Encode().String(), + SchemaVersion: "", + Depth: 20, + }, + }) + Expect(err).ShouldNot(HaveOccurred()) + Expect(resp.GetEntityIds()).To(ConsistOf("za", "zb", "zc")) + }) + + It("should stop same-type recursive attribute expansion at the BFS depth limit", func() { + schema := ` + entity user {} + + entity resource { + relation parent @resource + attribute is_public boolean + permission view = is_public or parent.view + } + ` + + db, err := factories.DatabaseFactory(config.Database{Engine: "memory"}) + Expect(err).ShouldNot(HaveOccurred()) + + conf, err := newSchema(schema) + Expect(err).ShouldNot(HaveOccurred()) + + schemaWriter := factories.SchemaWriterFactory(db) + err = schemaWriter.WriteSchema(context.Background(), conf) + Expect(err).ShouldNot(HaveOccurred()) + + schemaReader := factories.SchemaReaderFactory(db) + dataReader := factories.DataReaderFactory(db) + dataWriter := factories.DataWriterFactory(db) + + var tuples []*base.Tuple + for i := 1; i <= _maxBFSDepth; i++ { + t, err := tuple.Tuple(fmt.Sprintf("resource:r%d#parent@resource:r%d", i, i-1)) + Expect(err).ShouldNot(HaveOccurred()) + tuples = append(tuples, t) + } + + publicAttr, err := attribute.Attribute("resource:r0$is_public|boolean:true") + Expect(err).ShouldNot(HaveOccurred()) + + _, err = dataWriter.Write( + context.Background(), + "t1", + database.NewTupleCollection(tuples...), + database.NewAttributeCollection(publicAttr), + ) + Expect(err).ShouldNot(HaveOccurred()) + + checkEngine := NewCheckEngine(schemaReader, dataReader) + lookupEngine := NewLookupEngine(checkEngine, schemaReader, dataReader) + invoker := invoke.NewDirectInvoker(schemaReader, dataReader, checkEngine, nil, lookupEngine, nil) + checkEngine.SetInvoker(invoker) + + _, err = invoker.LookupEntity(context.Background(), &base.PermissionLookupEntityRequest{ + TenantId: "t1", + EntityType: "resource", + Subject: &base.Subject{ + Type: "user", + Id: "u1", + }, + Permission: "view", + Metadata: &base.PermissionLookupEntityRequestMetadata{ + SnapToken: token.NewNoopToken().Encode().String(), + SchemaVersion: "", + Depth: 20, + }, + }) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("recursive relation expansion exceeded maximum depth")) + }) + }) + + Context("Entity Filter Cursor", func() { + It("decodes empty cursor without error", func() { + value, err := decodeCursorValue("") + Expect(err).ShouldNot(HaveOccurred()) + Expect(value).To(Equal("")) + }) + + It("returns error for invalid cursor", func() { + _, err := decodeCursorValue("not-a-valid-base64") + Expect(err).Should(HaveOccurred()) + }) + + It("decodes a valid cursor value", func() { + value, err := decodeCursorValue("YWJj") + Expect(err).ShouldNot(HaveOccurred()) + Expect(value).To(Equal("abc")) + }) + }) }) diff --git a/internal/schema/linked_schema.go b/internal/schema/linked_schema.go index 5aa6c0ac5..2bf1252e3 100644 --- a/internal/schema/linked_schema.go +++ b/internal/schema/linked_schema.go @@ -270,7 +270,24 @@ func (g *LinkedSchemaGraph) findEntranceLeaf(target, source *base.Entrance, leaf var filteredResults []*LinkedEntrance for _, result := range results { - if result.Kind == AttributeLinkedEntrance && target.GetType() != result.TargetEntrance.GetType() { + if target.GetType() != result.TargetEntrance.GetType() && + (result.Kind == AttributeLinkedEntrance || result.Kind == PathChainLinkedEntrance) { + if result.Kind == PathChainLinkedEntrance && len(result.PathChain) > 0 { + // Compose the existing path chain with the tuple-set relation to preserve the exact path. + pathChain := make([]*base.RelationReference, 0, len(result.PathChain)+1) + pathChain = append(pathChain, &base.RelationReference{ + Type: target.GetType(), + Relation: tupleSet, + }) + pathChain = append(pathChain, result.PathChain...) + res = append(res, &LinkedEntrance{ + Kind: PathChainLinkedEntrance, + TargetEntrance: result.TargetEntrance, + TupleSetRelation: "", + PathChain: pathChain, + }) + continue + } cacheKey := target.GetType() + "->" + result.TargetEntrance.GetType() var pathChain []*base.RelationReference @@ -293,14 +310,11 @@ func (g *LinkedSchemaGraph) findEntranceLeaf(target, source *base.Entrance, leaf PathChain: pathChain, }) // Skip adding AttributeLinkedEntrance for cases with PathChain - } else { - // No PathChain, keep AttributeLinkedEntrance - filteredResults = append(filteredResults, result) + continue } - } else { - // Non-nested or other types: keep as-is - filteredResults = append(filteredResults, result) } + // Non-nested or other types: keep as-is + filteredResults = append(filteredResults, result) } res = append(res, filteredResults...) @@ -497,3 +511,79 @@ func (g *LinkedSchemaGraph) GetSubjectRelationForPathWalk(leftEntityType, relati } return "" } + +// SelfCycleRelationsForPermission returns tuple-set relations that cause a permission +// to reference itself (e.g., view = parent.view). +func (g *LinkedSchemaGraph) SelfCycleRelationsForPermission(entityType, permission string) []string { + entityDef, exists := g.schema.EntityDefinitions[entityType] + if !exists { + return nil + } + + permDef, exists := entityDef.Permissions[permission] + if !exists { + return nil + } + + seen := make(map[string]struct{}) + res := make([]string, 0) + + child := permDef.GetChild() + if child == nil { + return nil + } + + g.collectSelfCycleRelations(entityType, permission, child, seen, &res) + return res +} + +func (g *LinkedSchemaGraph) collectSelfCycleRelations(entityType, permission string, child *base.Child, seen map[string]struct{}, res *[]string) { + if child == nil { + return + } + + if child.GetRewrite() != nil { + for _, c := range child.GetRewrite().GetChildren() { + g.collectSelfCycleRelations(entityType, permission, c, seen, res) + } + return + } + + leaf := child.GetLeaf() + if leaf == nil { + return + } + + switch t := leaf.GetType().(type) { + case *base.Leaf_TupleToUserSet: + tupleSet := t.TupleToUserSet.GetTupleSet().GetRelation() + computed := t.TupleToUserSet.GetComputed().GetRelation() + if tupleSet == "" || computed == "" { + return + } + if computed != permission { + return + } + if _, ok := seen[tupleSet]; ok { + return + } + entityDef, exists := g.schema.EntityDefinitions[entityType] + if !exists { + return + } + relDef, exists := entityDef.Relations[tupleSet] + if !exists { + return + } + // Only include relations that point back to the same entity type. + for _, ref := range relDef.GetRelationReferences() { + if ref.GetType() == entityType { + seen[tupleSet] = struct{}{} + *res = append(*res, tupleSet) + return + } + } + case *base.Leaf_ComputedUserSet, *base.Leaf_ComputedAttribute, *base.Leaf_Call: + return + } +} diff --git a/internal/schema/linked_schema_test.go b/internal/schema/linked_schema_test.go index 9c99db360..b2a20615c 100644 --- a/internal/schema/linked_schema_test.go +++ b/internal/schema/linked_schema_test.go @@ -1785,8 +1785,8 @@ var _ = Describe("linked schema", func() { { Kind: PathChainLinkedEntrance, TargetEntrance: &base.Entrance{ - Type: "department", - Value: "dept_name", + Type: "company", + Value: "company_id", }, TupleSetRelation: "", PathChain: []*base.RelationReference{ @@ -1794,19 +1794,23 @@ var _ = Describe("linked schema", func() { Type: "employee", Relation: "department", }, + { + Type: "department", + Relation: "company", + }, }, }, { Kind: PathChainLinkedEntrance, TargetEntrance: &base.Entrance{ - Type: "company", - Value: "company_id", + Type: "department", + Value: "dept_name", }, TupleSetRelation: "", PathChain: []*base.RelationReference{ { - Type: "department", - Relation: "company", + Type: "employee", + Relation: "department", }, }, }, @@ -1821,6 +1825,157 @@ var _ = Describe("linked schema", func() { }, })) }) + + It("Case 32: Nested PathChain preserves tuple-set relation", func() { + sch, err := parser.NewParser(` + entity user {} + + entity org { + attribute is_public boolean + permission view = is_public + } + + entity folder { + relation parent @org + permission view = parent.view + } + + entity resource { + relation parent @folder + relation alt @folder + permission view = parent.view + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + ent, err := g.LinkedEntrances(&base.Entrance{ + Type: "resource", + Value: "view", + }, &base.Entrance{ + Type: "user", + Value: "", + }) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(ent).Should(Equal([]*LinkedEntrance{ + { + Kind: PathChainLinkedEntrance, + TargetEntrance: &base.Entrance{ + Type: "org", + Value: "is_public", + }, + TupleSetRelation: "", + PathChain: []*base.RelationReference{ + { + Type: "resource", + Relation: "parent", + }, + { + Type: "folder", + Relation: "parent", + }, + }, + }, + })) + }) + + It("Case 33: SelfCycleRelationsForPermission returns only same-type relations", func() { + sch, err := parser.NewParser(` + entity user {} + + entity resource { + relation parent @resource + relation owner @user + permission view = parent.view or owner + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + Expect(g.SelfCycleRelationsForPermission("resource", "view")).To(ConsistOf("parent")) + }) + + It("Case 34: SelfCycleRelationsForPermission ignores cross-type relations", func() { + sch, err := parser.NewParser(` + entity user {} + + entity org { + attribute is_public boolean + permission view = is_public + } + + entity resource { + relation parent @org + permission view = parent.view + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + Expect(g.SelfCycleRelationsForPermission("resource", "view")).To(BeEmpty()) + }) + + It("Case 35: GetSubjectRelationForPathWalk returns nested subject relation", func() { + sch, err := parser.NewParser(` + entity user {} + + entity group { + relation member @user + } + + entity document { + relation group @group#member + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + Expect(g.GetSubjectRelationForPathWalk("document", "group", "group")).To(Equal("member")) + Expect(g.GetSubjectRelationForPathWalk("document", "group", "user")).To(Equal("")) + }) + + It("Case 36: SelfCycleRelationsForPermission ignores non-self computed relation", func() { + sch, err := parser.NewParser(` + entity user {} + + entity resource { + relation parent @resource + relation owner @user + permission edit = owner + permission view = parent.edit + } + `).Parse() + + Expect(err).ShouldNot(HaveOccurred()) + + c := compiler.NewCompiler(true, sch) + a, _, _ := c.Compile() + + g := NewLinkedGraph(NewSchemaFromEntityAndRuleDefinitions(a, nil)) + + Expect(g.SelfCycleRelationsForPermission("resource", "view")).To(BeEmpty()) + }) }) Context("BuildRelationPathChain", func() {