Skip to content

fix: support recursive attributes in lookup-entity#2763

Merged
omer-topal merged 6 commits intomasterfrom
fix/lookup-entity-recursive-attributes
Mar 23, 2026
Merged

fix: support recursive attributes in lookup-entity#2763
omer-topal merged 6 commits intomasterfrom
fix/lookup-entity-recursive-attributes

Conversation

@omer-topal
Copy link
Copy Markdown
Contributor

@omer-topal omer-topal commented Feb 5, 2026

Summary by CodeRabbit

  • Refactor
    • Permission evaluation now expands attribute-based permissions recursively, covering same-type and cross-type chains while preserving multi-hop path relations.
    • Path resolution is faster and supports cursor-aware pagination for attribute lookups.
  • Tests
    • Added comprehensive checks and lookup tests for recursive permission propagation, pagination, and cursor decoding.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 5, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds cursor-aware recursive expansion for self-referential attribute permissions, self-cycle detection in the linked schema, path-chain composition/caching for nested attribute entrances, and tests covering recursive lookups, pagination, and cursor decoding.

Changes

Cohort / File(s) Summary
Recursive relation expansion & entity filter
internal/engines/entity_filter.go
Adds decodeCursorValue and expandRecursiveRelation; extends attributeEntrance to use self-cycle relations; implements queue-based recursive traversal, visited/published tracking, cursor-aware relation queries, and publishes recursive results. Imports tokenutils.
Linked schema: self-cycle detection & path-chain composition
internal/schema/linked_schema.go
Adds SelfCycleRelationsForPermission(entityType, permission) []string and collectSelfCycleRelations; composes PathChainLinkedEntrances by prefixing relation paths for nested results and caches relation path chains to avoid repeated BuildRelationPathChain calls.
Tests: recursive attribute scenarios & cursor tests
internal/engines/check_test.go, internal/engines/lookup_test.go, internal/schema/linked_schema_test.go
Adds multiple tests for same-type and cross-type recursive attribute permissions/lookups, pagination across pages, expansion when roots are pre-allowed, and unit tests for cursor decoding; expands linked-schema test cases for nested path-chains and self-cycle behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant EF as EntityFilter
    participant LS as LinkedSchema
    participant DB as Storage
    participant BP as BulkEntityPublisher

    Client->>EF: Check/Lookup request
    EF->>LS: SelfCycleRelationsForPermission(entityType, permission)
    LS-->>EF: selfCycleRelations
    EF->>EF: attributeEntrance(..., selfCycleRelations)
    EF->>DB: query direct attribute relations (with cursor)
    DB-->>EF: direct entities (+ cursor)
    EF->>BP: publish direct entities

    alt selfCycleRelations exist
        loop per relation
            EF->>EF: expandRecursiveRelation(relation, seedIDs)
            EF->>DB: queryRelations(relation, seedIDs, cursor)
            DB-->>EF: related entities (+ nextCursor)
            EF->>BP: publish recursive entities
        end
    end

    BP-->>Client: aggregated results
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I nibble at relations, one by one,

decode the tokens, chase the sun.
Seeds unfurl down recursive tracks,
I hop, publish, and trace the backs.
A tiny rabbit, mapping access fun.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: support recursive attributes in lookup-entity' accurately describes the main change—adding support for recursive attributes in the lookup-entity functionality. The core changes across entity_filter.go, linked_schema.go, and test files all implement recursive permission traversal mechanisms.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/lookup-entity-recursive-attributes
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/engines/entity_filter.go (1)

130-210: ⚠️ Potential issue | 🟠 Major

Cursor-filtered seeds can drop recursive results.

When recursion is enabled, the attribute query is cursor-paginated and the seed list only reflects the current page. Descendants with IDs after the cursor but reachable solely via pre-cursor attributes will be missed. Consider collecting seeds without the cursor and applying the cursor only at publish time.

✅ Suggested fix (collect full seeds, filter publish in-memory)
- pagination := database.NewCursorPagination(database.Cursor(request.GetCursor()), database.Sort("entity_id"))
+ needsRecursive := request.GetEntrance().GetType() == entrance.TargetEntrance.GetType() && len(selfCycleRelations) > 0
+ pagination := database.NewCursorPagination(database.Cursor(request.GetCursor()), database.Sort("entity_id"))
+ cursorValue := ""
+ if needsRecursive {
+ 	// For recursive expansion, collect full seed set and apply cursor in-memory.
+ 	pagination = database.NewCursorPagination(database.Sort("entity_id"))
+ 	if request.GetCursor() != "" {
+ 		var err error
+ 		cursorValue, err = decodeCursorValue(request.GetCursor())
+ 		if err != nil {
+ 			return err
+ 		}
+ 	}
+ }
 ...
