Skip to content

Commit 143202a

Browse files
authored
Service Accounts - Initial bootstrap plumbing for essential classes (#70391)
This PR is the initial effort to add essential classes for service accounts to lay down the foundation of future works. The classes are wired in places, but not yet been used. Also intentionally left out the actual credential store implementation. It is a good first commit which does not bring in too many changes.
1 parent c2ae7cb commit 143202a

16 files changed

+964
-37
lines changed

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@
200200
import org.elasticsearch.xpack.security.authc.TokenService;
201201
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
202202
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
203+
import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
204+
import org.elasticsearch.xpack.security.authc.service.ServiceAccountsCredentialStore.CompositeServiceAccountsCredentialStore;
203205
import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator;
204206
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
205207
import org.elasticsearch.xpack.security.authz.AuthorizationService;
@@ -488,9 +490,13 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
488490
final ApiKeyService apiKeyService = new ApiKeyService(settings, Clock.systemUTC(), client, getLicenseState(), securityIndex.get(),
489491
clusterService, cacheInvalidatorRegistry, threadPool);
490492
components.add(apiKeyService);
493+
494+
final ServiceAccountService serviceAccountService =
495+
new ServiceAccountService(new CompositeServiceAccountsCredentialStore(List.of()));
496+
491497
final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore,
492498
privilegeStore, rolesProviders, threadPool.getThreadContext(), getLicenseState(), fieldPermissionsCache, apiKeyService,
493-
dlsBitsetCache.get(), new DeprecationRoleDescriptorConsumer(clusterService, threadPool));
499+
serviceAccountService, dlsBitsetCache.get(), new DeprecationRoleDescriptorConsumer(clusterService, threadPool));
494500
securityIndex.get().addIndexStateListener(allRolesStore::onSecurityIndexStateChange);
495501

