Skip to content

Reduce per-request work on the resource-sharing path - #6601

Open
DarshitChanpura wants to merge 3 commits into
opensearch-project:mainfrom
DarshitChanpura:perf/rsc-per-request-work
Open

DarshitChanpura wants to merge 3 commits into
opensearch-project:mainfrom
DarshitChanpura:perf/rsc-per-request-work

Conversation

@DarshitChanpura

Copy link
Copy Markdown
Member

Description

Two pieces of per-request work in the resource-sharing framework. Both were found by reading the code and then measured, and neither changes an authorization answer.

A cyclic parent chain never answered the request. A sharing record's parent id comes from a field the owning plugin writes on the resource document, and its parent type from what that plugin's provider declares. A provider may declare a type as its own parent type, or two types as each other's, and nothing rejects that at registration, so the chain a document describes can be a cycle. The container fallback recursed into it with nothing to stop it. Each hop is a sharing-record read, so a cycle did not exhaust the stack: it read forever and never answered. The walk now ends at the first record it reaches twice and denies there, since a cycle has no terminating ancestor to grant the action. Chains that do terminate are unaffected.

No registered provider declares a cyclic parent type today, so this is not reachable on a current cluster. It is reachable through the SPI, and nested containers are a plausible enough provider shape to be worth bounding.

The protected-index set was rebuilt per write. With resource sharing enabled, every index and delete operation in the cluster asks the registry whether the target index holds a protected resource type, because ResourceIndexListener is installed on every index. The answer was recomputed per call: read the protected-types setting, take the registration read lock, stream the provider map and collect a fresh set. It is now an immutable snapshot recomputed in the three places that can change it (extensions registering, the setting being wired, the protected-types list being updated), under the write lock the registry already takes, and the read is a plain field read.

Measurements

Plain in-JVM loops, 200k warmup and 2M iterations, 10 registered types:

before after
getResourceIndicesForProtectedTypes() 477 ns/op, one throwaway HashSet 9 ns/op, no allocation

The matcher compilation in recordGrantsAction was measured in the same run at 393 ns/op against 12 ns from a cache. It is deliberately left alone: it sits next to a mandatory sharing-record GET on the same request, so it does not justify a cache keyed on configuration that would have to be invalidated on configupdate.

Testing

ResourceAccessHandlerTests covers the mutual-parent and self-parent cycles, plus a three-hop chain where the grandparent is what grants the action. Negative control: both cycle tests fail without the fix (as StackOverflowError, because the test's mocks answer synchronously), and the grandparent test passes either way, so inheritance is not shortened.

ResourcePluginInfoTests covers both registration orders, a protected-types update driven the way the settings listener drives it, and the empty case. Three of those four pass on main as well, which is the point: the snapshot change is behaviour-preserving. The fourth fails on main because the old method dereferenced the setting unconditionally and threw when asked before the setting was wired.

Full unit suite and the sample-resource-plugin integration suite (153 tests) green locally. Two unit failures in the full run were BindException: Address already in use in the cluster test harness, in ViewVersionApiTest and SSLTest, and both pass in isolation.

Issues Resolved

Split out of PR 6571, which should stay about the gating resource resolver.

Check List

  • New functionality includes testing
  • New functionality has been documented
  • Commits are signed per the DCO using --signoff

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

A sharing record's parent id comes from a field the owning plugin
writes on the resource document, and its parent type from what that
plugin's provider declares. A provider may declare a type as its own
parent type, or two types as each other's, and nothing rejects that at
registration, so the chain a document describes can be a cycle.

The container fallback recursed into that chain with nothing to stop it.
Each hop is a sharing-record read, so a cycle did not exhaust the stack:
it read forever and never answered the request being authorized.

The walk now ends at the first record it reaches twice, and denies
there, since a cycle has no terminating ancestor to grant the action.
Chains that do terminate are unaffected.

Signed-off-by: Darshit Chanpura <dchanp@amazon.com>
With resource sharing enabled, every index and delete operation in the
cluster asks the registry whether the target index holds a protected
resource type, because the resource index listener is installed on every
index. The answer was recomputed per call: read the protected-types
setting, take the registration read lock, stream the provider map and
collect a fresh set. Measured at 477 ns and one throwaway set per
operation, against 9 ns for reading a precomputed one.

The set now changes only when it can: when extensions register, when the
setting is wired, and when the protected-types list is updated. Each of
those recomputes an immutable snapshot under the write lock the registry
already takes, and the read is a plain field read.

The answer is unchanged, including when extensions are registered before
the setting is wired, which is the order the plugin uses. It no longer
throws when asked before the setting is wired at all.

Signed-off-by: Darshit Chanpura <dchanp@amazon.com>
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 7d72985)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 Multiple PR themes

