Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor IdentityAwarePlugin interface to be assigned a client for executing actions #16976

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

cwperks
Copy link
Member

@cwperks cwperks commented Jan 7, 2025

Description

Opening up this PR for discussion about a change in the interface that was introduced to formalize how plugins should interact with their own System Indices.

In the previous PR, there was a concept of a PluginSubject that was introduced that was assigned to IdentityAwarePlugins that could be used as a drop in replacement for try (ThreadContext.StoredContext ctx = threadContext.stashContext()) { ... } which is the pattern prevalently used for programmatic system index access.

There was discussion on that PR against introducing a separate client to make calls that execute actions in the context of the plugin's identity vs the authenticated user context. i.e. stashContext is a method to effectively switch contexts where plugins behave as the system and run without authz checks which allows access to a system index. There is an effort to put stronger mechanisms in place to perform authz checks when plugins switch context to better sandbox plugins and empower system administrators with information at installation-time with access that a plugin needs to operate normally.

Opening up this PR in response to a review comment that brings up reasons to pursue a solution with a separate client. This PR creates a subclass of FilterClient (called RunAsClient) that stashes the context prior to execution and action and restoring the original context before delegating back to the corresponding ActionListener's onResponse or onFailure method.

Related Issues

Related to opensearch-project/security#4439

Check List

  • Functionality includes testing.
  • API changes companion pull request created, if applicable.
  • Public documentation issue/PR created, if applicable.

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.

