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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions internal/engines/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2372,4 +2372,151 @@ 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))
})
})
})
146 changes: 145 additions & 1 deletion internal/engines/entity_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"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"
)
Expand Down Expand Up @@ -69,6 +70,12 @@
return nil
}

// Extract self-recursive relations used by the permission rewrite.
selfCycleRelations := engine.graph.SelfCycleRelationsForPermission(
request.GetEntrance().GetType(),
request.GetEntrance().GetValue(),
)

// Create a new context for executing goroutines and a cancel function.
cctx, cancel := context.WithCancel(ctx)
defer cancel()
Expand Down Expand Up @@ -97,7 +104,7 @@
return err
}
case schema.AttributeLinkedEntrance: // If the linked entrance is a computed user set entrance.
err = engine.attributeEntrance(cont, request, entrance, visits, publisher) // Call the tuple to user set entrance method.
err = engine.attributeEntrance(cont, request, entrance, visits, publisher, selfCycleRelations) // Call the tuple to user set entrance method.
if err != nil {
return err
}
Expand Down Expand Up @@ -126,6 +133,7 @@
entrance *schema.LinkedEntrance, // A linked entrance.
visits *VisitsMap, // A map that keeps track of visited entities to avoid infinite loops.
publisher *BulkEntityPublisher, // A custom publisher that publishes results in bulk.
selfCycleRelations []string, // Self-recursive relations from the permission rewrite.
) error { // Returns an error if one occurs during execution.
// attributeEntrance only handles direct attribute access
if !visits.AddEA(entrance.TargetEntrance.GetType(), entrance.TargetEntrance.GetValue()) {
Expand Down Expand Up @@ -162,6 +170,8 @@

it := database.NewUniqueAttributeIterator(rit, cti)

var attributeEntityIDs []string

// Publish entities directly for regular case
for it.HasNext() {
current, ok := it.GetNext()
Expand All @@ -183,6 +193,140 @@
SchemaVersion: request.GetMetadata().GetSchemaVersion(),
Depth: request.GetMetadata().GetDepth(),
}, request.GetContext(), base.CheckResult_CHECK_RESULT_UNSPECIFIED)

attributeEntityIDs = append(attributeEntityIDs, entity.GetId())
}

// Expand recursive relations for same-type attribute permissions (e.g., parent.view).
if request.GetEntrance().GetType() == entrance.TargetEntrance.GetType() &&
len(selfCycleRelations) > 0 &&
len(attributeEntityIDs) > 0 {
for _, relation := range selfCycleRelations {
err := engine.expandRecursiveRelation(ctx, request, entrance.TargetEntrance.GetType(), relation, attributeEntityIDs, visits, publisher)
if err != nil {
return err

Check warning on line 207 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L207

Added line #L207 was not covered by tests
}
}
}

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

Check warning on line 229 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L229

Added line #L229 was not covered by tests
}
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

Check warning on line 246 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L246

Added line #L246 was not covered by tests
}

cursorValue := ""
if request.GetEntrance().GetType() == entityType && request.GetCursor() != "" {
var err error
cursorValue, err = decodeCursorValue(request.GetCursor())
if err != nil {
return err

Check warning on line 254 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L251-L254

Added lines #L251 - L254 were not covered by tests
}
}

scope, exists := request.GetScope()[entityType]
var data []string
if exists {
data = scope.GetData()

Check warning on line 261 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L261

Added line #L261 was not covered by tests
}

seen := make(map[string]struct{}, len(seedSubjectIDs))
queue := make([]string, 0, len(seedSubjectIDs))
for _, id := range seedSubjectIDs {
if _, ok := seen[id]; ok {
continue

Check warning on line 268 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L268

Added line #L268 was not covered by tests
}
seen[id] = struct{}{}
queue = append(queue, id)
}

for len(queue) > 0 {
currentIDs := queue
queue = nil

filter := &base.TupleFilter{
Entity: &base.EntityFilter{
Type: entityType,
Ids: data,
},
Relation: relation,
Subject: &base.SubjectFilter{
Type: entityType,
Ids: currentIDs,
Relation: "",
},
}
Comment on lines +274 to +289
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Recursive traversal ignores subject relation.

For relations that point back to the same entity type with a subject relation (e.g., @group#member), leaving Subject.Relation empty can miss edges or over-include. Use the schema helper to derive the correct subject relation.

✅ Suggested fix (respect subject relation)
- filter := &base.TupleFilter{
+ subjectRelation := engine.graph.GetSubjectRelationForPathWalk(entityType, relation, entityType)
+ filter := &base.TupleFilter{
 	Entity: &base.EntityFilter{
 		Type: entityType,
 		Ids:  data,
 	},
 	Relation: relation,
 	Subject: &base.SubjectFilter{
 		Type:     entityType,
 		Ids:      currentIDs,
-		Relation: "",
+		Relation: subjectRelation,
 	},
 }
🤖 Prompt for AI Agents
In `@internal/engines/entity_filter.go` around lines 270 - 285, The loop builds a
TupleFilter but leaves Subject.Relation empty, which breaks recursive traversal
for self-referential relations; update the code that constructs the
&base.TupleFilter so Subject.Relation is set by calling the schema helper to
derive the correct subject relation for the given relation and entityType (e.g.,
obtain subjectRel := schemaHelper.DeriveSubjectRelation(relation, entityType) or
the equivalent helper method in your schema package) and assign Subject.Relation
= subjectRel (keep using EntityFilter.Type, Ids=data and Subject.Ids=currentIDs
as before).


pagination := database.NewCursorPagination()
cti, err := storageContext.NewContextualTuples(request.GetContext().GetTuples()...).QueryRelationships(filter, pagination)
if err != nil {
return err

Check warning on line 294 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L294

Added line #L294 was not covered by tests
}

rit, err := engine.dataReader.QueryRelationships(ctx, request.GetTenantId(), filter, request.GetMetadata().GetSnapToken(), pagination)
if err != nil {
return err

Check warning on line 299 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L299

Added line #L299 was not covered by tests
}

it := database.NewUniqueTupleIterator(rit, cti)
for it.HasNext() {
current, ok := it.GetNext()
if !ok {
break

Check warning on line 306 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L306

Added line #L306 was not covered by tests
}

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

Check warning on line 325 in internal/engines/entity_filter.go

View check run for this annotation

Codecov / codecov/patch

internal/engines/entity_filter.go#L325

Added line #L325 was not covered by tests
}
seen[entity.GetId()] = struct{}{}
queue = append(queue, entity.GetId())
}
}

return nil
Expand Down
Loading
Loading