- if !visits.AddPublished(entity) {
- 	continue
- }
- publisher.Publish(entity, &base.PermissionCheckRequestMetadata{
- 	SnapToken:     request.GetMetadata().GetSnapToken(),
- 	SchemaVersion: request.GetMetadata().GetSchemaVersion(),
- 	Depth:         request.GetMetadata().GetDepth(),
- }, request.GetContext(), base.CheckResult_CHECK_RESULT_UNSPECIFIED)
-
- attributeEntityIDs = append(attributeEntityIDs, entity.GetId())
+ attributeEntityIDs = append(attributeEntityIDs, entity.GetId())
+ if cursorValue == "" || entity.GetId() >= cursorValue {
+ 	if !visits.AddPublished(entity) {
+ 		continue
+ 	}
+ 	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 request.GetEntrance().GetType() == entrance.TargetEntrance.GetType() &&
- 	len(selfCycleRelations) > 0 &&
- 	len(attributeEntityIDs) > 0 {
+ if needsRecursive && len(attributeEntityIDs) > 0 {
🤖 Fix all issues with AI agents
In `@internal/engines/entity_filter.go`:
- Around line 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).

Comment on lines +270 to +285
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: "",
},
}
Copy link
Copy Markdown

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

@codecov
Copy link
Copy Markdown

codecov bot commented Feb 5, 2026

Codecov Report

❌ Patch coverage is 75.00000% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.58%. Comparing base (353c2ae) to head (f2b70e0).
⚠️ Report is 103 commits behind head on master.

Files with missing lines Patch % Lines
internal/engines/entity_filter.go 76.58% 13 Missing and 13 partials ⚠️
internal/schema/linked_schema.go 72.31% 9 Missing and 9 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2763      +/-   ##
==========================================
- Coverage   82.64%   82.58%   -0.06%     
==========================================
  Files          74       74              
  Lines        8125     8314     +189     
==========================================
+ Hits         6714     6865     +151     
- Misses        892      910      +18     
- Partials      519      539      +20     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 5, 2026

Note

Docstrings generation - SUCCESS
Generated docstrings for this pull request at #2765

coderabbitai bot added a commit that referenced this pull request Feb 5, 2026
Docstrings generation was requested by @omer-topal.

* #2763 (comment)

The following files were modified:

* `internal/engines/entity_filter.go`
Copy link
Copy Markdown

@wied03 wied03 left a comment

Choose a reason for hiding this comment

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

1st round of questions

return res
}

func (g *LinkedSchemaGraph) collectSelfCycleRelations(entityType, permission string, child *base.Child, seen map[string]struct{}, res *[]string) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'm not sure I fully understand the graph, but could we end up in a situation where we have a stack overflow from the recursion here? If seen is protecting us from that, can you explain how real quick? Thanks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The seen map in SelfCycleRelationsForPermission is only for deduping schema-level relation names. It detects which tuple-set relations in the schema cause a permission to reference itself. That's a static schema analysis step.
Runtime cycle protection is in expandRecursiveRelation. It uses an iterative BFS with a seen set seeded from initial IDs, and only enqueues unseen entity IDs. In a data cycle (1 -> 2 -> 1), 1 is already seen on revisit, so it is not re-enqueued and and the queue eventually drains.

}))
})

It("Case 32: Nested PathChain preserves tuple-set relation", func() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can you tell me which tests are ensuring we don't regress existing code with the fix, vs. tests that demonstrate the bug and fail (without the fix)?

Copy link
Copy Markdown
Contributor Author

@omer-topal omer-topal Mar 4, 2026

Choose a reason for hiding this comment

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

Cases 1–23 cover the existing schema graph logic and would catch any breakage.
Cases 32–36 are new schema-layer tests targeting the new SelfCycleRelationsForPermission method and corrected PathChain output.

The actual runtime behavior is tested in lookup_test.go under the "Recursive Attribute Lookup" context. These set up actual data:
resource:r1#parent@resource:default with is_public=true on default and assert that LookupEntity returns both r1 and default for view.
Those are the ones that directly demonstrate the bug and fail without the fix.

}))
})

It("Case 33: SelfCycleRelationsForPermission returns only same-type relations", func() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do these case numbers (32, 33, etc.) trace back to anything in particular?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nothing particular, I followed the existing numbering convention.

Copy link
Copy Markdown
Contributor

@lyleschemmerling lyleschemmerling left a comment

Choose a reason for hiding this comment

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

ignore

@lyleschemmerling
Copy link
Copy Markdown
Contributor

apologize for the over-zealous AI review, but did raise a couple good points that I think are worth looking into at least

@omer-topal omer-topal merged commit 0462a2e into master Mar 23, 2026
13 of 15 checks passed
@omer-topal omer-topal deleted the fix/lookup-entity-recursive-attributes branch March 23, 2026 18:08
@omer-topal
Copy link
Copy Markdown
Contributor Author

fix: #2745

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants