-
Notifications
You must be signed in to change notification settings - Fork 282
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
Implement new extension points in IdentityPlugin and add ContextProvidingPluginSubject #4896
base: main
Are you sure you want to change the base?
Conversation
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Set<String> clusterActions = new HashSet<>(); | ||
clusterActions.add(BulkAction.NAME); | ||
PluginSubject subject = new ContextProvidingPluginSubject(threadPool, settings, plugin); | ||
sf.updatePluginToClusterAction(subject.getPrincipal().getName(), clusterActions); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In what life-cycle stage(s) will this method will be called in?
In the current state of the implementation, this method must be called very early, as the constructed ActionPrivileges
instance is immutable and will be only updated upon configuration change events. If this method gets called at the wrong time, changes won't be picked up immediately.
Additionaly, an open question is whether one has to think of concurrent calls or not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is called during node bootstrap. Its called directly after createComponents
@@ -152,6 +154,7 @@ public class PrivilegesEvaluator { | |||
private DynamicConfigModel dcm; | |||
private final NamedXContentRegistry namedXContentRegistry; | |||
private final Settings settings; | |||
private final Map<String, Set<String>> pluginToClusterActions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess, it is only a temporary state that we'll have cluster actions here?
If we get also index privileges and possibly other stuff soon, this certainly would deserve its own class encapsulating and managing the details. Then, one could also avoid this call delegation of updatePluginToClusterActions()
through SecurityFilter
which feels artificial.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that's right. I made that chain of calls to updatePluginToClusterActions()
out of convenience, but it is less then ideal. I have a PR open in core to declare actions a plugin will perform with its identity and prompt on installation, but it is currently stalled.
I plan to take a little bit of time to see what it would take to move system index protection into the core and introduce a notion of admin certificate to the core as a mechanism for taking invasive action on system indices if required.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The biggest challenge I see is ActionRequest -> concrete index resolution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean ActionRequest -> concrete index resolution in the core or in the security plugin?
I really think that each transport request implementation should be required to provide an interface to do such a resolution in its own semantics.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Capturing all of the previous discussions into a comment here. This PR allows a plugin to access its own registered system indices using the pluginSubject
that was assigned to the IdentityAwarePlugin.
In some instances, plugins may need to perform actions against regular indices or cluster actions outside of the authenticated user context to guarantee that the action succeeds. The primary example of this is the security plugin needing to be able to write to the auditlog index if an opensearch index is used for the auditlog.
I opened a PR in core that would allow a plugin to declare the actions it will perform on a cluster (outside of system index access) and a cluster admin would be prompted on plugin install that the plugin will perform those actions. Using that PR in core, I would follow-up this PR with another that would allow a plugin to perform index actions using its pluginSubject
, but the core PR is in an uncertain state because the main feedback is that system index protection should be a core feature.
I don't disagree with that in principle, but there are complications. For instance, in the security plugin there is a notion of a superuser that is permitted to perform operations on a system index. To authenticate as the superuser, you present the admin certificate and then can run an action against a system index. Assuming a cluster operates normally, this would not be needed, but it is helpful for an operator to perform invasive actions if a cluster gets into a bad state.
In order to use client cert authentication, a cluster needs https. If system index protection is ported to the core, it means that it could run with or without https. In clusters without https, it is not possible to create a secure equivalent of a superuser.
FYI @reta, this is the Draft PR I created in core to demonstrate the challenges of porting system index protection into the core: opensearch-project/OpenSearch#16695
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd agree to this analysis, @cwperks .
Actually, core already contains ground works for a protection of system indices. It works like this:
- In case a
REST
request is issues against a system index, the following message will be logged:this request accesses system indices: [{}], but in a future major version, direct access to system indices will be prevented by default
- Exception: The HTTP header
X-opensearch-product-origin
is present in the REST request - Other exception: The
RestHandler
implementation overridesallowSystemIndexAccessByDefault()
and returnstrue
.
See this commit for details: opensearch-project/OpenSearch@5c8b066
These concepts still come from the ES times. If I understand it correctly, the goal was to replace the warning message by an error as a breaking change with a major release.
IMHO, this concept makes totally sense for core. As @cwperks has pointed out, core has no safe means of authentication. What core can do is a kind of cooperative check: If a client assures that they need to perform operations on a system index, they are allowed to. If the client signals no concrete intent to operate on system index, they are not allowed to. This avoids unwanted operations in case of bugs on side of the client - be it an overly broad index pattern or a similar case. Of course, it does not provide real security. But that just takes into account the fact that core has no means to perform a secure authentication.
IMHO, a good strategy would be to build on the present ground works and to complete them. That would be also useful from a code hygiene point of view, as otherwise that existing code would be there without real purpose.
Relevant code pieces:
Signed-off-by: Craig Perkins <[email protected]>
...est/java/org/opensearch/security/plugin/RestBulkIndexDocumentIntoMixOfSystemIndexAction.java
Outdated
Show resolved
Hide resolved
src/main/java/org/opensearch/security/auth/BackendRegistry.java
Outdated
Show resolved
Hide resolved
src/main/java/org/opensearch/security/filter/SecurityFilter.java
Outdated
Show resolved
Hide resolved
@@ -407,6 +441,14 @@ PrivilegesEvaluatorResponse providesPrivilege(PrivilegesEvaluationContext contex | |||
} | |||
} | |||
|
|||
// 4: If plugin is performing the action, check if plugin has permission | |||
if (this.usersToActionMatcher.containsKey(context.getUser().getName())) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we are assuming that this applies only to plugin users - should we be also somehow verifying this condition, just for hardening?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't explicitly put the check in because this.usersToActionMatcher
is only populated on bootstrap and we control this data structure.
While this would be limited to plugins in its current form, it could be extended to the service accounts associated with extensions. I think a check would be redundant, but we could add it in to prevent a map lookup for regular users.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added the check in
@@ -152,6 +154,7 @@ public class PrivilegesEvaluator { | |||
private DynamicConfigModel dcm; | |||
private final NamedXContentRegistry namedXContentRegistry; | |||
private final Settings settings; | |||
private final Map<String, Set<String>> pluginToClusterActions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean ActionRequest -> concrete index resolution in the core or in the security plugin?
I really think that each transport request implementation should be required to provide an interface to do such a resolution in its own semantics.
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @cwperks. Left a few comments but generally looks good to me. Will there be a follow-up PR to clean up pluginActions matcher store once the PR in core gets merged?
src/main/java/org/opensearch/security/privileges/SystemIndexAccessEvaluator.java
Show resolved
Hide resolved
src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java
Show resolved
Hide resolved
Signed-off-by: Craig Perkins <[email protected]>
Yes there would be one more PR to implement index actions (for completeness) if opensearch-project/OpenSearch#15778 is merged. There's been a lot of discussion and I left a comment here to outline some reasons why I don't recommend system index protection to be a core feature. This gist is that the security plugin has a notion of connecting with the admin certificate that can be used as a backdoor for an operator to perform invasive actions on system indices if necessary. The admin certificate requires that a cluster has |
|
||
/** | ||
* Check the custom attributes associated with this user | ||
* | ||
* @return true if it has a plugin account attributes, otherwise false | ||
*/ | ||
public boolean isPluginUser() { | ||
return name != null && name.startsWith("plugin:"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
QQ: Do we need to add a restriction that usernames cannot begin with this prefix?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I chose this as a convention since :
is forbidden from usernames. Users representing plugins will only exist in-memory while executing transport actions with the plugin's subject. If an inter-nodal transport hop occurs then the user is serialized and then deserialized on the receiving node.
src/integrationTest/java/org/opensearch/security/SystemIndexTests.java
Outdated
Show resolved
Hide resolved
src/main/java/org/opensearch/security/auth/BackendRegistry.java
Outdated
Show resolved
Hide resolved
final User effectiveUser = impersonatedUser == null ? authenticatedUser : impersonatedUser; | ||
threadPool.getThreadContext().putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, effectiveUser); | ||
UserSubject subject = new SecurityUser(threadPool, effectiveUser); | ||
threadPool.getThreadContext().putPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER, subject); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm seeing the putPersistent()
method for the first time. I have read in the API docs that the difference is that the value cannot be stashed.
This kind of shows me that I do not really understand the sematic differences between UserSubject
and User
? Why is one persistent, but the other transient?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ultimately, my goal is that there is a single TC persistent (unstashable) header that represents the authenticated user so that the security plugin can reliably get information about the authenticated user from the TC and does not need to be concerned if the TC has been stashed.
The difference between User and UserSubject is that UserSubject has a runAs
method that can be used to switch into the authenticated user context. For instance, its possible to write an API handler that switches to the plugin context and then back to the user context.
The transient user header would be used to hold the value of the "user" to authorize actions with. In most circumstances, this would be the authenticated user, but when a plugin uses their pluginSubject and calls runAs
then it will hold the identity associated with the plugin while the OPENDISTRO_SECURITY_AUTHENTICATED_USER header would hold information about the authenticated user.
} | ||
|
||
@Override | ||
public <T> T runAs(Callable<T> callable) throws Exception { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am wondering if this runAs()
API covers all possible scenarios.
It is sufficient for synchronous scenarios, where it allows to run the code subsequent to this runAs()
call again in the original context.
However, OpenSearch architecture is fundamentally asynchronos. Often, it happens that "subsequent" logic does actually not follow in the subsequent lines but in the onResult()
action listener method. In such a onResult()
method, there would be no way to restore the context with this runAs()
API.
Using the stashContext()
API dirtectly however supports such scenarios, one example of this pattern is here (see the restore call in line 238):
security/src/main/java/org/opensearch/security/configuration/DlsFilterLevelActionHandler.java
Lines 166 to 257 in 2fa7eff
private boolean handle() { | |
try (StoredContext ctx = threadContext.newStoredContext(true)) { | |
threadContext.putHeader(ConfigConstants.OPENDISTRO_SECURITY_FILTER_LEVEL_DLS_DONE, request.toString()); | |
try { | |
if (!modifyQuery()) { | |
return true; | |
} | |
if (log.isDebugEnabled()) { | |
log.debug("Created filterLevelQuery for " + request + ":\n" + filterLevelQueryBuilder); | |
} | |
} catch (Exception e) { | |
log.error("Unable to handle filter level DLS", e); | |
listener.onFailure(new OpenSearchSecurityException("Unable to handle filter level DLS", e)); | |
return false; | |
} | |
if (filterLevelQueryBuilder == null) { | |
return true; | |
} | |
if (request instanceof SearchRequest) { | |
return handle((SearchRequest) request, ctx); | |
} else if (request instanceof GetRequest) { | |
return handle((GetRequest) request, ctx); | |
} else if (request instanceof MultiGetRequest) { | |
return handle((MultiGetRequest) request, ctx); | |
} else if (request instanceof ClusterSearchShardsRequest) { | |
return handle((ClusterSearchShardsRequest) request, ctx); | |
} else { | |
log.error("Unsupported request type for filter level DLS: " + request); | |
listener.onFailure( | |
new OpenSearchSecurityException( | |
"Unsupported request type for filter level DLS: " + action + "; " + request.getClass().getName() | |
) | |
); | |
return false; | |
} | |
} | |
} | |
private boolean handle(SearchRequest searchRequest, StoredContext ctx) { | |
if (documentAllowlist != null) { | |
documentAllowlist.applyTo(threadContext); | |
} | |
String localClusterAlias = LOCAL_CLUSTER_ALIAS_GETTER.apply(searchRequest); | |
if (localClusterAlias != null) { | |
try { | |
modifyQuery(localClusterAlias); | |
} catch (Exception e) { | |
log.error("Unable to handle filter level DLS", e); | |
listener.onFailure(new OpenSearchSecurityException("Unable to handle filter level DLS", e)); | |
return false; | |
} | |
} | |
if (searchRequest.source().query() != null) { | |
filterLevelQueryBuilder.must(searchRequest.source().query()); | |
} | |
searchRequest.source().query(filterLevelQueryBuilder); | |
nodeClient.search(searchRequest, new ActionListener<SearchResponse>() { | |
@Override | |
public void onResponse(SearchResponse response) { | |
try { | |
ctx.restore(); | |
@SuppressWarnings("unchecked") | |
ActionListener<SearchResponse> searchListener = (ActionListener<SearchResponse>) listener; | |
searchListener.onResponse(response); | |
} catch (Exception e) { | |
listener.onFailure(e); | |
} | |
} | |
@Override | |
public void onFailure(Exception e) { | |
listener.onFailure(e); | |
} | |
}); | |
return false; | |
} | |
Note: I understand that this example does not match the use case for runAs()
- however I can imagine that also plugins could need such scenarios. Or do I get something wrong?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To illustrate the issue, look at the following code (from the int test):
Lines 51 to 84 in 154556c
protected void doExecute(Task task, RunClusterHealthRequest request, ActionListener<RunClusterHealthResponse> actionListener) { | |
String runAs = request.getRunAs(); | |
if ("user".equalsIgnoreCase(runAs)) { | |
Subject user = identityService.getCurrentSubject(); | |
try { | |
user.runAs(() -> { | |
ActionListener<ClusterHealthResponse> chr = ActionListener.wrap( | |
r -> { actionListener.onResponse(new RunClusterHealthResponse(true)); }, | |
actionListener::onFailure | |
); | |
client.admin().cluster().health(new ClusterHealthRequest(), chr); | |
return null; | |
}); | |
} catch (Exception e) { | |
throw new RuntimeException(e); | |
} | |
} else if ("plugin".equalsIgnoreCase(runAs)) { | |
contextSwitcher.runAs(() -> { | |
ActionListener<ClusterHealthResponse> chr = ActionListener.wrap( | |
r -> { actionListener.onResponse(new RunClusterHealthResponse(true)); }, | |
actionListener::onFailure | |
); | |
client.admin().cluster().health(new ClusterHealthRequest(), chr); | |
return null; | |
}); | |
} else { | |
ActionListener<ClusterHealthResponse> chr = ActionListener.wrap( | |
r -> { actionListener.onResponse(new RunClusterHealthResponse(true)); }, | |
actionListener::onFailure | |
); | |
client.admin().cluster().health(new ClusterHealthRequest(), chr); | |
} | |
} | |
} |
If runAs
equals plugin
, then the onResponse()
method of the provided actionListener
would be executed in the plugin user context. Thus, any client which calls this action would suddenly have a plugin user scope.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ultimate goal is to ensure that authz checks are run with any transport request that is executed. The security plugin needs to have access to an identity associated with the executor of a transport action. Currently with stashContext, the identity is erased from the TC and any plugin can operate w/o authz checks.
What is the purpose of restoring the context in DlsFilterLevelActionHandler? Is restoring the context necessary? wdyt about a higher-level api (similar to runAs) where it can be guaranteed when restoring the context that the OPENDISTRO_SECURITY_USER transient header is populated to ensure that authz checks are performed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the purpose of restoring the context in DlsFilterLevelActionHandler? Is restoring the context necessary?
Yes, because the control is returned then to the caller of the action. The caller might call another action for which DLS/FLS also needs to be enforced. This requires the ConfigConstants.OPENDISTRO_SECURITY_FILTER_LEVEL_DLS_DONE
header to be absent. If it would be still there, no DLS/FLS protections would be applied.
Just, to avoid any confusions: I used this example only to illustrate the pattern - a pattern which is IMHO an essential pattern. However, it is not directly related to runAs()
.
The actual issue: The runAs()
API has IMHO the fundamental issue that it assumes a single threading model. However, single thread scenarios are rather the exception than the norm in OpenSearch. IMHO, there needs to be another way to tell "this block of logic has finished" other than the current thread leaving the runAs()
block again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good point. I just tested out the restore approach you posted above and it works within a runAs
block.
i.e.
...
} else if ("plugin".equalsIgnoreCase(runAs)) {
ThreadContext.StoredContext ctx = threadPool.getThreadContext().newStoredContext(true);
ActionListener<ClusterHealthResponse> chr = ActionListener.wrap(
r -> {
ctx.restore();
actionListener.onResponse(new RunClusterHealthResponse(true)); },
e -> {
System.out.println("Before restore");
System.out.println("Current user: " + threadPool.getThreadContext().getTransient(OPENDISTRO_SECURITY_USER));
ctx.restore();
System.out.println("After restore");
System.out.println("Current user: " + threadPool.getThreadContext().getTransient(OPENDISTRO_SECURITY_USER));
actionListener.onFailure(e);
}
);
contextSwitcher.runAs(() -> {
client.admin().cluster().health(new ClusterHealthRequest(), chr);
return null;
});
}
...
and in the output I see
> Task :integrationTest
SystemIndexTests > testPluginShouldNotBeAbleToRunClusterActions STANDARD_OUT
Before restore
Current user: User [name=plugin:org.opensearch.security.systemindex.sampleplugin.SystemIndexPlugin1, backend_roles=[], requestedTenant=null]
After restore
Current user: User [name=admin, backend_roles=[], requestedTenant=null]
Ideally, there is a higher-level API when executing a transport action to ensure the handler for the transport response is run in the original context, but the same workaround from your origin comment works here as well. This PR would incrementally improve the security posture by ensuring that the transport action is run with authz checks and ensuring that a plugin only accesses its own system indices.
* | ||
*/ | ||
|
||
package org.opensearch.security.plugin; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: As the new classes in the org.opensearch.security.plugin
package are only used by the SystemIndexTests
class, shouldn't these moved into a common package? Otherwise, their purpose might be unclear.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rearranged this to put the test in a package called systemindex
and the sample plugins (and associated classes) in a subpackage named sampleplugin
src/main/java/org/opensearch/security/identity/PluginContextSwitcher.java
Outdated
Show resolved
Hide resolved
user.getName().replace("plugin:", ""), | ||
requestedResolved.getAllIndices() | ||
); | ||
if (requestedResolved.getAllIndices().equals(matchingSystemIndices)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
requestedResolved.getAllIndices()
must be guarded by a isLocalAll()
, otherwise you will get wrong results for _all
requests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See response below. Can the whole top level condition be if (user.isPluginUser() && !requestedResolved.isLocalAll()) { ... }
?
requestedResolved.getAllIndices() | ||
); | ||
if (requestedResolved.getAllIndices().equals(matchingSystemIndices)) { | ||
// plugin is authorized to perform any actions on its own registered system indices |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code is called for index and cluster actions. Even though getAllIndices()
probably returns *
for cluster action as a more or less suitable fallback - shouldn't we better check the action type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be sufficient to change the top level condition to if (user.isPluginUser() && !requestedResolved.isLocalAll()) { ... }
?
src/integrationTest/java/org/opensearch/security/plugin/SystemIndexPlugin1.java
Outdated
Show resolved
Hide resolved
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Signed-off-by: Craig Perkins <[email protected]>
Description
Re-opening this previously closed PR to rebase on top of Optimized Privilege Evaluation
Companion PR in core: opensearch-project/OpenSearch#14630
This PR by itself does not add additional functionality, it simply implements the new extension points in core and introduces a new class called
ContextProvidingPluginSubject
which populates a header in the ThreadContext with the canonical class name of the plugin that is executing code usingpluginSystemSubject.runAs(() -> { ... })
. See./gradlew integrationTest --tests SystemIndexTests
for an example that verifies that a block of code run withpluginSystemSubject.runAs(() -> { ... })
has contextual info in the thread context.Enhancement
Issues Resolved
Related to #4439
Check List
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.