Skip to content

Commit 476879a

Browse files
authored
Implement lookup of permissions for API keys (#35970)
This change implements a lookup of permissions for API keys when a request moves to authorization. In order to support this, the authentication of an API key will attach values as metadata on the authentication result. The values attached will include the source of the role descriptors. The authentication service will then copy this metadata to the authentication object and set the authentication type to API_KEY. The authorization service will use the authentication type to make a decision on how the roles should be obtained.
1 parent af2f1f6 commit 476879a

18 files changed

+477
-143
lines changed

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

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
import java.io.IOException;
2020
import java.util.Base64;
21+
import java.util.Collections;
22+
import java.util.Map;
2123
import java.util.Objects;
2224

2325
// TODO(hub-cap) Clean this up after moving User over - This class can re-inherit its field AUTHENTICATION_KEY in AuthenticationField.
@@ -28,16 +30,25 @@ public class Authentication implements ToXContentObject {
2830
private final RealmRef authenticatedBy;
2931
private final RealmRef lookedUpBy;
3032
private final Version version;
33+
private final AuthenticationType type;
34+
private final Map<String, Object> metadata;
3135

3236
public Authentication(User user, RealmRef authenticatedBy, RealmRef lookedUpBy) {
3337
this(user, authenticatedBy, lookedUpBy, Version.CURRENT);
3438
}
3539

3640
public Authentication(User user, RealmRef authenticatedBy, RealmRef lookedUpBy, Version version) {
41+
this(user, authenticatedBy, lookedUpBy, version, AuthenticationType.REALM, Collections.emptyMap());
42+
}
43+
44+
public Authentication(User user, RealmRef authenticatedBy, RealmRef lookedUpBy, Version version,
45+
AuthenticationType type, Map<String, Object> metadata) {
3746
this.user = Objects.requireNonNull(user);
3847
this.authenticatedBy = Objects.requireNonNull(authenticatedBy);
3948
this.lookedUpBy = lookedUpBy;
4049
this.version = version;
50+
this.type = type;
51+
this.metadata = metadata;
4152
}
4253

4354
public Authentication(StreamInput in) throws IOException {
@@ -49,6 +60,13 @@ public Authentication(StreamInput in) throws IOException {
4960
this.lookedUpBy = null;
5061
}
5162
this.version = in.getVersion();
63+
if (in.getVersion().onOrAfter(Version.V_7_0_0)) { // TODO change to V6_6 after backport
64+
type = AuthenticationType.values()[in.readVInt()];
65+
metadata = in.readMap();
66+
} else {
67+
type = AuthenticationType.REALM;
68+
metadata = Collections.emptyMap();
69+
}
5270
}
5371

5472
public User getUser() {
@@ -67,8 +85,15 @@ public Version getVersion() {
6785
return version;
6886
}
6987

70-
public static Authentication readFromContext(ThreadContext ctx)
71-
throws IOException, IllegalArgumentException {
88+
public AuthenticationType getAuthenticationType() {
89+
return type;
90+
}
91+
92+
public Map<String, Object> getMetadata() {
93+
return metadata;
94+
}
95+
96+
public static Authentication readFromContext(ThreadContext ctx) throws IOException, IllegalArgumentException {
7297
Authentication authentication = ctx.getTransient(AuthenticationField.AUTHENTICATION_KEY);
7398
if (authentication != null) {
7499
assert ctx.getHeader(AuthenticationField.AUTHENTICATION_KEY) != null;
@@ -107,8 +132,7 @@ public static Authentication decode(String header) throws IOException {
107132
* Writes the authentication to the context. There must not be an existing authentication in the context and if there is an
108133
* {@link IllegalStateException} will be thrown
109134
*/
110-
public void writeToContext(ThreadContext ctx)
111-
throws IOException, IllegalArgumentException {
135+
public void writeToContext(ThreadContext ctx) throws IOException, IllegalArgumentException {
112136
ensureContextDoesNotContainAuthentication(ctx);
113137
String header = encode();
114138
ctx.putTransient(AuthenticationField.AUTHENTICATION_KEY, this);
@@ -141,28 +165,28 @@ public void writeTo(StreamOutput out) throws IOException {
141165
} else {
142166
out.writeBoolean(false);
143167
}
168+
if (out.getVersion().onOrAfter(Version.V_7_0_0)) { // TODO change to V6_6 after backport
169+
out.writeVInt(type.ordinal());
170+
out.writeMap(metadata);
171+
}
144172
}
145173

146174
@Override
147175
public boolean equals(Object o) {
148176
if (this == o) return true;
149177
if (o == null || getClass() != o.getClass()) return false;
150-
151178
Authentication that = (Authentication) o;
152-
153-
if (!user.equals(that.user)) return false;
154-
if (!authenticatedBy.equals(that.authenticatedBy)) return false;
155-
if (lookedUpBy != null ? !lookedUpBy.equals(that.lookedUpBy) : that.lookedUpBy != null) return false;
156-
return version.equals(that.version);
179+
return user.equals(that.user) &&
180+
authenticatedBy.equals(that.authenticatedBy) &&
181+
Objects.equals(lookedUpBy, that.lookedUpBy) &&
182+
version.equals(that.version) &&
183+
type == that.type &&
184+
metadata.equals(that.metadata);
157185
}
158186

159187
@Override
160188
public int hashCode() {
161-
int result = user.hashCode();
162-
result = 31 * result + authenticatedBy.hashCode();
163-
result = 31 * result + (lookedUpBy != null ? lookedUpBy.hashCode() : 0);
164-
result = 31 * result + version.hashCode();
165-
return result;
189+
return Objects.hash(user, authenticatedBy, lookedUpBy, version, type, metadata);
166190
}
167191

168192
@Override
@@ -246,5 +270,11 @@ public int hashCode() {
246270
return result;
247271
}
248272
}
273+
274+
public enum AuthenticationType {
275+
REALM,
276+
API_KEY,
277+
TOKEN
278+
}
249279
}
250280

x-pack/plugin/core/src/main/resources/security-index-template.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
},
157157
"role_descriptors" : {
158158
"type" : "object",
159-
"dynamic" : true
159+
"enabled": false
160160
},
161161
"version" : {
162162
"type" : "integer"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
479479
// minimal
480480
getLicenseState().addListener(allRolesStore::invalidateAll);
481481
final AuthorizationService authzService = new AuthorizationService(settings, allRolesStore, clusterService,
482-
auditTrailService, failureHandler, threadPool, anonymousUser);
482+
auditTrailService, failureHandler, threadPool, anonymousUser, apiKeyService);
483483
components.add(nativeRolesStore); // used by roles actions
484484
components.add(reservedRolesStore); // used by roles actions
485485
components.add(allRolesStore); // for SecurityFeatureSet and clear roles cache

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportGetUserPrivilegesAction.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,14 @@ public TransportGetUserPrivilegesAction(ThreadPool threadPool, TransportService
6060
protected void doExecute(Task task, GetUserPrivilegesRequest request, ActionListener<GetUserPrivilegesResponse> listener) {
6161
final String username = request.username();
6262

63-
final User user = Authentication.getAuthentication(threadPool.getThreadContext()).getUser();
63+
final Authentication authentication = Authentication.getAuthentication(threadPool.getThreadContext());
64+
final User user = authentication.getUser();
6465
if (user.principal().equals(username) == false) {
6566
listener.onFailure(new IllegalArgumentException("users may only list the privileges of their own account"));
6667
return;
6768
}
6869

69-
authorizationService.roles(user, ActionListener.wrap(
70+
authorizationService.roles(user, authentication, ActionListener.wrap(
7071
role -> listener.onResponse(buildResponseObject(role)),
7172
listener::onFailure));
7273
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportHasPrivilegesAction.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,14 @@ public TransportHasPrivilegesAction(ThreadPool threadPool, TransportService tran
6868
protected void doExecute(Task task, HasPrivilegesRequest request, ActionListener<HasPrivilegesResponse> listener) {
6969
final String username = request.username();
7070

71-
final User user = Authentication.getAuthentication(threadPool.getThreadContext()).getUser();
71+
final Authentication authentication = Authentication.getAuthentication(threadPool.getThreadContext());
72+
final User user = authentication.getUser();
7273
if (user.principal().equals(username) == false) {
7374
listener.onFailure(new IllegalArgumentException("users may only check the privileges of their own account"));
7475
return;
7576
}
7677

77-
authorizationService.roles(user, ActionListener.wrap(
78+
authorizationService.roles(user, authentication, ActionListener.wrap(
7879
role -> resolveApplicationPrivileges(request, ActionListener.wrap(
7980
applicationPrivilegeLookup -> checkPrivileges(request, role, applicationPrivilegeLookup, listener),
8081
listener::onFailure)),

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

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,41 @@
1919
import org.elasticsearch.common.CharArrays;
2020
import org.elasticsearch.common.Strings;
2121
import org.elasticsearch.common.UUIDs;
22+
import org.elasticsearch.common.bytes.BytesReference;
23+
import org.elasticsearch.common.logging.DeprecationLogger;
2224
import org.elasticsearch.common.settings.SecureString;
2325
import org.elasticsearch.common.settings.Setting;
2426
import org.elasticsearch.common.settings.Settings;
2527
import org.elasticsearch.common.util.concurrent.ThreadContext;
28+
import org.elasticsearch.common.xcontent.DeprecationHandler;
29+
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
2630
import org.elasticsearch.common.xcontent.XContentBuilder;
2731
import org.elasticsearch.common.xcontent.XContentFactory;
32+
import org.elasticsearch.common.xcontent.XContentParser;
33+
import org.elasticsearch.common.xcontent.XContentType;
2834
import org.elasticsearch.xpack.core.XPackSettings;
2935
import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest;
3036
import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse;
3137
import org.elasticsearch.xpack.core.security.authc.Authentication;
3238
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
3339
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
40+
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
41+
import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissionsCache;
42+
import org.elasticsearch.xpack.core.security.authz.permission.Role;
3443
import org.elasticsearch.xpack.core.security.user.User;
44+
import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore;
3545
import org.elasticsearch.xpack.security.support.SecurityIndexManager;
3646

3747
import javax.crypto.SecretKeyFactory;
3848
import java.io.Closeable;
3949
import java.io.IOException;
50+
import java.io.UncheckedIOException;
4051
import java.security.NoSuchAlgorithmException;
4152
import java.time.Clock;
4253
import java.time.Instant;
4354
import java.util.Arrays;
4455
import java.util.Base64;
56+
import java.util.HashMap;
4557
import java.util.List;
4658
import java.util.Locale;
4759
import java.util.Map;
@@ -55,7 +67,12 @@
5567
public class ApiKeyService {
5668

5769
private static final Logger logger = LogManager.getLogger(ApiKeyService.class);
70+
private static final DeprecationLogger deprecationLogger = new DeprecationLogger(logger);
5871
private static final String TYPE = "doc";
72+
static final String API_KEY_ID_KEY = "_security_api_key_id";
73+
static final String API_KEY_ROLE_DESCRIPTORS_KEY = "_security_api_key_role_descriptors";
74+
static final String API_KEY_ROLE_KEY = "_security_api_key_role";
75+
5976
public static final Setting<String> PASSWORD_HASHING_ALGORITHM = new Setting<>(
6077
"xpack.security.authc.api_key_hashing.algorithm", "pbkdf2", Function.identity(), (v, s) -> {
6178
if (Hasher.getAvailableAlgoStoredHash().contains(v.toLowerCase(Locale.ROOT)) == false) {
@@ -126,8 +143,12 @@ public void createApiKey(Authentication authentication, CreateApiKeyRequest requ
126143
}
127144
}
128145

129-
builder.array("role_descriptors", request.getRoleDescriptors())
130-
.field("name", request.getName())
146+
builder.startObject("role_descriptors");
147+
for (RoleDescriptor descriptor : request.getRoleDescriptors()) {
148+
builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true));
149+
}
150+
builder.endObject();
151+
builder.field("name", request.getName())
131152
.field("version", version.id)
132153
.startObject("creator")
133154
.field("principal", authentication.getUser().principal())
@@ -174,7 +195,8 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener<Authentic
174195
executeAsyncWithOrigin(ctx, SECURITY_ORIGIN, getRequest, ActionListener.<GetResponse>wrap(response -> {
175196
if (response.isExists()) {
176197
try (ApiKeyCredentials ignore = credentials) {
177-
validateApiKeyCredentials(response.getSource(), credentials, clock, listener);
198+
final Map<String, Object> source = response.getSource();
199+
validateApiKeyCredentials(source, credentials, clock, listener);
178200
}
179201
} else {
180202
credentials.close();
@@ -194,6 +216,56 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener<Authentic
194216
}
195217
}
196218

219+
/**
220+
* The current request has been authenticated by an API key and this method enables the
221+
* retrieval of role descriptors that are associated with the api key and triggers the building
222+
* of the {@link Role} to authorize the request.
223+
*/
224+
public void getRoleForApiKey(Authentication authentication, ThreadContext threadContext, CompositeRolesStore rolesStore,
225+
FieldPermissionsCache fieldPermissionsCache, ActionListener<Role> listener) {
226+
if (authentication.getAuthenticationType() != Authentication.AuthenticationType.API_KEY) {
227+
throw new IllegalStateException("authentication type must be api key but is " + authentication.getAuthenticationType());
228+
}
229+
230+
final Map<String, Object> metadata = authentication.getMetadata();
231+
final String apiKeyId = (String) metadata.get(API_KEY_ID_KEY);
232+
final String contextKeyId = threadContext.getTransient(API_KEY_ID_KEY);
233+
if (apiKeyId.equals(contextKeyId)) {
234+
final Role preBuiltRole = threadContext.getTransient(API_KEY_ROLE_KEY);
235+
if (preBuiltRole != null) {
236+
listener.onResponse(preBuiltRole);
237+
return;
238+
}
239+
} else if (contextKeyId != null) {
240+
throw new IllegalStateException("authentication api key id [" + apiKeyId + "] does not match context value [" +
241+
contextKeyId + "]");
242+
}
243+
244+
final Map<String, Object> roleDescriptors = (Map<String, Object>) metadata.get(API_KEY_ROLE_DESCRIPTORS_KEY);
245+
final List<RoleDescriptor> roleDescriptorList = roleDescriptors.entrySet().stream()
246+
.map(entry -> {
247+
final String name = entry.getKey();
248+
final Map<String, Object> rdMap = (Map<String, Object>) entry.getValue();
249+
try (XContentBuilder builder = XContentBuilder.builder(XContentType.JSON.xContent())) {
250+
builder.map(rdMap);
251+
try (XContentParser parser = XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY,
252+
new ApiKeyLoggingDeprecationHandler(deprecationLogger, apiKeyId),
253+
BytesReference.bytes(builder).streamInput())) {
254+
return RoleDescriptor.parse(name, parser, false);
255+
}
256+
} catch (IOException e) {
257+
throw new UncheckedIOException(e);
258+
}
259+
}).collect(Collectors.toList());
260+
261+
rolesStore.buildRoleFromDescriptors(roleDescriptorList, fieldPermissionsCache, ActionListener.wrap(role -> {
262+
threadContext.putTransient(API_KEY_ID_KEY, apiKeyId);
263+
threadContext.putTransient(API_KEY_ROLE_KEY, role);
264+
listener.onResponse(role);
265+
}, listener::onFailure));
266+
267+
}
268+
197269
/**
198270
* Validates the ApiKey using the source map
199271
* @param source the source map from a get of the ApiKey document
@@ -214,13 +286,13 @@ static void validateApiKeyCredentials(Map<String, Object> source, ApiKeyCredenti
214286
final Map<String, Object> creator = Objects.requireNonNull((Map<String, Object>) source.get("creator"));
215287
final String principal = Objects.requireNonNull((String) creator.get("principal"));
216288
final Map<String, Object> metadata = (Map<String, Object>) creator.get("metadata");
217-
final List<Map<String, Object>> roleDescriptors = (List<Map<String, Object>>) source.get("role_descriptors");
218-
final String[] roleNames = roleDescriptors.stream()
219-
.map(rdSource -> (String) rdSource.get("name"))
220-
.collect(Collectors.toList())
221-
.toArray(Strings.EMPTY_ARRAY);
289+
final Map<String, Object> roleDescriptors = (Map<String, Object>) source.get("role_descriptors");
290+
final String[] roleNames = roleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY);
222291
final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true);
223-
listener.onResponse(AuthenticationResult.success(apiKeyUser));
292+
final Map<String, Object> authResultMetadata = new HashMap<>();
293+
authResultMetadata.put(API_KEY_ROLE_DESCRIPTORS_KEY, roleDescriptors);
294+
authResultMetadata.put(API_KEY_ID_KEY, credentials.getId());
295+
listener.onResponse(AuthenticationResult.success(apiKeyUser, authResultMetadata));
224296
} else {
225297
listener.onResponse(AuthenticationResult.terminate("api key is expired", null));
226298
}
@@ -310,4 +382,27 @@ public void close() {
310382
key.close();
311383
}
312384
}
385+
386+
private static class ApiKeyLoggingDeprecationHandler implements DeprecationHandler {
387+
388+
private final DeprecationLogger deprecationLogger;
389+
private final String apiKeyId;
390+
391+
private ApiKeyLoggingDeprecationHandler(DeprecationLogger logger, String apiKeyId) {
392+
this.deprecationLogger = logger;
393+
this.apiKeyId = apiKeyId;
394+
}
395+
396+
@Override
397+
public void usedDeprecatedName(String usedName, String modernName) {
398+
deprecationLogger.deprecated("Deprecated field [{}] used in api key [{}], expected [{}] instead",
399+
usedName, apiKeyId, modernName);
400+
}
401+
402+
@Override
403+
public void usedDeprecatedField(String usedName, String replacedWith) {
404+
deprecationLogger.deprecated("Deprecated field [{}] used in api key [{}], replaced by [{}]",
405+
usedName, apiKeyId, replacedWith);
406+
}
407+
}
313408
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.apache.logging.log4j.message.ParameterizedMessage;
1111
import org.apache.logging.log4j.util.Supplier;
1212
import org.elasticsearch.ElasticsearchSecurityException;
13+
import org.elasticsearch.Version;
1314
import org.elasticsearch.action.ActionListener;
1415
import org.elasticsearch.action.support.ContextPreservingActionListener;
1516
import org.elasticsearch.common.Nullable;
@@ -205,7 +206,8 @@ private void checkForApiKey() {
205206
if (authResult.isAuthenticated()) {
206207
final User user = authResult.getUser();
207208
authenticatedBy = new RealmRef("_es_api_key", "_es_api_key", nodeName);
208-
writeAuthToContext(new Authentication(user, authenticatedBy, null));
209+
writeAuthToContext(new Authentication(user, authenticatedBy, null, Version.CURRENT,
210+
Authentication.AuthenticationType.API_KEY, authResult.getMetadata()));
209211
} else if (authResult.getStatus() == AuthenticationResult.Status.TERMINATE) {
210212
Exception e = (authResult.getException() != null) ? authResult.getException()
211213
: Exceptions.authenticationError(authResult.getMessage());

0 commit comments

Comments
 (0)