496502
// to keep things simple, just invalidate all cached entries on license change. this happens so rarely that the impact should be
@@ -509,7 +515,7 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
509515
operatorPrivilegesService = OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE;
510516
}
511517
authcService.set(new AuthenticationService(settings, realms, auditTrailService, failureHandler, threadPool,
512-
anonymousUser, tokenService, apiKeyService, operatorPrivilegesService));
518+
anonymousUser, tokenService, apiKeyService, serviceAccountService, operatorPrivilegesService));
513519
components.add(authcService.get());
514520
securityIndex.get().addIndexStateListener(authcService.get()::onSecurityIndexStateChange);
515521

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import org.elasticsearch.xpack.security.audit.AuditTrail;
4747
import org.elasticsearch.xpack.security.audit.AuditTrailService;
4848
import org.elasticsearch.xpack.security.audit.AuditUtil;
49+
import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
4950
import org.elasticsearch.xpack.security.authc.support.RealmUserLookup;
5051
import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService;
5152
import org.elasticsearch.xpack.security.support.SecurityIndexManager;
@@ -88,6 +89,7 @@ public class AuthenticationService {
8889
private final Cache<String, Realm> lastSuccessfulAuthCache;
8990
private final AtomicLong numInvalidation = new AtomicLong();
9091
private final ApiKeyService apiKeyService;
92+
private final ServiceAccountService serviceAccountService;
9193
private final OperatorPrivilegesService operatorPrivilegesService;
9294
private final boolean runAsEnabled;
9395
private final boolean isAnonymousUserEnabled;
@@ -96,6 +98,7 @@ public class AuthenticationService {
9698
public AuthenticationService(Settings settings, Realms realms, AuditTrailService auditTrailService,
9799
AuthenticationFailureHandler failureHandler, ThreadPool threadPool,
98100
AnonymousUser anonymousUser, TokenService tokenService, ApiKeyService apiKeyService,
101+
ServiceAccountService serviceAccountService,
99102
OperatorPrivilegesService operatorPrivilegesService) {
100103
this.nodeName = Node.NODE_NAME_SETTING.get(settings);
101104
this.realms = realms;
@@ -115,6 +118,7 @@ public AuthenticationService(Settings settings, Realms realms, AuditTrailService
115118
this.lastSuccessfulAuthCache = null;
116119
}
117120
this.apiKeyService = apiKeyService;
121+
this.serviceAccountService = serviceAccountService;
118122
this.operatorPrivilegesService = operatorPrivilegesService;
119123
this.authenticationSerializer = new AuthenticationContextSerializer();
120124
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.security.authc.service;
9+
10+
import org.elasticsearch.common.Strings;
11+
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
12+
import org.elasticsearch.xpack.core.security.user.User;
13+
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.Objects;
17+
import java.util.function.Function;
18+
import java.util.stream.Collectors;
19+
20+
final class ElasticServiceAccounts {
21+
22+
static final String NAMESPACE = "elastic";
23+
24+
private static final ServiceAccount FLEET_ACCOUNT = new ElasticServiceAccount("fleet",
25+
new RoleDescriptor(
26+
NAMESPACE + "/fleet",
27+
new String[]{"monitor", "manage_own_api_key"},
28+
new RoleDescriptor.IndicesPrivileges[]{
29+
RoleDescriptor.IndicesPrivileges
30+
.builder()
31+
.indices("logs-*", "metrics-*", "traces-*")
32+
.privileges("write", "create_index", "auto_configure")
33+
.build()
34+
},
35+
null,
36+
null,
37+
null,
38+
null,
39+
null
40+
));
41+
42+
static Map<String, ServiceAccount> ACCOUNTS = List.of(FLEET_ACCOUNT).stream()
43+
.collect(Collectors.toMap(a -> a.id().serviceName(), Function.identity()));;
44+
45+
private ElasticServiceAccounts() {}
46+
47+
static class ElasticServiceAccount implements ServiceAccount {
48+
private final ServiceAccountId id;
49+
private final RoleDescriptor roleDescriptor;
50+
private final User user;
51+
52+
ElasticServiceAccount(String serviceName, RoleDescriptor roleDescriptor) {
53+
this.id = new ServiceAccountId(NAMESPACE, serviceName);
54+
this.roleDescriptor = Objects.requireNonNull(roleDescriptor, "Role descriptor cannot be null");
55+
if (roleDescriptor.getName().equals(id.asPrincipal()) == false) {
56+
throw new IllegalArgumentException("the provided role descriptor [" + roleDescriptor.getName()
57+
+ "] must have the same name as the service account [" + id.asPrincipal() + "]");
58+
}
59+
this.user = new User(id.asPrincipal(), Strings.EMPTY_ARRAY, "Service account - " + id, null,
60+
Map.of("_elastic_service_account", true),
61+
true);
62+
}
63+
64+
@Override
65+
public ServiceAccountId id() {
66+
return id;
67+
}
68+
69+
@Override
70+
public RoleDescriptor roleDescriptor() {
71+
return roleDescriptor;
72+
}
73+
74+
@Override
75+
public User asUser() {
76+
return user;
77+
}
78+
}
79+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.security.authc.service;
9+
10+
import org.apache.logging.log4j.util.Strings;
11+
import org.elasticsearch.common.io.stream.StreamInput;
12+
import org.elasticsearch.common.io.stream.StreamOutput;
13+
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
14+
import org.elasticsearch.xpack.core.security.user.User;
15+
16+
import java.io.IOException;
17+
import java.util.Objects;
18+
19+
public interface ServiceAccount {
20+
21+
ServiceAccountId id();
22+
23+
RoleDescriptor roleDescriptor();
24+
25+
User asUser();
26+
27+
final class ServiceAccountId {
28+
29+
private final String namespace;
30+
private final String serviceName;
31+
32+
public static ServiceAccountId fromPrincipal(String principal) {
33+
final int split = principal.indexOf('/');
34+
if (split == -1) {
35+
throw new IllegalArgumentException(
36+
"a service account ID must be in the form {namespace}/{service-name}, but was [" + principal + "]");
37+
}
38+
return new ServiceAccountId(principal.substring(0, split), principal.substring(split + 1));
39+
}
40+
41+
public ServiceAccountId(String namespace, String serviceName) {
42+
this.namespace = namespace;
43+
this.serviceName = serviceName;
44+
if (Strings.isBlank(this.namespace)) {
45+
throw new IllegalArgumentException("the namespace of a service account ID must not be empty");
46+
}
47+
if (Strings.isBlank(this.serviceName)) {
48+
throw new IllegalArgumentException("the service-name of a service account ID must not be empty");
49+
}
50+
}
51+
52+
public ServiceAccountId(StreamInput in) throws IOException {
53+
this.namespace = in.readString();
54+
this.serviceName = in.readString();
55+
}
56+
57+
public void write(StreamOutput out) throws IOException {
58+
out.writeString(namespace);
59+
out.writeString(serviceName);
60+
}
61+
62+
public String namespace() {
63+
return namespace;
64+
}
65+
66+
public String serviceName() {
67+
return serviceName;
68+
}
69+
70+
public String asPrincipal() {
71+
return namespace + "/" + serviceName;
72+
}
73+
74+
@Override
75+
public String toString() {
76+
return asPrincipal();
77+
}
78+
79+
@Override
80+
public boolean equals(Object o) {
81+
if (this == o) return true;
82+
if (o == null || getClass() != o.getClass()) return false;
83+
ServiceAccountId that = (ServiceAccountId) o;
84+
return namespace.equals(that.namespace) && serviceName.equals(that.serviceName);
85+
}
86+
87+
@Override
88+
public int hashCode() {
89+
return Objects.hash(namespace, serviceName);
90+
}
91+
}
92+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.security.authc.service;
9+
10+
import org.apache.logging.log4j.LogManager;
11+
import org.apache.logging.log4j.Logger;
12+
import org.apache.logging.log4j.message.ParameterizedMessage;
13+
import org.elasticsearch.ElasticsearchSecurityException;
14+
import org.elasticsearch.Version;
15+
import org.elasticsearch.action.ActionListener;
16+
import org.elasticsearch.common.CharArrays;
17+
import org.elasticsearch.common.hash.MessageDigests;
18+
import org.elasticsearch.common.io.stream.InputStreamStreamInput;
19+
import org.elasticsearch.common.io.stream.StreamInput;
20+
import org.elasticsearch.common.settings.SecureString;
21+
import org.elasticsearch.common.util.concurrent.ThreadContext;
22+
import org.elasticsearch.xpack.core.security.authc.Authentication;
23+
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
24+
import org.elasticsearch.xpack.core.security.user.User;
25+
import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId;
26+
import org.elasticsearch.xpack.security.authc.support.SecurityTokenType;
27+
28+
import java.io.ByteArrayInputStream;
29+
import java.io.IOException;
30+
import java.util.Base64;
31+
import java.util.Map;
32+
33+
import static org.elasticsearch.xpack.security.authc.service.ElasticServiceAccounts.ACCOUNTS;
34+
35+
public class ServiceAccountService {
36+
37+
public static final String REALM_TYPE = "service_account";
38+
public static final String REALM_NAME = "service_account";
39+
public static final Version VERSION_MINIMUM = Version.V_8_0_0;
40+
41+
private static final Logger logger = LogManager.getLogger(ServiceAccountService.class);
42+
43+
private final ServiceAccountsCredentialStore serviceAccountsCredentialStore;
44+
45+
public ServiceAccountService(ServiceAccountsCredentialStore serviceAccountsCredentialStore) {
46+
this.serviceAccountsCredentialStore = serviceAccountsCredentialStore;
47+
}
48+
49+
public static boolean isServiceAccount(Authentication authentication) {
50+
return REALM_TYPE.equals(authentication.getAuthenticatedBy().getType()) && null == authentication.getLookedUpBy();
51+
}
52+
53+
// {@link org.elasticsearch.xpack.security.authc.TokenService#extractBearerTokenFromHeader extracted} from an HTTP authorization header.
54+
/**
55+
* Parses a token object from the content of a {@link ServiceAccountToken#asBearerString()} bearer string}.
56+
* This bearer string would typically be
57+
*
58+
* <p>
59+
* <strong>This method does not validate the credential, it simply parses it.</strong>
60+
* There is no guarantee that the {@link ServiceAccountToken#getSecret() secret} is valid,
61+
* or even that the {@link ServiceAccountToken#getAccountId() account} exists.
62+
* </p>
63+
* @param token A raw token string (if this is from an HTTP header, then the <code>"Bearer "</code> prefix must be removed before
64+
* calling this method.
65+
* @return An unvalidated token object.
66+
*/
67+
public static ServiceAccountToken tryParseToken(SecureString token) {
68+
try {
69+
if (token == null) {
70+
return null;
71+
}
72+
return doParseToken(token);
73+
} catch (IOException e) {
74+
logger.debug("Cannot parse possible service account token", e);
75+
return null;
76+
}
77+
}
78+
79+
public void authenticateWithToken(ServiceAccountToken token, ThreadContext threadContext, String nodeName,
80+
ActionListener<Authentication> listener) {
81+
82+
if (ElasticServiceAccounts.NAMESPACE.equals(token.getAccountId().namespace()) == false) {
83+
final ParameterizedMessage message = new ParameterizedMessage(
84+
"only [{}] service accounts are supported, but received [{}]",
85+
ElasticServiceAccounts.NAMESPACE, token.getAccountId().asPrincipal());
86+
logger.debug(message);
87+
listener.onFailure(new ElasticsearchSecurityException(message.getFormattedMessage()));
88+
return;
89+
}
90+
91+
final ServiceAccount account = ACCOUNTS.get(token.getAccountId().serviceName());
92+
if (account == null) {
93+
final ParameterizedMessage message = new ParameterizedMessage(
94+
"the [{}] service account does not exist", token.getAccountId().asPrincipal());
95+
logger.debug(message);
96+
listener.onFailure(new ElasticsearchSecurityException(message.getFormattedMessage()));
97+
return;
98+
}
99+
100+
if (serviceAccountsCredentialStore.authenticate(token)) {
101+
listener.onResponse(success(account, token, nodeName));
102+
} else {
103+
final ParameterizedMessage message = new ParameterizedMessage(
104+
"failed to authenticate service account [{}] with token name [{}]",
105+
token.getAccountId().asPrincipal(),
106+
token.getTokenName());
107+
logger.debug(message);
108+
listener.onFailure(new ElasticsearchSecurityException(message.getFormattedMessage()));
109+
}
110+
}
111+
112+
public void getRoleDescriptor(Authentication authentication, ActionListener<RoleDescriptor> listener) {
113+
assert isServiceAccount(authentication) : "authentication is not for service account: " + authentication;
114+
115+
final ServiceAccountId accountId = ServiceAccountId.fromPrincipal(authentication.getUser().principal());
116+
final ServiceAccount account = ACCOUNTS.get(accountId.serviceName());
117+
if (account == null) {
118+
listener.onFailure(new ElasticsearchSecurityException(
119+
"cannot load role for service account [" + accountId.asPrincipal() + "] - no such service account"
120+
));
121+
return;
122+
}
123+
listener.onResponse(account.roleDescriptor());
124+
}
125+
126+
private Authentication success(ServiceAccount account, ServiceAccountToken token, String nodeName) {
127+
final User user = account.asUser();
128+
final Authentication.RealmRef authenticatedBy = new Authentication.RealmRef(REALM_NAME, REALM_TYPE, nodeName);
129+
return new Authentication(user, authenticatedBy, null, Version.CURRENT, Authentication.AuthenticationType.TOKEN,
130+
Map.of("_token_name", token.getTokenName()));
131+
}
132+
133+
134+
135+
private static ServiceAccountToken doParseToken(SecureString token) throws IOException {
136+
final byte[] bytes = CharArrays.toUtf8Bytes(token.getChars());
137+
logger.trace("parsing token bytes {}", MessageDigests.toHexString(bytes));
138+
try (StreamInput in = new InputStreamStreamInput(Base64.getDecoder().wrap(new ByteArrayInputStream(bytes)), bytes.length)) {
139+
final Version version = Version.readVersion(in);
140+
in.setVersion(version);
141+
if (version.before(VERSION_MINIMUM)) {
142+
logger.trace("token has version {}, but we need at least {}", version, VERSION_MINIMUM);
143+
return null;
144+
}
145+
final SecurityTokenType tokenType = SecurityTokenType.read(in);
146+
if (tokenType != SecurityTokenType.SERVICE_ACCOUNT) {
147+
logger.trace("token is of type {}, but we only handle {}", tokenType, SecurityTokenType.SERVICE_ACCOUNT);
148+
return null;
149+
}
150+
151+
final ServiceAccountId account = new ServiceAccountId(in);
152+
final String tokenName = in.readString();
153+
final SecureString secret = in.readSecureString();
154+
155+
return new ServiceAccountToken(account, tokenName, secret);
156+
}
157+
}
158+
159+
}

0 commit comments

Comments
 (0)