Signed-off-by: Craig Perkins <[email protected]>
try (ThreadContext.StoredContext ctx = threadContext.stashContext()) {

ActionListener<Response> wrappedListener = ActionListener.wrap(r -> {
ctx.restore();
Copy link
Member Author

@cwperks cwperks Jan 7, 2025

Choose a reason for hiding this comment

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

This is the main reason for introducing this PR, to ensure that the original context is restored when an action is completed.

When the Security Plugin provides its implementation of a RunAsClient, it would inject a user corresponding to the plugin before doExecute and restore the original context (including authenticated user info) before calling the original actionListener's onResponse or onFailure.

Copy link
Member Author

@cwperks cwperks Jan 7, 2025

Choose a reason for hiding this comment

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

I created a PR on my own fork of the security plugin to demonstrate how the changes would be integrated into a sample plugin: cwperks/security#40

Copy link
Member

Choose a reason for hiding this comment

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

rather than ActionListener.wrap( ... restore; onResponse ... restore; onFailure ) why not just use ActionListener.runBefore(listener, () -> context.restore()) (or better yet ActionListener.runBefore(listener, context::restore))?

Copy link
Contributor

github-actions bot commented Jan 7, 2025

❌ Gradle check result for 2765e88: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

Signed-off-by: Craig Perkins <[email protected]>
* @opensearch.internal
*/
@InternalApi
public class RunAsSystemClient extends FilterClient {
Copy link
Member Author

@cwperks cwperks Jan 7, 2025

Choose a reason for hiding this comment

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

This isn't an instance of a new client, its a wrapper around that local node client initialized in Node.java that overrides the doExecute method.

In particular, this is the default implementation that stashes the context prior to executing an action and restores it prior to delegating back to the original actionListener's onResponse or onFailure.

Copy link
Member

Choose a reason for hiding this comment

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

In the multitenant client mentioned earlier, the pattern is:

  • code stashes context
  • code calls the sdkClient (wrapper client)
  • for the NodeClient implementatoin, wrapper client then calls client.foo() which invokes the "protected" call

So for this to work we would need to change the default client implementation to use this RunAsSystemClient instance conditionally. Not sure how we do that... I'm sure it's possible, though.

See https://github.com/opensearch-project/opensearch-remote-metadata-sdk/blob/main/core/src/main/java/org/opensearch/remote/metadata/client/impl/LocalClusterIndicesClient.java for existing implementation

Copy link
Contributor

github-actions bot commented Jan 7, 2025

❕ Gradle check result for be4b7a5: UNSTABLE

Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure.

Copy link

codecov bot commented Jan 7, 2025

Codecov Report

Attention: Patch coverage is 74.07407% with 7 lines in your changes missing coverage. Please review.

Project coverage is 72.15%. Comparing base (d7641ca) to head (9439a0e).
Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
...opensearch/identity/shiro/ShiroIdentityPlugin.java 0.00% 2 Missing ⚠️
.../java/org/opensearch/identity/IdentityService.java 60.00% 2 Missing ⚠️
...g/opensearch/identity/noop/NoopIdentityPlugin.java 66.66% 1 Missing ⚠️
...va/org/opensearch/plugins/IdentityAwarePlugin.java 0.00% 1 Missing ⚠️
...in/java/org/opensearch/plugins/PluginsService.java 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #16976      +/-   ##
============================================
+ Coverage     72.11%   72.15%   +0.03%     
- Complexity    65151    65161      +10     
============================================
  Files          5299     5297       -2     
  Lines        303534   303537       +3     
  Branches      43941    43941              
============================================
+ Hits         218900   219017     +117     
+ Misses        66648    66499     -149     
- Partials      17986    18021      +35     

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

Signed-off-by: Craig Perkins <[email protected]>
Copy link
Contributor

github-actions bot commented Jan 7, 2025

❕ Gradle check result for 7a20d21: UNSTABLE

Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure.

Copy link
Contributor

github-actions bot commented Jan 8, 2025

❕ Gradle check result for ad3fbb6: UNSTABLE

Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure.

Signed-off-by: Craig Perkins <[email protected]>
Copy link
Contributor

github-actions bot commented Jan 8, 2025

✅ Gradle check result for 9439a0e: SUCCESS

@cwperks
Copy link
Member Author

cwperks commented Jan 9, 2025

@reta I raised this PR based on a review comment when implementing this interface in the security plugin. I know we discussed the 2 client approach previously, but I think @nibix raises a good point about restoring the context in the action listener. By using the FilterClient it wraps the existing client passed to plugins in createComponents so it really is a wrapper around the client that ensures that the security plugin can inject an identity after stashing the ThreadContext and then restoring before onResponse or onFailure is called.

@reta
Copy link
Collaborator

reta commented Jan 9, 2025

@reta I raised this PR based on a review comment when implementing this interface in the security plugin. I know we discussed the 2 client approach previously, but I think @nibix raises a good point about restoring the context in the action listener.

@cwperks thanks for continue working on it, I sadly don't have much time this week for reviews but will try to get to it asap, thanks

Copy link
Member

@dbwiddis dbwiddis left a comment

Choose a reason for hiding this comment

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

Thanks for working on this.

Unfortunately creating a wrapper client conflicts with other wrapper clients that we're moving toward. In our multitenant client case (see https://github.com/opensearch-project/opensearch-remote-metadata-sdk/tree/main) we are creating a single instance at plugin instantiation, with a default client which wraps node client (but other clients don't need it).

If we used the RunAsSystemClient wrapper at that point, we'd give that client permission all the time for every execute call. And then some existing internal checks that rely on the context (User access control) would fail.

Also there are many design patterns where multiple transport actions (that do the doExecute() you're targeting) call helper methods where the client is actually used.

I'm wondering if it's possible to just have a standalone method that does all the context stashing when passed the client at the time of the call? See for example this superclass that I wrote for convenience to solve a different repeating pattern (doPrivileged) but could serve a similar purpose: https://github.com/opensearch-project/opensearch-remote-metadata-sdk/blob/116c164dc700c45e96e605abf7dc997e2c502d6e/core/src/main/java/org/opensearch/remote/metadata/client/AbstractSdkClient.java#L53-L55

import org.opensearch.identity.Subject;
import org.opensearch.identity.noop.RunAsSystemClient;
Copy link
Member

Choose a reason for hiding this comment

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

When I see noop I think it does nothing but this seems to be doing "something". Is this the right package for it?

Copy link
Member Author

@cwperks cwperks Jan 10, 2025

Choose a reason for hiding this comment

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

This is the rationale, and sorry if there's not enough background context on the PR description.

  1. IdentityPlugin - In practical terms, the Identity Plugin is the security plugin as its the plugin that authenticates a request and provides the identity of the caller.
  2. IdentityAwarePlugins - I'm not crazy about the name here, but these are plugins that are aware of the fact that they need to do privileged operations outside of the authenticated user context. For instance, needing system index access which currently requires the usage of ThreadContext.stashContext.

There are 2 different scenarios this PR needs to account for:

  1. Cluster running w/o security
  2. Cluster running w/ security

When security is not installed, there is no IdentityPlugin and what's provided to the IdentityAwarePlugins is this RunAsSystemClient. This client does the current system index access pattern seen across the plugins, it stashes the thread context before executing a transport action. This client will then restore back the original context before delegating back to the original actionListener.

When security is installed, what would be provided is not this class, but another client defined by the security plugin. Its not introduced yet, but the code may look similar to this:

public class RunAsClient extends FilterClient {
    private final NamedPrincipal pluginPrincipal;
    private final User pluginUser;

    public RunAsClient(Client delegate, Plugin plugin) {
        super(delegate);
        String principal = "plugin:" + plugin.getClass().getCanonicalName();
        this.pluginPrincipal = new NamedPrincipal(principal);
        // Convention for plugin username. Prefixed with 'plugin:'. ':' is forbidden from usernames, so this
        // guarantees that a user with this username cannot be created by other means.
        this.pluginUser = new User(principal);
    }

    public NamedPrincipal getPrincipal() {
        return pluginPrincipal;
    }

    @Override
    protected <Request extends ActionRequest, Response extends ActionResponse> void doExecute(
        ActionType<Response> action,
        Request request,
        ActionListener<Response> actionListener
    ) {
        ThreadContext threadContext = threadPool().getThreadContext();

        try (ThreadContext.StoredContext ctx = threadContext.stashContext()) {

            ActionListener<Response> wrappedListener = ActionListener.wrap(r -> {
                ctx.restore();
                actionListener.onResponse(r);
            }, e -> {
                ctx.restore();
                actionListener.onFailure(e);
            });

            threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, pluginUser);
            super.doExecute(action, request, wrappedListener);
        }
    }
}

This client stashes the threadcontext, but then it injects an identity corresponding to the respective plugin that this client was assigned to. Security will use this identity to run authz checks which it does not currently do today. Currently, plugins can perform any action and are allowed to do so. The intent of this client is to allow system index access (to their own system indices) and prohibit other actions unless the cluster admin explicitly allows a plugin to perform an action outside the authenticated user context.

The one in this PR is in a package called noop because there's a notion of a NoopIdentityPlugin, but I agree that the naming is confusing.

Copy link
Member Author

@cwperks cwperks Jan 10, 2025

Choose a reason for hiding this comment

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

Plugins already get a node client through createComponents. This particular client (the client provided in IdentityAwarePlugin.assignRunAsClient) is intended to perform operations outside the authenticated user context (in the context of the plugin if you will).

I want to work towards cluster administrators knowing explicitly what actions a plugin will perform outside the authenticated user context and have the cluster administrator sign-off at installation time. Similar to JSM.

For instance, one use-case the security plugin will need facilitated is the ability to write to the audit log index if a cluster is using an opensearch index for the audit log. The security plugin needs a guarantee that writes to this index will succeed regardless of the callers permissions and it stashes the ThreadContext to do this operation today.

@@ -138,7 +140,7 @@ public void handleRequest(RestRequest request, RestChannel channel, NodeClient c
}
}

public PluginSubject getPluginSubject(Plugin plugin) {
return new ShiroPluginSubject(threadPool);
public Client getRunAsClient(Plugin plugin) {
Copy link
Member

Choose a reason for hiding this comment

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

(Style) I prefer createRunAsClient() but I'm ok with consistency if this is a similar pattern elsewhere.

public PluginSubject getPluginSubject(Plugin plugin) {
return new ShiroPluginSubject(threadPool);
public Client getRunAsClient(Plugin plugin) {
return new RunAsSystemClient(client);
Copy link
Member

Choose a reason for hiding this comment

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

With the new multitenancy feature, we're using this wrapped execution with a different client interface. So the thread context stashing wraps other code elsewhere (with a client object included) using both clients (client for thread pools and sdkClient for the indices). How would this object enable such a pattern?

See https://github.com/opensearch-project/ml-commons/blob/3bbc70077bc76f790077cf46666569017a7032ed/plugin/src/main/java/org/opensearch/ml/action/connector/GetConnectorTransportAction.java#L83-L96 for an example of delegating to a utility class method

See https://github.com/opensearch-project/ml-commons/blob/3bbc70077bc76f790077cf46666569017a7032ed/plugin/src/main/java/org/opensearch/ml/action/connector/DeleteConnectorTransportAction.java#L121-L162 for a wrapped call using a different client interface

try (ThreadContext.StoredContext ctx = threadContext.stashContext()) {

ActionListener<Response> wrappedListener = ActionListener.wrap(r -> {
ctx.restore();
Copy link
Member

Choose a reason for hiding this comment

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

rather than ActionListener.wrap( ... restore; onResponse ... restore; onFailure ) why not just use ActionListener.runBefore(listener, () -> context.restore()) (or better yet ActionListener.runBefore(listener, context::restore))?

* @opensearch.internal
*/
@InternalApi
public class RunAsSystemClient extends FilterClient {
Copy link
Member

Choose a reason for hiding this comment

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

In the multitenant client mentioned earlier, the pattern is:

  • code stashes context
  • code calls the sdkClient (wrapper client)
  • for the NodeClient implementatoin, wrapper client then calls client.foo() which invokes the "protected" call

So for this to work we would need to change the default client implementation to use this RunAsSystemClient instance conditionally. Not sure how we do that... I'm sure it's possible, though.

See https://github.com/opensearch-project/opensearch-remote-metadata-sdk/blob/main/core/src/main/java/org/opensearch/remote/metadata/client/impl/LocalClusterIndicesClient.java for existing implementation

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.

4 participants