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

Service Accounts - Initial bootstrap plumbing to add essential classes #70391

Merged
merged 8 commits into from
Mar 16, 2021
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,6 @@
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
import org.elasticsearch.xpack.security.authc.service.ServiceAccountsCredentialStore;
import org.elasticsearch.xpack.security.authc.service.ServiceAccountsCredentialStore.CompositeServiceAccountsCredentialStore;
import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator;
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ public static ServiceAccountId fromPrincipal(String principal) {
public ServiceAccountId(String namespace, String serviceName) {
this.namespace = namespace;
this.serviceName = serviceName;
validate();
if (Strings.isBlank(this.namespace)) {
throw new IllegalArgumentException("the namespace of a service account ID must not be empty");
}
if (Strings.isBlank(this.serviceName)) {
throw new IllegalArgumentException("the service-name of a service account ID must not be empty");
}
}

public ServiceAccountId(StreamInput in) throws IOException {
Expand All @@ -66,15 +71,6 @@ public String asPrincipal() {
return namespace + "/" + serviceName;
}

private void validate() {
if (Strings.isBlank(namespace)) {
throw new IllegalArgumentException("the namespace of a service account ID must not be empty");
}
if (Strings.isBlank(serviceName)) {
throw new IllegalArgumentException("the service-name of a service account ID must not be empty");
}
}

@Override
public String toString() {
return asPrincipal();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ public class ServiceAccountService {

public static final String REALM_TYPE = "service_account";
public static final String REALM_NAME = "service_account";
public static final Version VERSION_MINIMUM = Version.V_8_0_0;

private static final Logger logger = LogManager.getLogger(ServiceAccountService.class);
private static final Version VERSION_MINIMUM = Version.V_8_0_0;

private final ServiceAccountsCredentialStore serviceAccountsCredentialStore;

Expand All @@ -47,7 +47,7 @@ public ServiceAccountService(ServiceAccountsCredentialStore serviceAccountsCrede
}

public static boolean isServiceAccount(Authentication authentication) {
return REALM_TYPE.equals(authentication.getAuthenticatedBy().getType());
return REALM_TYPE.equals(authentication.getAuthenticatedBy().getType()) && null == authentication.getLookedUpBy();
}

// {@link org.elasticsearch.xpack.security.authc.TokenService#extractBearerTokenFromHeader extracted} from an HTTP authorization header.
ywangd marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -80,24 +80,26 @@ public void authenticateWithToken(ServiceAccountToken token, ThreadContext threa
ActionListener<Authentication> listener) {

if (ElasticServiceAccounts.NAMESPACE.equals(token.getAccountId().namespace()) == false) {
logger.debug("only [{}] service accounts are supported, but received [{}]",
final ParameterizedMessage message = new ParameterizedMessage(
"only [{}] service accounts are supported, but received [{}]",
ElasticServiceAccounts.NAMESPACE, token.getAccountId().asPrincipal());
listener.onResponse(null);
return;
logger.debug(message);
throw new ElasticsearchSecurityException(message.getFormattedMessage());
}

final ServiceAccount account = ACCOUNTS.get(token.getAccountId().serviceName());
if (account == null) {
logger.debug("the [{}] service account does not exist", token.getAccountId().asPrincipal());
listener.onFailure(null);
return;
final ParameterizedMessage message = new ParameterizedMessage(
"the [{}] service account does not exist", token.getAccountId().asPrincipal());
logger.debug(message);
throw new ElasticsearchSecurityException(message.getFormattedMessage());
}

if (serviceAccountsCredentialStore.authenticate(token)) {
ywangd marked this conversation as resolved.
Show resolved Hide resolved
listener.onResponse(success(account, token, nodeName));
} else {
final ParameterizedMessage message = new ParameterizedMessage(
"failed to authenticate service account [{}] with token name []",
"failed to authenticate service account [{}] with token name [{}]",
token.getAccountId().asPrincipal(),
token.getTokenName());
logger.debug(message);
Expand All @@ -106,7 +108,7 @@ public void authenticateWithToken(ServiceAccountToken token, ThreadContext threa
}

public void getRoleDescriptor(Authentication authentication, ActionListener<RoleDescriptor> listener) {
assert isServiceAccount(authentication) : "authentication is not for service account";
assert isServiceAccount(authentication) : "authentication is not for service account: " + authentication;

final ServiceAccountId accountId = ServiceAccountId.fromPrincipal(authentication.getUser().principal());
final ServiceAccount account = ACCOUNTS.get(accountId.serviceName());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@

import java.io.IOException;
import java.util.Base64;
import java.util.Objects;

/**
* A decoded credential that may be used to authenticate a {@link ServiceAccount}.
* It consists of:
* <ol>
* <li>A {@link #getAccountId() service account id}</li>
* <li>The {@link #getTokenName() name of the token} to be used</li>
* <li>The {@link #getSecret() secreet credential} for that token</li>
* <li>The {@link #getSecret() secret credential} for that token</li>
* </ol>
*/
public class ServiceAccountToken {
Expand All @@ -44,10 +45,6 @@ public String getTokenName() {
return tokenName;
}

public String getQualifiedName() {
return getAccountId().asPrincipal() + '/' + tokenName;
}
ywangd marked this conversation as resolved.
Show resolved Hide resolved

public SecureString getSecret() {
return secret;
}
Expand All @@ -66,4 +63,19 @@ public SecureString asBearerString() throws IOException {
return new SecureString(base64.toCharArray());
}
}

@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
ServiceAccountToken that = (ServiceAccountToken) o;
return accountId.equals(that.accountId) && tokenName.equals(that.tokenName) && secret.equals(that.secret);
}

@Override
public int hashCode() {
return Objects.hash(accountId, tokenName, secret);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.security.authc.service;

import org.elasticsearch.test.ESTestCase;

import java.util.List;

import static org.hamcrest.Matchers.is;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class CompositeServiceAccountsCredentialStoreTests extends ESTestCase {

public void testAuthenticate() {
final ServiceAccountToken token = mock(ServiceAccountToken.class);

final ServiceAccountsCredentialStore store1 = mock(ServiceAccountsCredentialStore.class);
final ServiceAccountsCredentialStore store2 = mock(ServiceAccountsCredentialStore.class);
final ServiceAccountsCredentialStore store3 = mock(ServiceAccountsCredentialStore.class);

final boolean store1Success = randomBoolean();
final boolean store2Success = randomBoolean();
final boolean store3Success = randomBoolean();

when(store1.authenticate(token)).thenReturn(store1Success);
when(store2.authenticate(token)).thenReturn(store2Success);
when(store3.authenticate(token)).thenReturn(store3Success);

final ServiceAccountsCredentialStore.CompositeServiceAccountsCredentialStore compositeStore =
new ServiceAccountsCredentialStore.CompositeServiceAccountsCredentialStore(List.of(store1, store2, store3));

if (store1Success || store2Success || store3Success) {
assertThat(compositeStore.authenticate(token), is(true));
} else {
assertThat(compositeStore.authenticate(token), is(false));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.security.authc.service;

import org.elasticsearch.common.Strings;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction;
import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest;
import org.elasticsearch.xpack.core.security.action.GetApiKeyAction;
import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest;
import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction;
import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest;
import org.elasticsearch.xpack.core.security.authc.Authentication;
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
import org.elasticsearch.xpack.core.security.authz.permission.Role;
import org.elasticsearch.xpack.core.security.user.User;
import org.elasticsearch.xpack.security.authc.service.ElasticServiceAccounts.ElasticServiceAccount;

import java.util.Map;

import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.mockito.Mockito.mock;

public class ElasticServiceAccountsTests extends ESTestCase {

public void testElasticFleetPrivileges() {
final Role role = Role.builder(ElasticServiceAccounts.ACCOUNTS.get("fleet").roleDescriptor(), null).build();
final Authentication authentication = mock(Authentication.class);
assertThat(role.cluster().check(CreateApiKeyAction.NAME,
new CreateApiKeyRequest(randomAlphaOfLengthBetween(3, 8), null, null), authentication), is(true));
assertThat(role.cluster().check(GetApiKeyAction.NAME, GetApiKeyRequest.forOwnedApiKeys(), authentication), is(true));
assertThat(role.cluster().check(InvalidateApiKeyAction.NAME, InvalidateApiKeyRequest.forOwnedApiKeys(), authentication), is(true));

assertThat(role.cluster().check(GetApiKeyAction.NAME, randomFrom(GetApiKeyRequest.forAllApiKeys()), authentication), is(false));
assertThat(role.cluster().check(InvalidateApiKeyAction.NAME,
InvalidateApiKeyRequest.usingUserName(randomAlphaOfLengthBetween(3, 16)), authentication), is(false));

// TODO: more tests when role descriptor is finalised for elastic/fleet
}

public void testElasticServiceAccount() {
final String serviceName = randomAlphaOfLengthBetween(3, 8);
final String principal = ElasticServiceAccounts.NAMESPACE + "/" + serviceName;
final RoleDescriptor roleDescriptor1 = new RoleDescriptor(principal, null, null, null);
final ElasticServiceAccount serviceAccount = new ElasticServiceAccount(
serviceName, roleDescriptor1);
assertThat(serviceAccount.id(), equalTo(new ServiceAccount.ServiceAccountId(ElasticServiceAccounts.NAMESPACE, serviceName)));
assertThat(serviceAccount.roleDescriptor(), equalTo(roleDescriptor1));
assertThat(serviceAccount.asUser(), equalTo(new User(principal, Strings.EMPTY_ARRAY,
"Service account - " + principal, null,
Map.of("_elastic_service_account", true),
true)));

final NullPointerException e1 =
expectThrows(NullPointerException.class, () -> new ElasticServiceAccount(serviceName, null));
assertThat(e1.getMessage(), containsString("Role descriptor cannot be null"));

final RoleDescriptor roleDescriptor2 = new RoleDescriptor(randomAlphaOfLengthBetween(6, 16),
null, null, null);
final IllegalArgumentException e2 =
expectThrows(IllegalArgumentException.class, () -> new ElasticServiceAccount(serviceName, roleDescriptor2));
assertThat(e2.getMessage(), containsString(
"the provided role descriptor [" + roleDescriptor2.getName()
+ "] must have the same name as the service account [" + principal + "]"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.security.authc.service;

import org.elasticsearch.common.io.stream.BytesStreamOutput;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.test.ESTestCase;

import java.io.IOException;

import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;

public class ServiceAccountIdTests extends ESTestCase {

public void testFromPrincipalAndInstantiate() {
final String namespace1 = randomAlphaOfLengthBetween(3, 8);
final String serviceName1 = randomAlphaOfLengthBetween(3, 8);
final String principal1 = namespace1 + "/" + serviceName1;

final ServiceAccount.ServiceAccountId accountId1;
if (randomBoolean()) {
accountId1 = ServiceAccount.ServiceAccountId.fromPrincipal(principal1);
} else {
accountId1 = new ServiceAccount.ServiceAccountId(namespace1, serviceName1);
}
assertThat(accountId1.asPrincipal(), equalTo(principal1));
assertThat(accountId1.namespace(), equalTo(namespace1));
assertThat(accountId1.serviceName(), equalTo(serviceName1));

// No '/'
final String principal2 = randomAlphaOfLengthBetween(6, 16);
final IllegalArgumentException e2 =
expectThrows(IllegalArgumentException.class, () -> ServiceAccount.ServiceAccountId.fromPrincipal(principal2));
assertThat(e2.getMessage(), containsString(
"a service account ID must be in the form {namespace}/{service-name}, but was [" + principal2 + "]"));

// blank namespace
final IllegalArgumentException e3;
if (randomBoolean()) {
e3 = expectThrows(IllegalArgumentException.class,
() -> ServiceAccount.ServiceAccountId.fromPrincipal(
randomFrom("", " ", "\t", " \t") + "/" + randomAlphaOfLengthBetween(3, 8)));
} else {
e3 = expectThrows(IllegalArgumentException.class,
() -> new ServiceAccount.ServiceAccountId(randomFrom("", " ", "\t", " \t", null), randomAlphaOfLengthBetween(3, 8)));
}
assertThat(e3.getMessage(), containsString("the namespace of a service account ID must not be empty"));

// blank service-name
final IllegalArgumentException e4;
if (randomBoolean()) {
e4 = expectThrows(IllegalArgumentException.class,
() -> ServiceAccount.ServiceAccountId.fromPrincipal(
randomAlphaOfLengthBetween(3, 8) + "/" + randomFrom("", " ", "\t", " \t")));
} else {
e4 = expectThrows(IllegalArgumentException.class,
() -> new ServiceAccount.ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomFrom("", " ", "\t", " \t", null)));
}
assertThat(e4.getMessage(), containsString("the service-name of a service account ID must not be empty"));
}

public void testStreamReadWrite() throws IOException {
final ServiceAccount.ServiceAccountId accountId =
new ServiceAccount.ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8));
try (BytesStreamOutput out = new BytesStreamOutput()) {
accountId.write(out);
try (StreamInput in = out.bytes().streamInput()) {
assertThat(new ServiceAccount.ServiceAccountId(in), equalTo(accountId));
}
}
}
}
Loading