Sub-PR theme: Terminate cyclic parent chains in resource-sharing access checks

Relevant files:

  • src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java
  • src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java

Sub-PR theme: Cache protected resource indices as a snapshot in ResourcePluginInfo

Relevant files:

  • src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java
  • src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java

⚡ No major issues detected

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 7d72985
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Ensure snapshot refresh on all setting changes

The snapshot protectedResourceIndices is only refreshed on setProtectedTypesSetting,
setResourceSharingExtensions, and updateProtectedTypes, but not when the dynamic
setting value changes via its own listener if updateProtectedTypes is not invoked.
If the setting value can change without going through updateProtectedTypes, readers
will observe a stale snapshot. Verify that every path mutating the protected-types
value also calls updateProtectedTypes (or refresh the snapshot inside a
settings-change hook).

src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java [459-461]

+public Set<String> getResourceIndicesForProtectedTypes() {
+    return protectedResourceIndices;
+}
 
-
Suggestion importance[1-10]: 4

__

Why: The suggestion only asks the author to verify that updateProtectedTypes is called on every setting-change path, and does not propose an actual code change (improved_code equals existing_code). It raises a potentially valid concern but is speculative without concrete evidence of a stale-snapshot path.

Low
Use thread-safe set across async callbacks

visitedChain is a plain HashSet but gets mutated from the async callback chain in
checkParent/hasPermission, which may hop threads between the fetch callback and
subsequent recursion. While each id has its own set, that set is still touched
across threads via happens-before only if the async framework provides it; to be
safe, use a thread-safe set (e.g., ConcurrentHashMap.newKeySet()) or document the
required memory-visibility guarantee.

src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java [210-217]

 for (ResourceSharing sharingInfo : needContainerCheck) {
-    // Each id walks its own parent chain, so each gets its own visited set: these run concurrently and a
-    // shared set would be mutated from several threads at once. The record in hand has been consulted
-    // already, which the single-id path records on entry and this path has to record for itself.
-    final Set<String> visitedChain = new HashSet<>();
+    final Set<String> visitedChain = ConcurrentHashMap.newKeySet();
     visitedChain.add(chainKey(resourceType, sharingInfo.getResourceId()));
     checkContainers(sharingInfo, action, visitedChain, groupedListener);
 }
Suggestion importance[1-10]: 3

__

Why: Each id has its own visitedChain set and the recursion is sequential per chain (one async step after another), so a plain HashSet is typically fine given async framework happens-before semantics. The concern is speculative without evidence of concurrent access to the same set.

Low

Previous suggestions

Suggestions up to commit 562554b
CategorySuggestion                                                                                                                                    Impact
General
Track current record in cycle detection

The cycle-detection key only tracks the parent being visited, not the starting
record. If the parent chain cycles back to the original resourceId (e.g. A -> B ->
A, starting from A), the first record is never added to visitedChain, so the cycle
is only detected after an extra hop. More importantly, if A is its own parent, the
detection works, but for A -> B -> A started at A, A itself is never recorded.
Consider adding the current record's id to visitedChain before recursing into the
parent, or seeding it in the public entry point.

src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java [374]

