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

Add run-as support for OAuth2 tokens #86680

Merged
merged 5 commits into from
May 20, 2022
Merged
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
5 changes: 5 additions & 0 deletions docs/changelog/86680.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 86680
summary: Add run-as support for OAuth2 tokens
area: Security
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -242,12 +242,12 @@ public Authentication maybeRewriteForOlderVersion(Version olderVersion) {
* The security {@code RealmRef#Domain} of the resulting {@code Authentication} is that of the run-as user's realm.
*/
public Authentication runAs(User runAs, @Nullable RealmRef lookupRealmRef) {
Objects.requireNonNull(runAs);
assert supportsRunAs(null);
assert false == runAs instanceof RunAsUser;
assert false == runAs instanceof AnonymousUser;
assert false == getUser() instanceof RunAsUser;
assert false == hasSyntheticRealmNameOrType(lookupRealmRef) : "should not use synthetic realm name/type for lookup realms";
assert AuthenticationType.REALM == getAuthenticationType() || AuthenticationType.API_KEY == getAuthenticationType();

Objects.requireNonNull(runAs);
return new Authentication(
new RunAsUser(runAs, getUser()),
getAuthenticatedBy(),
Expand Down Expand Up @@ -388,6 +388,55 @@ public boolean isApiKey() {
return effectiveSubject.getType() == Subject.Type.API_KEY;
}

/**
* Whether the authentication can run-as another user
*/
public boolean supportsRunAs(@Nullable AnonymousUser anonymousUser) {
// Chained run-as not allowed
if (isRunAs()) {
return false;
}
assert false == getUser() instanceof RunAsUser;

// We may allow service account to run-as in the future, but for now no service account requires it
if (isServiceAccount()) {
return false;
}

// There is no reason for internal users to run-as. This check prevents either internal user itself
// or a token created for it (though no such thing in current code) to run-as.
if (User.isInternal(getUser())) {
return false;
}

// Anonymous user or its token cannot run-as
// There is no perfect way to determine an anonymous user if we take custom realms into consideration
// 1. A custom realm can return a user object that can pass `equals(anonymousUser)` check
// (this is the existing check used elsewhere)
// 2. A custom realm can declare its type and name to be __anonymous
//
// This problem is at least partly due to we don't have special serialisation for the AnonymousUser class.
// As a result, it is serialised just as a normal user. At deserializing time, it is impossible to reliably
// tell the difference. This is what happens when AnonymousUser creates a token.
// Also, if anonymous access is disabled or anonymous username, roles are changed after the token is created.
// Should we still consider the token being created by an anonymous user which is now different from the new
// anonymous user?
if (getUser().equals(anonymousUser)) {
assert ANONYMOUS_REALM_TYPE.equals(getAuthenticatingSubject().getRealm().getType())
&& ANONYMOUS_REALM_NAME.equals(getAuthenticatingSubject().getRealm().getName());
Copy link
Contributor

@justincr-elastic justincr-elastic May 11, 2022

Choose a reason for hiding this comment

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

Should this be || based on your comment that a custom realm can use __anonymous for its name and type?

Copy link
Member Author

Choose a reason for hiding this comment

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

The intention is to make sure we only treat ES's own anonymous user as the true anonymous user. So the user's realm must match both of the name and type of ES anonymous realm.

return false;
}

// Run-as is supported for authentication with realm, api_key or token.
if (AuthenticationType.REALM == getAuthenticationType()
|| AuthenticationType.API_KEY == getAuthenticationType()
|| AuthenticationType.TOKEN == getAuthenticationType()) {
return true;
}

return false;
}

/**
* Writes the authentication to the context. There must not be an existing authentication in the context and if there is an
* {@link IllegalStateException} will be thrown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,38 @@ public void testDomainSerialize() throws Exception {
}
}

public void testSupportsRunAs() {
final Settings.Builder settingsBuilder = Settings.builder();
if (randomBoolean()) {
settingsBuilder.putList(AnonymousUser.ROLES_SETTING.getKey(), randomList(1, 3, () -> randomAlphaOfLengthBetween(3, 8)));
}
final AnonymousUser anonymousUser = new AnonymousUser(settingsBuilder.build());

// Both realm authentication and a token for it can run-as
assertThat(AuthenticationTestHelper.builder().realm().build(false).supportsRunAs(anonymousUser), is(true));
assertThat(AuthenticationTestHelper.builder().realm().build(false).token().supportsRunAs(anonymousUser), is(true));
// but not when it is already a run-as
assertThat(AuthenticationTestHelper.builder().realm().build(true).supportsRunAs(anonymousUser), is(false));
assertThat(AuthenticationTestHelper.builder().realm().build(true).token().supportsRunAs(anonymousUser), is(false));

// API Key or its token both can run-as
assertThat(AuthenticationTestHelper.builder().apiKey().build(false).supportsRunAs(anonymousUser), is(true));
assertThat(AuthenticationTestHelper.builder().apiKey().build(false).token().supportsRunAs(anonymousUser), is(true));
// But not when it already run-as another user
assertThat(AuthenticationTestHelper.builder().apiKey().runAs().build().supportsRunAs(anonymousUser), is(false));

// Service account cannot run-as
assertThat(AuthenticationTestHelper.builder().serviceAccount().build().supportsRunAs(anonymousUser), is(false));

// Neither internal user nor its token can run-as
assertThat(AuthenticationTestHelper.builder().internal().build().supportsRunAs(anonymousUser), is(false));
assertThat(AuthenticationTestHelper.builder().internal().build().token().supportsRunAs(anonymousUser), is(false));

// Neither anonymous user nor its token can run-as
assertThat(AuthenticationTestHelper.builder().anonymous(anonymousUser).build().supportsRunAs(anonymousUser), is(false));
assertThat(AuthenticationTestHelper.builder().anonymous(anonymousUser).build().token().supportsRunAs(anonymousUser), is(false));
}

private void assertCanAccessResources(Authentication authentication0, Authentication authentication1) {
assertTrue(authentication0.canAccessResourcesOf(authentication1));
assertTrue(authentication1.canAccessResourcesOf(authentication0));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.elasticsearch.xpack.core.XPackSettings;
import org.elasticsearch.xpack.core.security.authc.AuthenticationServiceField;
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
import org.elasticsearch.xpack.core.security.user.AnonymousUser;
import org.junit.BeforeClass;

import java.io.IOException;
Expand All @@ -36,7 +37,12 @@ public class RunAsIntegTests extends SecurityIntegTestCase {
+ SecuritySettingsSource.TEST_USER_NAME
+ "', '"
+ NO_ROLE_USER
+ "', 'idontexist' ]\n";
+ "', 'idontexist' ]\n"
+ "anonymous_role:\n"
+ " cluster: ['manage_token']\n"
+ " run_as: ['"
+ NO_ROLE_USER
+ "']\n";

// indicates whether the RUN_AS_USER that is being authenticated is also a superuser
private static boolean runAsHasSuperUserRole;
Expand Down Expand Up @@ -85,7 +91,8 @@ public String configUsersRoles() {
@Override
protected Settings nodeSettings(int nodeOrdinal, Settings otherSettings) {
final Settings.Builder builder = Settings.builder().put(super.nodeSettings(nodeOrdinal, otherSettings));
builder.put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), "true");
builder.put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), "true")
.putList(AnonymousUser.ROLES_SETTING.getKey(), "anonymous_role");
return builder.build();
}

Expand Down Expand Up @@ -157,26 +164,38 @@ public void testRunAsUsingApiKey() throws IOException {
createApiKeyResponse.getEntity().getContent()
);

// randomly using either the API key itself or a token created for it
final boolean useOAuth2Token = randomBoolean();
final String authHeader;
if (useOAuth2Token) {
final Request createTokenRequest = new Request("POST", "/_security/oauth2/token");
createTokenRequest.setOptions(
createTokenRequest.getOptions().toBuilder().addHeader("Authorization", "ApiKey " + apiKeyMapView.get("encoded"))
);
createTokenRequest.setJsonEntity("{\"grant_type\":\"client_credentials\"}");
final Response createTokenResponse = getRestClient().performRequest(createTokenRequest);
final XContentTestUtils.JsonMapView createTokenJsonView = XContentTestUtils.createJsonMapView(
createTokenResponse.getEntity().getContent()
);
authHeader = "Bearer " + createTokenJsonView.get("access_token");
} else {
authHeader = "ApiKey " + apiKeyMapView.get("encoded");
}

final boolean runAsTestUser = randomBoolean();

final Request authenticateRequest = new Request("GET", "/_security/_authenticate");
authenticateRequest.setOptions(
authenticateRequest.getOptions()
.toBuilder()
.addHeader("Authorization", "ApiKey " + apiKeyMapView.get("encoded"))
final XContentTestUtils.JsonMapView authenticateJsonView = authenticateWithOptions(
RequestOptions.DEFAULT.toBuilder()
.addHeader("Authorization", authHeader)
.addHeader(
AuthenticationServiceField.RUN_AS_USER_HEADER,
runAsTestUser ? SecuritySettingsSource.TEST_USER_NAME : NO_ROLE_USER
)
);
final Response authenticateResponse = getRestClient().performRequest(authenticateRequest);
final XContentTestUtils.JsonMapView authenticateJsonView = XContentTestUtils.createJsonMapView(
authenticateResponse.getEntity().getContent()
);
assertThat(authenticateJsonView.get("username"), equalTo(runAsTestUser ? SecuritySettingsSource.TEST_USER_NAME : NO_ROLE_USER));
assertThat(authenticateJsonView.get("authentication_realm.type"), equalTo("_es_api_key"));
assertThat(authenticateJsonView.get("lookup_realm.type"), equalTo("file"));
assertThat(authenticateJsonView.get("authentication_type"), equalTo("api_key"));
assertThat(authenticateJsonView.get("authentication_type"), equalTo(useOAuth2Token ? "token" : "api_key"));

final Request getUserRequest = new Request("GET", "/_security/user");
getUserRequest.setOptions(
Expand All @@ -195,34 +214,32 @@ public void testRunAsUsingApiKey() throws IOException {
assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(403));
}

// Run-as ignored if using a token created by the API key
// Run-As ignored if using the token is already created with run-as
final Request createTokenRequest = new Request("POST", "/_security/oauth2/token");
createTokenRequest.setOptions(
createTokenRequest.getOptions().toBuilder().addHeader("Authorization", "ApiKey " + apiKeyMapView.get("encoded"))
createTokenRequest.getOptions()
.toBuilder()
.addHeader("Authorization", "ApiKey " + apiKeyMapView.get("encoded"))
.addHeader(AuthenticationServiceField.RUN_AS_USER_HEADER, SecuritySettingsSource.TEST_USER_NAME)
);
createTokenRequest.setJsonEntity("{\"grant_type\":\"client_credentials\"}");
final Response createTokenResponse = getRestClient().performRequest(createTokenRequest);
final XContentTestUtils.JsonMapView createTokenJsonView = XContentTestUtils.createJsonMapView(
createTokenResponse.getEntity().getContent()
);

authenticateRequest.setOptions(
final XContentTestUtils.JsonMapView authenticateJsonView2 = authenticateWithOptions(
RequestOptions.DEFAULT.toBuilder()
.addHeader("Authorization", "Bearer " + createTokenJsonView.get("access_token"))
.addHeader(
AuthenticationServiceField.RUN_AS_USER_HEADER,
runAsTestUser ? SecuritySettingsSource.TEST_USER_NAME : NO_ROLE_USER
)
);
final Response authenticateResponse2 = getRestClient().performRequest(authenticateRequest);
final XContentTestUtils.JsonMapView authenticateJsonView2 = XContentTestUtils.createJsonMapView(
authenticateResponse2.getEntity().getContent()
.addHeader(AuthenticationServiceField.RUN_AS_USER_HEADER, NO_ROLE_USER)
);
// run-as header is ignored, the user is still the run_as_user
assertThat(authenticateJsonView2.get("username"), equalTo(RUN_AS_USER));
// run-as header is ignored, the user is still test_user
assertThat(authenticateJsonView2.get("username"), equalTo(SecuritySettingsSource.TEST_USER_NAME));
assertThat(authenticateJsonView2.get("authentication_type"), equalTo("token"));
}

public void testRunAsIgnoredForOAuthToken() throws IOException {
public void testRunAsForOAuthToken() throws IOException {
// Run-as works for oauth tokens
final Request createTokenRequest = new Request("POST", "/_security/oauth2/token");
createTokenRequest.setJsonEntity("{\"grant_type\":\"client_credentials\"}");
createTokenRequest.setOptions(
Expand All @@ -235,19 +252,64 @@ public void testRunAsIgnoredForOAuthToken() throws IOException {
createTokenResponse.getEntity().getContent()
);

final Request authenticateRequest = new Request("GET", "/_security/_authenticate");
authenticateRequest.setOptions(
authenticateRequest.getOptions()
.toBuilder()
final XContentTestUtils.JsonMapView authenticateJsonView = authenticateWithOptions(
RequestOptions.DEFAULT.toBuilder()
.addHeader("Authorization", "Bearer " + tokenMapView.get("access_token"))
.addHeader(AuthenticationServiceField.RUN_AS_USER_HEADER, NO_ROLE_USER)
);
assertThat(authenticateJsonView.get("username"), equalTo(NO_ROLE_USER));
assertThat(authenticateJsonView.get("authentication_type"), equalTo("token"));

// Run-as is ignored if the token itself already has run-as
final Request createTokenRequest2 = new Request("POST", "/_security/oauth2/token");
createTokenRequest2.setJsonEntity("{\"grant_type\":\"client_credentials\"}");
createTokenRequest2.setOptions(
createTokenRequest2.getOptions()
.toBuilder()
.addHeader("Authorization", UsernamePasswordToken.basicAuthHeaderValue(RUN_AS_USER, TEST_PASSWORD_SECURE_STRING))
.addHeader(AuthenticationServiceField.RUN_AS_USER_HEADER, SecuritySettingsSource.TEST_USER_NAME)
);
final Response authenticateResponse = getRestClient().performRequest(authenticateRequest);
final XContentTestUtils.JsonMapView authenticateJsonView = XContentTestUtils.createJsonMapView(
authenticateResponse.getEntity().getContent()
final Response createTokenResponse2 = getRestClient().performRequest(createTokenRequest2);
final XContentTestUtils.JsonMapView tokenMapView2 = XContentTestUtils.createJsonMapView(
createTokenResponse2.getEntity().getContent()
);
assertThat(authenticateJsonView.get("username"), equalTo(RUN_AS_USER));
assertThat(authenticateJsonView.get("authentication_type"), equalTo("token"));

final XContentTestUtils.JsonMapView authenticateJsonView2 = authenticateWithOptions(
RequestOptions.DEFAULT.toBuilder()
.addHeader("Authorization", "Bearer " + tokenMapView2.get("access_token"))
.addHeader(AuthenticationServiceField.RUN_AS_USER_HEADER, NO_ROLE_USER)
);
assertThat(authenticateJsonView2.get("username"), equalTo(SecuritySettingsSource.TEST_USER_NAME));
assertThat(authenticateJsonView2.get("authentication_type"), equalTo("token"));
}

public void testRunAsIsIgnoredForAnonymousUser() throws IOException {
final RequestOptions.Builder optionsBuilder = RequestOptions.DEFAULT.toBuilder()
.addHeader(AuthenticationServiceField.RUN_AS_USER_HEADER, NO_ROLE_USER);

// randomly use a token created for the anonymous user
final boolean useOauth2Token = randomBoolean();
if (useOauth2Token) {
final Request createTokenRequest = new Request("POST", "/_security/oauth2/token");
createTokenRequest.setJsonEntity("{\"grant_type\":\"client_credentials\"}");
createTokenRequest.setOptions(createTokenRequest.getOptions().toBuilder());
final Response createTokenResponse = getRestClient().performRequest(createTokenRequest);
final XContentTestUtils.JsonMapView tokenMapView = XContentTestUtils.createJsonMapView(
createTokenResponse.getEntity().getContent()
);
optionsBuilder.addHeader("Authorization", "Bearer " + tokenMapView.get("access_token"));
}

final XContentTestUtils.JsonMapView authenticateJsonView = authenticateWithOptions(optionsBuilder);
assertThat(authenticateJsonView.get("username"), equalTo(AnonymousUser.DEFAULT_ANONYMOUS_USERNAME));
assertThat(authenticateJsonView.get("authentication_type"), equalTo(useOauth2Token ? "token" : "anonymous"));
}

private XContentTestUtils.JsonMapView authenticateWithOptions(RequestOptions.Builder optionsBuilder) throws IOException {
final Request authenticateRequest = new Request("GET", "/_security/_authenticate");
authenticateRequest.setOptions(optionsBuilder);
final Response authenticateResponse = getRestClient().performRequest(authenticateRequest);
return XContentTestUtils.createJsonMapView(authenticateResponse.getEntity().getContent());
}

private static Request requestForUserRunAsUser(String user) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService;

import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
Expand Down Expand Up @@ -203,15 +202,8 @@ void maybeLookupRunAsUser(Authenticator.Context context, Authentication authenti
return;
}

// Run-as is supported for authentication with realm or api_key. Run-as for other authentication types is ignored.
// Both realm user and api_key can create tokens. They can also run-as another user and create tokens.
// In both cases, the created token will have a TOKEN authentication type and hence does not support run-as.
if (Authentication.AuthenticationType.REALM != authentication.getAuthenticationType()
&& Authentication.AuthenticationType.API_KEY != authentication.getAuthenticationType()) {
logger.info(
"ignore run-as header since it is currently not supported for authentication type [{}]",
authentication.getAuthenticationType().name().toLowerCase(Locale.ROOT)
);
if (false == authentication.supportsRunAs(anonymousUser)) {
logger.info("ignore run-as header since it is currently not supported for authentication [{}]", authentication);
Copy link
Contributor

Choose a reason for hiding this comment

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

Log parameter changed from authentication.getAuthenticationType().name().toLowerCase(Locale.ROOT) to authentication. Is that design intent?

Copy link
Member Author

Choose a reason for hiding this comment

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

With the changes here, determine whether an authentication supports run-as is more involved and not just based on the authenticationType. So I changed it to log the whole authentication object, which will be easier for us to diagnose if the logging message ever comes to us in a SDH.

Technically we can log multiple different message but more precise about exactly which part of authentication preventing run-as. But I am not sure if it's worth it. Let me know if you feel strongly about it.

finishAuthentication(context, authentication, listener);
return;
}
Expand Down
Loading