-if (!visitedChain.add(parentType + "/" + parentId)) {
+if (!visitedChain.add(parentType + "/" + parentId) || !visitedChain.add(sharingInfo.getResourceType() + "/" + sharingInfo.getResourceId())) {
Suggestion importance[1-10]: 2

__

Why: The suggestion is largely incorrect. For a cycle A->B->A starting at A, when checkParent runs on A, it adds "B" to the chain; recursion into B then calls checkParent which attempts to add "A", which is not in the set yet, so it adds. But then B's parent is A, and when hasPermission(A) runs again, checkParent tries to add "B" again, which is already present—detected. The existing logic terminates correctly. The suggested fix also conflates resourceType with the parent type key format and may add unrelated entries.

Low
Confirm volatile visibility of snapshot

protectedResourceIndices is updated inside write-locked sections but read here
without any synchronization. Although the field is declared volatile, ensure callers
understand the snapshot can be stale relative to typeToProvider only when read
concurrently with an in-progress update; since the snapshot is always rebuilt under
the write lock before being published, this is safe — but confirm
protectedResourceIndices is volatile (it is) to guarantee visibility across threads.

src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java [459-461]

+public Set<String> getResourceIndicesForProtectedTypes() {
+    return protectedResourceIndices;
+}
 
-
Suggestion importance[1-10]: 1

__

Why: This suggestion merely asks to confirm existing behavior (volatile already declared) and offers no actual code change (existing_code equals improved_code).

Low

@cwperks

cwperks commented Oct 5, 2026

Copy link
Copy Markdown
Member

No registered provider declares a cyclic parent type today, so this is not reachable on a current cluster.

That may be the case, but if we have folder organization this can happen so good to have a check in.

The parent walk recorded only the parent it was about to visit, so the
record the walk started from was never in the set. A cycle back to it was
still caught, one hop later than it needed to be, after re-reading that
record.

The chain now records a record at the one place a record is read, and
the parent check refuses to read one twice. A two-record cycle costs two
reads instead of three, and a record naming itself costs one instead of
two.

Signed-off-by: Darshit Chanpura <dchanp@amazon.com>
@DarshitChanpura

Copy link
Copy Markdown
Member Author

Addressed the cycle-detection point in 7d72985. The walk recorded only the parent it was about to visit, so the record it started from was never in the set: a cycle back to that record was still caught, but one hop later, after re-reading it. A record is now recorded at the one place a record is read, and the parent check refuses to read one twice. Two-record cycle: two reads instead of three. Record naming itself: one instead of two. Both counts are asserted, and both assertions fail on the previous commit with TooManyActualInvocations.

The volatile suggestion needs nothing: protectedResourceIndices is already volatile and only published after being rebuilt under the write lock.

On the multiple-themes flag: the two changes are separable and I am happy to split them if a reviewer prefers that.

@DarshitChanpura

Copy link
Copy Markdown
Member Author

@cwperks agreed on folder organization being the case that makes it reachable. A provider declaring its own type as its parent type is all it takes, and nothing rejects that at registration, so the bound is worth having before such a provider exists rather than after.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 7d72985

@codecov

codecov Bot commented Oct 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.87234% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 76.31%. Comparing base (bb00b15) to head (7d72985).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...nsearch/security/resources/ResourcePluginInfo.java 96.15% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #6601      +/-   ##
==========================================
+ Coverage   76.27%   76.31%   +0.04%     
==========================================
  Files         470      475       +5     
  Lines       31311    31467     +156     
  Branches     4718     4741      +23     
==========================================
+ Hits        23881    24015     +134     
- Misses       5234     5249      +15     
- Partials     2196     2203       +7     
Files with missing lines Coverage Δ
...arch/security/resources/ResourceAccessHandler.java 77.41% <100.00%> (+1.13%) ⬆️
...nsearch/security/resources/ResourcePluginInfo.java 85.54% <96.15%> (+1.21%) ⬆️

... and 18 files with indirect coverage changes

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

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.

2 participants