From 3725cb53da15892a83ac467aaaf1cd6e49cd9c31 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Mon, 29 Mar 2021 09:23:30 +1100 Subject: [PATCH] Support metadata on API keys (#70292) This PR adds metadata support for API keys. Metadata are of type Map and can be optionally provided at API key creation time. It is returned as part of GetApiKey response. It is also stored as part of the authentication object to transfer throw the wire. Note that it is not yet searchable and not exposed to any ingest processors. They will be handled by separate PRs. --- .../client/security/CreateApiKeyRequest.java | 23 +- .../client/security/support/ApiKey.java | 20 +- .../SecurityRequestConvertersTests.java | 4 +- .../SecurityDocumentationIT.java | 41 ++-- .../security/CreateApiKeyRequestTests.java | 80 +++++-- .../security/GetApiKeyResponseTests.java | 2 +- .../security/GrantApiKeyRequestTests.java | 35 ++- .../common/xcontent/ObjectParserHelper.java | 15 +- .../security/create-api-keys.asciidoc | 13 ++ .../rest-api/security/get-api-keys.asciidoc | 14 +- .../xpack/core/security/action/ApiKey.java | 34 ++- .../security/action/CreateApiKeyRequest.java | 30 +++ .../action/CreateApiKeyRequestBuilder.java | 12 +- .../core/security/action/ApiKeyTests.java | 75 +++++++ .../action/CreateApiKeyRequestTests.java | 13 ++ .../action/GetApiKeyResponseTests.java | 20 +- .../security/authc/ApiKeyIntegTests.java | 207 +++++++++++------- .../xpack/security/Security.java | 4 + .../xpack/security/authc/ApiKeyService.java | 34 ++- .../security/authc/ApiKeyServiceTests.java | 132 +++++++---- .../apikey/RestGetApiKeyActionTests.java | 13 +- x-pack/qa/rolling-upgrade/build.gradle | 4 +- .../test/mixed_cluster/120_api_key.yml | 42 ++++ .../test/mixed_cluster/120_api_key_auth.yml | 23 -- .../test/old_cluster/120_api_key.yml | 16 ++ .../test/upgraded_cluster/120_api_key.yml | 55 +++++ 26 files changed, 732 insertions(+), 229 deletions(-) create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/ApiKeyTests.java create mode 100644 x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/mixed_cluster/120_api_key.yml delete mode 100644 x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/mixed_cluster/120_api_key_auth.yml create mode 100644 x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/old_cluster/120_api_key.yml create mode 100644 x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/upgraded_cluster/120_api_key.yml diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyRequest.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyRequest.java index 9947f02600c65..fded84a1672a5 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyRequest.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateApiKeyRequest.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -28,19 +29,28 @@ public final class CreateApiKeyRequest implements Validatable, ToXContentObject private final TimeValue expiration; private final List roles; private final RefreshPolicy refreshPolicy; + private final Map metadata; /** * Create API Key request constructor * @param name name for the API key * @param roles list of {@link Role}s * @param expiration to specify expiration for the API key + * @param metadata Arbitrary metadata for the API key */ public CreateApiKeyRequest(String name, List roles, @Nullable TimeValue expiration, - @Nullable final RefreshPolicy refreshPolicy) { + @Nullable final RefreshPolicy refreshPolicy, + @Nullable Map metadata) { this.name = name; this.roles = Objects.requireNonNull(roles, "roles may not be null"); this.expiration = expiration; this.refreshPolicy = (refreshPolicy == null) ? RefreshPolicy.getDefault() : refreshPolicy; + this.metadata = metadata; + } + + public CreateApiKeyRequest(String name, List roles, @Nullable TimeValue expiration, + @Nullable final RefreshPolicy refreshPolicy) { + this(name, roles, expiration, refreshPolicy, null); } public String getName() { @@ -59,9 +69,13 @@ public RefreshPolicy getRefreshPolicy() { return refreshPolicy; } + public Map getMetadata() { + return metadata; + } + @Override public int hashCode() { - return Objects.hash(name, refreshPolicy, roles, expiration); + return Objects.hash(name, refreshPolicy, roles, expiration, metadata); } @Override @@ -74,7 +88,7 @@ public boolean equals(Object o) { } final CreateApiKeyRequest that = (CreateApiKeyRequest) o; return Objects.equals(name, that.name) && Objects.equals(refreshPolicy, that.refreshPolicy) && Objects.equals(roles, that.roles) - && Objects.equals(expiration, that.expiration); + && Objects.equals(expiration, that.expiration) && Objects.equals(metadata, that.metadata); } @Override @@ -107,6 +121,9 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.endObject(); } builder.endObject(); + if (metadata != null) { + builder.field("metadata", metadata); + } return builder.endObject(); } diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ApiKey.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ApiKey.java index d054e7e08a2e1..1503dc7f57d6e 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ApiKey.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ApiKey.java @@ -15,6 +15,7 @@ import java.io.IOException; import java.time.Instant; +import java.util.Map; import java.util.Objects; import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; @@ -32,8 +33,10 @@ public final class ApiKey { private final boolean invalidated; private final String username; private final String realm; + private final Map metadata; - public ApiKey(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, String realm) { + public ApiKey(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, String realm, + Map metadata) { this.name = name; this.id = id; // As we do not yet support the nanosecond precision when we serialize to JSON, @@ -44,6 +47,7 @@ public ApiKey(String name, String id, Instant creation, Instant expiration, bool this.invalidated = invalidated; this.username = username; this.realm = realm; + this.metadata = metadata; } public String getId() { @@ -90,9 +94,13 @@ public String getRealm() { return realm; } + public Map getMetadata() { + return metadata; + } + @Override public int hashCode() { - return Objects.hash(name, id, creation, expiration, invalidated, username, realm); + return Objects.hash(name, id, creation, expiration, invalidated, username, realm, metadata); } @Override @@ -113,12 +121,15 @@ public boolean equals(Object obj) { && Objects.equals(expiration, other.expiration) && Objects.equals(invalidated, other.invalidated) && Objects.equals(username, other.username) - && Objects.equals(realm, other.realm); + && Objects.equals(realm, other.realm) + && Objects.equals(metadata, other.metadata); } + @SuppressWarnings("unchecked") static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>("api_key", args -> { return new ApiKey((String) args[0], (String) args[1], Instant.ofEpochMilli((Long) args[2]), - (args[3] == null) ? null : Instant.ofEpochMilli((Long) args[3]), (Boolean) args[4], (String) args[5], (String) args[6]); + (args[3] == null) ? null : Instant.ofEpochMilli((Long) args[3]), (Boolean) args[4], (String) args[5], (String) args[6], + (Map) args[7]); }); static { PARSER.declareField(optionalConstructorArg(), (p, c) -> p.textOrNull(), new ParseField("name"), @@ -129,6 +140,7 @@ public boolean equals(Object obj) { PARSER.declareBoolean(constructorArg(), new ParseField("invalidated")); PARSER.declareString(constructorArg(), new ParseField("username")); PARSER.declareString(constructorArg(), new ParseField("realm")); + PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata")); } public static ApiKey fromXContent(XContentParser parser) throws IOException { diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java index 764f3823586e1..45b31474fa326 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java @@ -14,6 +14,7 @@ import org.apache.http.client.methods.HttpPut; import org.elasticsearch.client.security.ChangePasswordRequest; import org.elasticsearch.client.security.CreateApiKeyRequest; +import org.elasticsearch.client.security.CreateApiKeyRequestTests; import org.elasticsearch.client.security.CreateTokenRequest; import org.elasticsearch.client.security.DelegatePkiAuthenticationRequest; import org.elasticsearch.client.security.DeletePrivilegesRequest; @@ -449,7 +450,8 @@ private CreateApiKeyRequest buildCreateApiKeyRequest() { .indicesPrivileges(IndicesPrivileges.builder().indices("ind-x").privileges(IndexPrivilegeName.ALL).build()).build()); final TimeValue expiration = randomBoolean() ? null : TimeValue.timeValueHours(24); final RefreshPolicy refreshPolicy = randomFrom(RefreshPolicy.values()); - final CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + final Map metadata = CreateApiKeyRequestTests.randomMetadata(); + final CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy, metadata); return createApiKeyRequest; } diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java index d74bd32e4c888..acfac3dc652e7 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java @@ -28,6 +28,7 @@ import org.elasticsearch.client.security.ClearRolesCacheResponse; import org.elasticsearch.client.security.ClearSecurityCacheResponse; import org.elasticsearch.client.security.CreateApiKeyRequest; +import org.elasticsearch.client.security.CreateApiKeyRequestTests; import org.elasticsearch.client.security.CreateApiKeyResponse; import org.elasticsearch.client.security.CreateTokenRequest; import org.elasticsearch.client.security.CreateTokenResponse; @@ -1957,10 +1958,11 @@ public void testCreateApiKey() throws Exception { .indicesPrivileges(IndicesPrivileges.builder().indices("ind-x").privileges(IndexPrivilegeName.ALL).build()).build()); final TimeValue expiration = TimeValue.timeValueHours(24); final RefreshPolicy refreshPolicy = randomFrom(RefreshPolicy.values()); + final Map metadata = CreateApiKeyRequestTests.randomMetadata(); { final String name = randomAlphaOfLength(5); // tag::create-api-key-request - CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy, metadata); // end::create-api-key-request // tag::create-api-key-execute @@ -1978,7 +1980,7 @@ public void testCreateApiKey() throws Exception { { final String name = randomAlphaOfLength(5); - CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy, metadata); ActionListener listener; // tag::create-api-key-execute-listener @@ -2027,6 +2029,7 @@ public void testGrantApiKey() throws Exception { final Instant start = Instant.now(); + final Map metadata = CreateApiKeyRequestTests.randomMetadata(); CheckedConsumer apiKeyVerifier = (created) -> { final GetApiKeyRequest getApiKeyRequest = GetApiKeyRequest.usingApiKeyId(created.getId(), false); final GetApiKeyResponse getApiKeyResponse = client.security().getApiKey(getApiKeyRequest, RequestOptions.DEFAULT); @@ -2039,6 +2042,11 @@ public void testGrantApiKey() throws Exception { assertThat(apiKeyInfo.isInvalidated(), equalTo(false)); assertThat(apiKeyInfo.getCreation(), greaterThanOrEqualTo(start)); assertThat(apiKeyInfo.getCreation(), lessThanOrEqualTo(Instant.now())); + if (metadata == null) { + assertThat(apiKeyInfo.getMetadata(), equalTo(Map.of())); + } else { + assertThat(apiKeyInfo.getMetadata(), equalTo(metadata)); + } }; final TimeValue expiration = TimeValue.timeValueHours(24); @@ -2046,7 +2054,7 @@ public void testGrantApiKey() throws Exception { { final String name = randomAlphaOfLength(5); // tag::grant-api-key-request - CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy, metadata); GrantApiKeyRequest.Grant grant = GrantApiKeyRequest.Grant.passwordGrant(username, password); GrantApiKeyRequest grantApiKeyRequest = new GrantApiKeyRequest(grant, createApiKeyRequest); // end::grant-api-key-request @@ -2071,7 +2079,7 @@ public void testGrantApiKey() throws Exception { final CreateTokenRequest tokenRequest = CreateTokenRequest.passwordGrant(username, password); final CreateTokenResponse token = client.security().createToken(tokenRequest, RequestOptions.DEFAULT); - CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy, metadata); GrantApiKeyRequest.Grant grant = GrantApiKeyRequest.Grant.accessTokenGrant(token.getAccessToken()); GrantApiKeyRequest grantApiKeyRequest = new GrantApiKeyRequest(grant, createApiKeyRequest); @@ -2117,14 +2125,15 @@ public void testGetApiKey() throws Exception { .indicesPrivileges(IndicesPrivileges.builder().indices("ind-x").privileges(IndexPrivilegeName.ALL).build()).build()); final TimeValue expiration = TimeValue.timeValueHours(24); final RefreshPolicy refreshPolicy = randomFrom(RefreshPolicy.values()); + final Map metadata = CreateApiKeyRequestTests.randomMetadata(); // Create API Keys - CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest("k1", roles, expiration, refreshPolicy); + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest("k1", roles, expiration, refreshPolicy, metadata); CreateApiKeyResponse createApiKeyResponse1 = client.security().createApiKey(createApiKeyRequest, RequestOptions.DEFAULT); assertThat(createApiKeyResponse1.getName(), equalTo("k1")); assertNotNull(createApiKeyResponse1.getKey()); final ApiKey expectedApiKeyInfo = new ApiKey(createApiKeyResponse1.getName(), createApiKeyResponse1.getId(), Instant.now(), - Instant.now().plusMillis(expiration.getMillis()), false, "test_user", "default_file"); + Instant.now().plusMillis(expiration.getMillis()), false, "test_user", "default_file", metadata); { // tag::get-api-key-id-request GetApiKeyRequest getApiKeyRequest = GetApiKeyRequest.usingApiKeyId(createApiKeyResponse1.getId(), false); @@ -2258,6 +2267,11 @@ private void verifyApiKey(final ApiKey actual, final ApiKey expected) { assertThat(actual.getRealm(), is(expected.getRealm())); assertThat(actual.isInvalidated(), is(expected.isInvalidated())); assertThat(actual.getExpiration(), is(greaterThan(Instant.now()))); + if (expected.getMetadata() == null) { + assertThat(actual.getMetadata(), equalTo(Map.of())); + } else { + assertThat(actual.getMetadata(), equalTo(expected.getMetadata())); + } } public void testInvalidateApiKey() throws Exception { @@ -2267,8 +2281,9 @@ public void testInvalidateApiKey() throws Exception { .indicesPrivileges(IndicesPrivileges.builder().indices("ind-x").privileges(IndexPrivilegeName.ALL).build()).build()); final TimeValue expiration = TimeValue.timeValueHours(24); final RefreshPolicy refreshPolicy = randomFrom(RefreshPolicy.values()); + final Map metadata = CreateApiKeyRequestTests.randomMetadata(); // Create API Keys - CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest("k1", roles, expiration, refreshPolicy); + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest("k1", roles, expiration, refreshPolicy, metadata); CreateApiKeyResponse createApiKeyResponse1 = client.security().createApiKey(createApiKeyRequest, RequestOptions.DEFAULT); assertThat(createApiKeyResponse1.getName(), equalTo("k1")); assertNotNull(createApiKeyResponse1.getKey()); @@ -2312,7 +2327,7 @@ public void testInvalidateApiKey() throws Exception { } { - createApiKeyRequest = new CreateApiKeyRequest("k2", roles, expiration, refreshPolicy); + createApiKeyRequest = new CreateApiKeyRequest("k2", roles, expiration, refreshPolicy, metadata); CreateApiKeyResponse createApiKeyResponse2 = client.security().createApiKey(createApiKeyRequest, RequestOptions.DEFAULT); assertThat(createApiKeyResponse2.getName(), equalTo("k2")); assertNotNull(createApiKeyResponse2.getKey()); @@ -2336,7 +2351,7 @@ public void testInvalidateApiKey() throws Exception { } { - createApiKeyRequest = new CreateApiKeyRequest("k3", roles, expiration, refreshPolicy); + createApiKeyRequest = new CreateApiKeyRequest("k3", roles, expiration, refreshPolicy, metadata); CreateApiKeyResponse createApiKeyResponse3 = client.security().createApiKey(createApiKeyRequest, RequestOptions.DEFAULT); assertThat(createApiKeyResponse3.getName(), equalTo("k3")); assertNotNull(createApiKeyResponse3.getKey()); @@ -2359,7 +2374,7 @@ public void testInvalidateApiKey() throws Exception { } { - createApiKeyRequest = new CreateApiKeyRequest("k4", roles, expiration, refreshPolicy); + createApiKeyRequest = new CreateApiKeyRequest("k4", roles, expiration, refreshPolicy, metadata); CreateApiKeyResponse createApiKeyResponse4 = client.security().createApiKey(createApiKeyRequest, RequestOptions.DEFAULT); assertThat(createApiKeyResponse4.getName(), equalTo("k4")); assertNotNull(createApiKeyResponse4.getKey()); @@ -2382,7 +2397,7 @@ public void testInvalidateApiKey() throws Exception { } { - createApiKeyRequest = new CreateApiKeyRequest("k5", roles, expiration, refreshPolicy); + createApiKeyRequest = new CreateApiKeyRequest("k5", roles, expiration, refreshPolicy, metadata); CreateApiKeyResponse createApiKeyResponse5 = client.security().createApiKey(createApiKeyRequest, RequestOptions.DEFAULT); assertThat(createApiKeyResponse5.getName(), equalTo("k5")); assertNotNull(createApiKeyResponse5.getKey()); @@ -2407,7 +2422,7 @@ public void testInvalidateApiKey() throws Exception { } { - createApiKeyRequest = new CreateApiKeyRequest("k6", roles, expiration, refreshPolicy); + createApiKeyRequest = new CreateApiKeyRequest("k6", roles, expiration, refreshPolicy, metadata); CreateApiKeyResponse createApiKeyResponse6 = client.security().createApiKey(createApiKeyRequest, RequestOptions.DEFAULT); assertThat(createApiKeyResponse6.getName(), equalTo("k6")); assertNotNull(createApiKeyResponse6.getKey()); @@ -2450,7 +2465,7 @@ public void onFailure(Exception e) { } { - createApiKeyRequest = new CreateApiKeyRequest("k7", roles, expiration, refreshPolicy); + createApiKeyRequest = new CreateApiKeyRequest("k7", roles, expiration, refreshPolicy, metadata); CreateApiKeyResponse createApiKeyResponse7 = client.security().createApiKey(createApiKeyRequest, RequestOptions.DEFAULT); assertThat(createApiKeyResponse7.getName(), equalTo("k7")); assertNotNull(createApiKeyResponse7.getKey()); diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java index 1a7b63aecec3e..c68530357a4ee 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateApiKeyRequestTests.java @@ -12,11 +12,9 @@ import org.elasticsearch.client.security.user.privileges.Role; import org.elasticsearch.client.security.user.privileges.Role.ClusterPrivilegeName; import org.elasticsearch.client.security.user.privileges.Role.IndexPrivilegeName; -import org.elasticsearch.common.Strings; import org.elasticsearch.common.unit.TimeValue; -import org.elasticsearch.common.xcontent.ToXContent; -import org.elasticsearch.common.xcontent.XContentBuilder; -import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.EqualsHashCodeTestUtils; @@ -24,7 +22,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import static org.hamcrest.Matchers.equalTo; @@ -38,16 +38,35 @@ public void test() throws IOException { roles.add(Role.builder().name("r2").clusterPrivileges(ClusterPrivilegeName.ALL) .indicesPrivileges(IndicesPrivileges.builder().indices("ind-y").privileges(IndexPrivilegeName.ALL).build()).build()); - CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest("api-key", roles, null, null); - final XContentBuilder builder = XContentFactory.jsonBuilder(); - createApiKeyRequest.toXContent(builder, ToXContent.EMPTY_PARAMS); - final String output = Strings.toString(builder); - assertThat(output, equalTo( - "{\"name\":\"api-key\",\"role_descriptors\":{\"r1\":{\"applications\":[],\"cluster\":[\"all\"],\"indices\":[{\"names\":" - + "[\"ind-x\"],\"privileges\":[\"all\"],\"allow_restricted_indices\":false}],\"metadata\":{},\"run_as\":[]}," - + "\"r2\":{\"applications\":[],\"cluster\":" - + "[\"all\"],\"indices\":[{\"names\":[\"ind-y\"],\"privileges\":[\"all\"],\"allow_restricted_indices\":false}]," - + "\"metadata\":{},\"run_as\":[]}}}")); + final Map apiKeyMetadata = randomMetadata(); + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest("api-key", roles, null, null, apiKeyMetadata); + + Map expected = new HashMap<>(Map.of( + "name", "api-key", + "role_descriptors", Map.of( + "r1", Map.of( + "applications", List.of(), + "cluster", List.of("all"), + "indices", List.of( + Map.of("names", List.of("ind-x"), "privileges", List.of("all"), "allow_restricted_indices", false)), + "metadata", Map.of(), + "run_as", List.of()), + "r2", Map.of( + "applications", List.of(), + "cluster", List.of("all"), + "indices", List.of( + Map.of("names", List.of("ind-y"), "privileges", List.of("all"), "allow_restricted_indices", false)), + "metadata", Map.of(), + "run_as", List.of())) + )); + if (apiKeyMetadata != null) { + expected.put("metadata", apiKeyMetadata); + } + + assertThat( + XContentHelper.convertToMap(XContentHelper.toXContent( + createApiKeyRequest, XContentType.JSON, false), false, XContentType.JSON).v2(), + equalTo(expected)); } public void testEqualsHashCode() { @@ -57,38 +76,53 @@ public void testEqualsHashCode() { final TimeValue expiration = null; final RefreshPolicy refreshPolicy = randomFrom(RefreshPolicy.values()); - CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy, randomMetadata()); EqualsHashCodeTestUtils.checkEqualsAndHashCode(createApiKeyRequest, (original) -> { - return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), original.getRefreshPolicy()); + return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), original.getRefreshPolicy(), + original.getMetadata()); }); EqualsHashCodeTestUtils.checkEqualsAndHashCode(createApiKeyRequest, (original) -> { - return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), original.getRefreshPolicy()); + return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), original.getRefreshPolicy(), + original.getMetadata()); }, CreateApiKeyRequestTests::mutateTestItem); } private static CreateApiKeyRequest mutateTestItem(CreateApiKeyRequest original) { - switch (randomIntBetween(0, 3)) { + switch (randomIntBetween(0, 4)) { case 0: return new CreateApiKeyRequest(randomAlphaOfLength(5), original.getRoles(), original.getExpiration(), - original.getRefreshPolicy()); + original.getRefreshPolicy(), original.getMetadata()); case 1: return new CreateApiKeyRequest(original.getName(), Collections.singletonList(Role.builder().name(randomAlphaOfLength(6)).clusterPrivileges(ClusterPrivilegeName.ALL) .indicesPrivileges( IndicesPrivileges.builder().indices(randomAlphaOfLength(4)).privileges(IndexPrivilegeName.ALL).build()) .build()), - original.getExpiration(), original.getRefreshPolicy()); + original.getExpiration(), original.getRefreshPolicy(), original.getMetadata()); case 2: return new CreateApiKeyRequest(original.getName(), original.getRoles(), TimeValue.timeValueSeconds(10000), - original.getRefreshPolicy()); + original.getRefreshPolicy(), original.getMetadata()); case 3: List values = Arrays.stream(RefreshPolicy.values()).filter(rp -> rp != original.getRefreshPolicy()) .collect(Collectors.toList()); - return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), randomFrom(values)); + return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), randomFrom(values), + original.getMetadata()); + case 4: + return new CreateApiKeyRequest(original.getName(), original.getRoles(), original.getExpiration(), original.getRefreshPolicy(), + randomValueOtherThan(original.getMetadata(), CreateApiKeyRequestTests::randomMetadata)); default: return new CreateApiKeyRequest(randomAlphaOfLength(5), original.getRoles(), original.getExpiration(), - original.getRefreshPolicy()); + original.getRefreshPolicy(), original.getMetadata()); } } + + @SuppressWarnings("unchecked") + public static Map randomMetadata() { + return randomFrom( + Map.of("status", "active", "level", 42, "nested", Map.of("foo", "bar")), + Map.of("status", "active"), + Map.of(), + null); + } } diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GetApiKeyResponseTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GetApiKeyResponseTests.java index ffb894833dc7c..ac538a5b400a1 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GetApiKeyResponseTests.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GetApiKeyResponseTests.java @@ -86,6 +86,6 @@ private static GetApiKeyResponse mutateTestItem(GetApiKeyResponse original) { private static ApiKey createApiKeyInfo(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, String realm) { - return new ApiKey(name, id, creation, expiration, invalidated, username, realm); + return new ApiKey(name, id, creation, expiration, invalidated, username, realm, null); } } diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GrantApiKeyRequestTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GrantApiKeyRequestTests.java index 411dc061817fb..c837c52f717ea 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GrantApiKeyRequestTests.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/GrantApiKeyRequestTests.java @@ -17,11 +17,14 @@ import org.elasticsearch.common.xcontent.ToXContent; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.EqualsHashCodeTestUtils; +import org.elasticsearch.test.XContentTestUtils; import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import static org.hamcrest.Matchers.equalTo; @@ -29,18 +32,22 @@ public class GrantApiKeyRequestTests extends ESTestCase { public void testToXContent() throws IOException { - final CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest("api-key", List.of(), null, null); + final Map apiKeyMetadata = CreateApiKeyRequestTests.randomMetadata(); + final CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest("api-key", List.of(), null, null, + apiKeyMetadata); final GrantApiKeyRequest.Grant grant = GrantApiKeyRequest.Grant.passwordGrant("kamala.khan", "JerseyGirl!".toCharArray()); final GrantApiKeyRequest grantApiKeyRequest = new GrantApiKeyRequest(grant, createApiKeyRequest); final XContentBuilder builder = XContentFactory.jsonBuilder(); grantApiKeyRequest.toXContent(builder, ToXContent.EMPTY_PARAMS); final String output = Strings.toString(builder); + final String apiKeyMetadataString = apiKeyMetadata == null ? "" + : ",\"metadata\":" + XContentTestUtils.convertToXContent(apiKeyMetadata, XContentType.JSON).utf8ToString(); assertThat(output, equalTo( "{" + "\"grant_type\":\"password\"," + "\"username\":\"kamala.khan\"," + "\"password\":\"JerseyGirl!\"," + - "\"api_key\":{\"name\":\"api-key\",\"role_descriptors\":{}}" + + "\"api_key\":{\"name\":\"api-key\",\"role_descriptors\":{}" + apiKeyMetadataString + "}" + "}")); } @@ -61,7 +68,8 @@ public void testEqualsHashCode() { final TimeValue expiration = randomBoolean() ? null : TimeValue.timeValueHours(randomIntBetween(4, 100)); final RefreshPolicy refreshPolicy = randomFrom(RefreshPolicy.values()); - final CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy); + final CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(name, roles, expiration, refreshPolicy, + CreateApiKeyRequestTests.randomMetadata()); final GrantApiKeyRequest.Grant grant = randomBoolean() ? GrantApiKeyRequest.Grant.passwordGrant(randomAlphaOfLength(8), randomAlphaOfLengthBetween(6, 12).toCharArray()) : GrantApiKeyRequest.Grant.accessTokenGrant(randomAlphaOfLength(24)); @@ -89,7 +97,8 @@ private CreateApiKeyRequest clone(CreateApiKeyRequest apiKeyRequest) { apiKeyRequest.getName(), apiKeyRequest.getRoles().stream().map(r -> Role.builder().clone(r).build()).collect(Collectors.toUnmodifiableList()), apiKeyRequest.getExpiration(), - apiKeyRequest.getRefreshPolicy() + apiKeyRequest.getRefreshPolicy(), + apiKeyRequest.getMetadata() ); } @@ -106,7 +115,8 @@ private static GrantApiKeyRequest mutateTestItem(GrantApiKeyRequest original) { randomAlphaOfLengthBetween(10, 15), original.getApiKeyRequest().getRoles(), original.getApiKeyRequest().getExpiration(), - original.getApiKeyRequest().getRefreshPolicy() + original.getApiKeyRequest().getRefreshPolicy(), + original.getApiKeyRequest().getMetadata() ) ); case 2: @@ -115,17 +125,28 @@ private static GrantApiKeyRequest mutateTestItem(GrantApiKeyRequest original) { original.getApiKeyRequest().getName(), List.of(), // No role limits original.getApiKeyRequest().getExpiration(), - original.getApiKeyRequest().getRefreshPolicy() + original.getApiKeyRequest().getRefreshPolicy(), + original.getApiKeyRequest().getMetadata() ) ); case 3: + return new GrantApiKeyRequest(original.getGrant(), + new CreateApiKeyRequest( + original.getApiKeyRequest().getName(), + original.getApiKeyRequest().getRoles(), + original.getApiKeyRequest().getExpiration(), + original.getApiKeyRequest().getRefreshPolicy(), + randomValueOtherThan(original.getApiKeyRequest().getMetadata(), CreateApiKeyRequestTests::randomMetadata) + ) + ); default: return new GrantApiKeyRequest(original.getGrant(), new CreateApiKeyRequest( original.getApiKeyRequest().getName(), original.getApiKeyRequest().getRoles(), TimeValue.timeValueMinutes(randomIntBetween(10, 120)), - original.getApiKeyRequest().getRefreshPolicy() + original.getApiKeyRequest().getRefreshPolicy(), + original.getApiKeyRequest().getMetadata() ) ); } diff --git a/server/src/main/java/org/elasticsearch/common/xcontent/ObjectParserHelper.java b/server/src/main/java/org/elasticsearch/common/xcontent/ObjectParserHelper.java index 49345db15664d..c3e7a6c103136 100644 --- a/server/src/main/java/org/elasticsearch/common/xcontent/ObjectParserHelper.java +++ b/server/src/main/java/org/elasticsearch/common/xcontent/ObjectParserHelper.java @@ -29,13 +29,24 @@ public final class ObjectParserHelper { public void declareRawObject(final AbstractObjectParser parser, final BiConsumer consumer, final ParseField field) { - final CheckedFunction bytesParser = p -> { + final CheckedFunction bytesParser = getBytesParser(); + parser.declareField(consumer, bytesParser, field, ValueType.OBJECT); + } + + public void declareRawObjectOrNull(final AbstractObjectParser parser, + final BiConsumer consumer, + final ParseField field) { + final CheckedFunction bytesParser = getBytesParser(); + parser.declareField(consumer, bytesParser, field, ValueType.OBJECT_OR_NULL); + } + + private CheckedFunction getBytesParser() { + return p -> { try (XContentBuilder builder = JsonXContent.contentBuilder()) { builder.copyCurrentStructure(p); return BytesReference.bytes(builder); } }; - parser.declareField(consumer, bytesParser, field, ValueType.OBJECT); } } diff --git a/x-pack/docs/en/rest-api/security/create-api-keys.asciidoc b/x-pack/docs/en/rest-api/security/create-api-keys.asciidoc index c0b5f8294ee35..84002f2e3fba7 100644 --- a/x-pack/docs/en/rest-api/security/create-api-keys.asciidoc +++ b/x-pack/docs/en/rest-api/security/create-api-keys.asciidoc @@ -74,6 +74,11 @@ authentication; it will not have authority to call {es} APIs. (Optional, string) Expiration time for the API key. By default, API keys never expire. +`metadata`:: +(object) Arbitrary metadata that you want to associate with the API key. +It supports nested data structure. +Within the `metadata` object, keys beginning with `_` are reserved for +system usage. [[security-api-create-api-key-example]] ==== {api-examples-title} @@ -105,6 +110,14 @@ POST /_security/api_key } ] } + }, + "metadata": { + "application": "my-application", + "environment": { + "level": 1, + "trusted": true, + "tags": ["dev", "staging"] + } } } ------------------------------------------------------------ diff --git a/x-pack/docs/en/rest-api/security/get-api-keys.asciidoc b/x-pack/docs/en/rest-api/security/get-api-keys.asciidoc index ca9f90c2f6961..dce3f48370028 100644 --- a/x-pack/docs/en/rest-api/security/get-api-keys.asciidoc +++ b/x-pack/docs/en/rest-api/security/get-api-keys.asciidoc @@ -146,7 +146,10 @@ Following creates an API key ------------------------------------------------------------ POST /_security/api_key { - "name": "my-api-key-1" + "name": "my-api-key-1", + "metadata": { + "application": "my-application" + } } ------------------------------------------------------------ @@ -182,7 +185,10 @@ A successful call returns a JSON structure that contains the information of one "expiration": 1548551550158, <5> "invalidated": false, <6> "username": "myuser", <7> - "realm": "native1" <8> + "realm": "native1", <8> + "metadata": { <9> + "application": "myapp" + } }, { "id": "api-key-id-2", @@ -190,7 +196,8 @@ A successful call returns a JSON structure that contains the information of one "creation": 1548550550158, "invalidated": false, "username": "user-y", - "realm": "realm-2" + "realm": "realm-2", + "metadata": {} } ] } @@ -206,3 +213,4 @@ A successful call returns a JSON structure that contains the information of one a value of `true`. Otherwise, it is `false`. <7> Principal for which this API key was created <8> Realm name of the principal for which this API key was created +<9> Metadata of the API key diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java index 86ff8ce8ad22c..1b80d6d8a8747 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.core.security.action; import org.elasticsearch.Version; +import org.elasticsearch.common.Nullable; import org.elasticsearch.common.ParseField; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; @@ -19,6 +20,7 @@ import java.io.IOException; import java.time.Instant; +import java.util.Map; import java.util.Objects; import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; @@ -36,8 +38,10 @@ public final class ApiKey implements ToXContentObject, Writeable { private final boolean invalidated; private final String username; private final String realm; + private final Map metadata; - public ApiKey(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, String realm) { + public ApiKey(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, String realm, + @Nullable Map metadata) { this.name = name; this.id = id; // As we do not yet support the nanosecond precision when we serialize to JSON, @@ -48,6 +52,7 @@ public ApiKey(String name, String id, Instant creation, Instant expiration, bool this.invalidated = invalidated; this.username = username; this.realm = realm; + this.metadata = metadata == null ? Map.of() : metadata; } public ApiKey(StreamInput in) throws IOException { @@ -62,6 +67,11 @@ public ApiKey(StreamInput in) throws IOException { this.invalidated = in.readBoolean(); this.username = in.readString(); this.realm = in.readString(); + if (in.getVersion().onOrAfter(Version.V_8_0_0)) { + this.metadata = in.readMap(); + } else { + this.metadata = Map.of(); + } } public String getId() { @@ -92,6 +102,10 @@ public String getRealm() { return realm; } + public Map getMetadata() { + return metadata; + } + @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject() @@ -103,7 +117,8 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws } builder.field("invalidated", invalidated) .field("username", username) - .field("realm", realm); + .field("realm", realm) + .field("metadata", (metadata == null ? Map.of() : metadata)); return builder.endObject(); } @@ -120,11 +135,14 @@ public void writeTo(StreamOutput out) throws IOException { out.writeBoolean(invalidated); out.writeString(username); out.writeString(realm); + if (out.getVersion().onOrAfter(Version.V_8_0_0)) { + out.writeMap(metadata); + } } @Override public int hashCode() { - return Objects.hash(name, id, creation, expiration, invalidated, username, realm); + return Objects.hash(name, id, creation, expiration, invalidated, username, realm, metadata); } @Override @@ -145,12 +163,15 @@ public boolean equals(Object obj) { && Objects.equals(expiration, other.expiration) && Objects.equals(invalidated, other.invalidated) && Objects.equals(username, other.username) - && Objects.equals(realm, other.realm); + && Objects.equals(realm, other.realm) + && Objects.equals(metadata, other.metadata); } + @SuppressWarnings("unchecked") static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>("api_key", args -> { return new ApiKey((String) args[0], (String) args[1], Instant.ofEpochMilli((Long) args[2]), - (args[3] == null) ? null : Instant.ofEpochMilli((Long) args[3]), (Boolean) args[4], (String) args[5], (String) args[6]); + (args[3] == null) ? null : Instant.ofEpochMilli((Long) args[3]), (Boolean) args[4], (String) args[5], (String) args[6], + (args[7] == null) ? null : (Map) args[7]); }); static { PARSER.declareString(constructorArg(), new ParseField("name")); @@ -160,6 +181,7 @@ public boolean equals(Object obj) { PARSER.declareBoolean(constructorArg(), new ParseField("invalidated")); PARSER.declareString(constructorArg(), new ParseField("username")); PARSER.declareString(constructorArg(), new ParseField("realm")); + PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata")); } public static ApiKey fromXContent(XContentParser parser) throws IOException { @@ -169,7 +191,7 @@ public static ApiKey fromXContent(XContentParser parser) throws IOException { @Override public String toString() { return "ApiKey [name=" + name + ", id=" + id + ", creation=" + creation + ", expiration=" + expiration + ", invalidated=" - + invalidated + ", username=" + username + ", realm=" + realm + "]"; + + invalidated + ", username=" + username + ", realm=" + realm + ", metadata=" + metadata + "]"; } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java index ac9d536c3363a..0b7d918361e92 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequest.java @@ -18,10 +18,12 @@ import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.support.MetadataUtils; import java.io.IOException; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; import static org.elasticsearch.action.ValidateActions.addValidationError; @@ -36,10 +38,12 @@ public final class CreateApiKeyRequest extends ActionRequest { private final String id; private String name; private TimeValue expiration; + private Map metadata; private List roleDescriptors = Collections.emptyList(); private WriteRequest.RefreshPolicy refreshPolicy = DEFAULT_REFRESH_POLICY; public CreateApiKeyRequest() { + super(); this.id = UUIDs.base64UUID(); // because auditing can currently only catch requests but not responses, // we generate the API key id soonest so it's part of the request body so it is audited } @@ -51,10 +55,16 @@ public CreateApiKeyRequest() { * @param expiration to specify expiration for the API key */ public CreateApiKeyRequest(String name, @Nullable List roleDescriptors, @Nullable TimeValue expiration) { + this(name, roleDescriptors, expiration, null); + } + + public CreateApiKeyRequest(String name, @Nullable List roleDescriptors, @Nullable TimeValue expiration, + @Nullable Map metadata) { this(); this.name = name; this.roleDescriptors = (roleDescriptors == null) ? List.of() : List.copyOf(roleDescriptors); this.expiration = expiration; + this.metadata = metadata; } public CreateApiKeyRequest(StreamInput in) throws IOException { @@ -72,6 +82,11 @@ public CreateApiKeyRequest(StreamInput in) throws IOException { this.expiration = in.readOptionalTimeValue(); this.roleDescriptors = List.copyOf(in.readList(RoleDescriptor::new)); this.refreshPolicy = WriteRequest.RefreshPolicy.readFrom(in); + if (in.getVersion().onOrAfter(Version.V_8_0_0)) { + this.metadata = in.readMap(); + } else { + this.metadata = null; + } } public String getId() { @@ -114,6 +129,14 @@ public void setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) { this.refreshPolicy = Objects.requireNonNull(refreshPolicy, "refresh policy may not be null"); } + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + @Override public ActionRequestValidationException validate() { ActionRequestValidationException validationException = null; @@ -130,6 +153,10 @@ public ActionRequestValidationException validate() { validationException = addValidationError("api key name may not begin with an underscore", validationException); } } + if (metadata != null && MetadataUtils.containsReservedMetadata(metadata)) { + validationException = + addValidationError("metadata keys may not start with [" + MetadataUtils.RESERVED_PREFIX + "]", validationException); + } return validationException; } @@ -147,5 +174,8 @@ public void writeTo(StreamOutput out) throws IOException { out.writeOptionalTimeValue(expiration); out.writeList(roleDescriptors); refreshPolicy.writeTo(out); + if (out.getVersion().onOrAfter(Version.V_8_0_0)) { + out.writeMap(metadata); + } } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java index 5772f2e3516c8..38e1b086fc4f4 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java @@ -22,6 +22,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.List; +import java.util.Map; import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg; @@ -35,7 +36,8 @@ public final class CreateApiKeyRequestBuilder extends ActionRequestBuilder PARSER = new ConstructingObjectParser<>( "api_key_request", false, (args, v) -> { return new CreateApiKeyRequest((String) args[0], (List) args[1], - TimeValue.parseTimeValue((String) args[2], null, "expiration")); + TimeValue.parseTimeValue((String) args[2], null, "expiration"), + (Map) args[3]); }); static { @@ -45,6 +47,7 @@ public final class CreateApiKeyRequestBuilder extends ActionRequestBuilder p.map(), new ParseField("metadata")); } public CreateApiKeyRequestBuilder(ElasticsearchClient client) { @@ -71,6 +74,11 @@ public CreateApiKeyRequestBuilder setRefreshPolicy(WriteRequest.RefreshPolicy re return this; } + public CreateApiKeyRequestBuilder setMetadata(Map metadata) { + request.setMetadata(metadata); + return this; + } + public CreateApiKeyRequestBuilder source(BytesReference source, XContentType xContentType) throws IOException { final NamedXContentRegistry registry = NamedXContentRegistry.EMPTY; try (InputStream stream = source.streamInput(); @@ -79,6 +87,8 @@ public CreateApiKeyRequestBuilder source(BytesReference source, XContentType xCo setName(createApiKeyRequest.getName()); setRoleDescriptors(createApiKeyRequest.getRoleDescriptors()); setExpiration(createApiKeyRequest.getExpiration()); + setMetadata(createApiKeyRequest.getMetadata()); + } return this; } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/ApiKeyTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/ApiKeyTests.java new file mode 100644 index 0000000000000..46f899049e057 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/ApiKeyTests.java @@ -0,0 +1,75 @@ +/* + * 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.core.security.action; + +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +public class ApiKeyTests extends ESTestCase { + + public void testXContent() throws IOException { + final String name = randomAlphaOfLengthBetween(4, 10); + final String id = randomAlphaOfLength(20); + // between 1970 and 2065 + final Instant creation = Instant.ofEpochSecond(randomLongBetween(0, 3000000000L), randomLongBetween(0, 999999999)); + final Instant expiration = randomBoolean() ? null + : Instant.ofEpochSecond(randomLongBetween(0, 3000000000L), randomLongBetween(0, 999999999)); + final boolean invalidated = randomBoolean(); + final String username = randomAlphaOfLengthBetween(4, 10); + final String realmName = randomAlphaOfLengthBetween(3, 8); + final Map metadata = randomMetadata(); + + final ApiKey apiKey = new ApiKey(name, id, creation, expiration, invalidated, username, realmName, metadata); + // The metadata will never be null because the constructor convert it to empty map if a null is passed in + assertThat(apiKey.getMetadata(), notNullValue()); + + XContentBuilder builder = XContentFactory.jsonBuilder(); + apiKey.toXContent(builder, ToXContent.EMPTY_PARAMS); + final Map map = XContentHelper.convertToMap(BytesReference.bytes(builder), false, builder.contentType()).v2(); + + assertThat(map.get("name"), equalTo(name)); + assertThat(map.get("id"), equalTo(id)); + assertThat(map.get("creation"), equalTo(creation.toEpochMilli())); + if (expiration != null) { + assertThat(map.get("expiration"), equalTo(expiration.toEpochMilli())); + } else { + assertThat(map.containsKey("expiration"), is(false)); + } + assertThat(map.get("invalidated"), is(invalidated)); + assertThat(map.get("username"), equalTo(username)); + assertThat(map.get("realm"), equalTo(realmName)); + assertThat(map.get("metadata"), equalTo(Objects.requireNonNullElseGet(metadata, Map::of))); + } + + @SuppressWarnings("unchecked") + public static Map randomMetadata() { + return randomFrom( + Map.of("application", randomAlphaOfLength(5), + "number", 1, + "numbers", List.of(1, 3, 5), + "environment", Map.of("os", "linux", "level", 42, "category", "trusted") + ), + Map.of(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)), + Map.of(), + null); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestTests.java index 98d51545b8a0e..6e8d5a3d46b2b 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestTests.java @@ -19,8 +19,10 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; public class CreateApiKeyRequestTests extends ESTestCase { @@ -72,6 +74,17 @@ public void testNameValidation() { assertThat(ve.validationErrors().get(0), containsString("api key name may not begin with an underscore")); } + public void testMetadataKeyValidation() { + final String name = randomAlphaOfLengthBetween(1, 256); + CreateApiKeyRequest request = new CreateApiKeyRequest(); + request.setName(name); + request.setMetadata(Map.of("_foo", "bar")); + final ActionRequestValidationException ve = request.validate(); + assertNotNull(ve); + assertThat(ve.validationErrors().size(), equalTo(1)); + assertThat(ve.validationErrors().get(0), containsString("metadata keys may not start with [_]")); + } + public void testSerialization() throws IOException { final String name = randomAlphaOfLengthBetween(1, 256); final TimeValue expiration = randomBoolean() ? null : diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponseTests.java index aede5d8844c67..be41ad28cf183 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponseTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/GetApiKeyResponseTests.java @@ -19,6 +19,7 @@ import java.time.Instant; import java.util.Arrays; import java.util.Collections; +import java.util.Map; import static org.hamcrest.Matchers.equalTo; @@ -28,7 +29,8 @@ public void testSerialization() throws IOException { boolean withApiKeyName = randomBoolean(); boolean withExpiration = randomBoolean(); ApiKey apiKeyInfo = createApiKeyInfo((withApiKeyName) ? randomAlphaOfLength(4) : null, randomAlphaOfLength(5), Instant.now(), - (withExpiration) ? Instant.now() : null, false, randomAlphaOfLength(4), randomAlphaOfLength(5)); + (withExpiration) ? Instant.now() : null, false, randomAlphaOfLength(4), randomAlphaOfLength(5), + randomBoolean() ? null : Map.of(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8))); GetApiKeyResponse response = new GetApiKeyResponse(Collections.singletonList(apiKeyInfo)); try (BytesStreamOutput output = new BytesStreamOutput()) { response.writeTo(output); @@ -41,11 +43,11 @@ public void testSerialization() throws IOException { public void testToXContent() throws IOException { ApiKey apiKeyInfo1 = createApiKeyInfo("name1", "id-1", Instant.ofEpochMilli(100000L), Instant.ofEpochMilli(10000000L), false, - "user-a", "realm-x"); + "user-a", "realm-x", null); ApiKey apiKeyInfo2 = createApiKeyInfo("name2", "id-2", Instant.ofEpochMilli(100000L), Instant.ofEpochMilli(10000000L), true, - "user-b", "realm-y"); + "user-b", "realm-y", Map.of()); ApiKey apiKeyInfo3 = createApiKeyInfo(null, "id-3", Instant.ofEpochMilli(100000L), null, true, - "user-c", "realm-z"); + "user-c", "realm-z", Map.of("foo", "bar")); GetApiKeyResponse response = new GetApiKeyResponse(Arrays.asList(apiKeyInfo1, apiKeyInfo2, apiKeyInfo3)); XContentBuilder builder = XContentFactory.jsonBuilder(); response.toXContent(builder, ToXContent.EMPTY_PARAMS); @@ -53,18 +55,18 @@ public void testToXContent() throws IOException { "{" + "\"api_keys\":[" + "{\"id\":\"id-1\",\"name\":\"name1\",\"creation\":100000,\"expiration\":10000000,\"invalidated\":false," - + "\"username\":\"user-a\",\"realm\":\"realm-x\"}," + + "\"username\":\"user-a\",\"realm\":\"realm-x\",\"metadata\":{}}," + "{\"id\":\"id-2\",\"name\":\"name2\",\"creation\":100000,\"expiration\":10000000,\"invalidated\":true," - + "\"username\":\"user-b\",\"realm\":\"realm-y\"}," + + "\"username\":\"user-b\",\"realm\":\"realm-y\",\"metadata\":{}}," + "{\"id\":\"id-3\",\"name\":null,\"creation\":100000,\"invalidated\":true," - + "\"username\":\"user-c\",\"realm\":\"realm-z\"}" + + "\"username\":\"user-c\",\"realm\":\"realm-z\",\"metadata\":{\"foo\":\"bar\"}}" + "]" + "}")); } private ApiKey createApiKeyInfo(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, - String realm) { - return new ApiKey(name, id, creation, expiration, invalidated, username, realm); + String realm, Map metadata) { + return new ApiKey(name, id, creation, expiration, invalidated, username, realm, metadata); } } diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java index 8ae8f1aafb101..190e7441a18c5 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java @@ -40,6 +40,7 @@ import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.action.ApiKey; +import org.elasticsearch.xpack.core.security.action.ApiKeyTests; import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction; import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheRequest; import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheResponse; @@ -67,6 +68,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -75,7 +77,9 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import java.util.stream.Collectors; +import java.util.stream.IntStream; import java.util.stream.Stream; import static org.elasticsearch.test.SecuritySettingsSource.TEST_SUPERUSER; @@ -173,6 +177,7 @@ public void testCreateApiKey() throws Exception { .setName("test key") .setExpiration(TimeValue.timeValueHours(TimeUnit.DAYS.toHours(7L))) .setRoleDescriptors(Collections.singletonList(descriptor)) + .setMetadata(ApiKeyTests.randomMetadata()) .get(); assertEquals("test key", response.getName()); @@ -221,7 +226,8 @@ public void testMultipleApiKeysCanHaveSameName() { Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); final CreateApiKeyResponse response = new CreateApiKeyRequestBuilder(client).setName(keyName).setExpiration(null) - .setRoleDescriptors(Collections.singletonList(descriptor)).get(); + .setRoleDescriptors(Collections.singletonList(descriptor)) + .setMetadata(ApiKeyTests.randomMetadata()).get(); assertNotNull(response.getId()); assertNotNull(response.getKey()); responses.add(response); @@ -242,7 +248,7 @@ public void testCreateApiKeyWithoutNameWillFail() { public void testInvalidateApiKeysForRealm() throws InterruptedException, ExecutionException { int noOfApiKeys = randomIntBetween(3, 5); - List responses = createApiKeys(noOfApiKeys, null); + List responses = createApiKeys(noOfApiKeys, null).v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); PlainActionFuture listener = new PlainActionFuture<>(); @@ -253,7 +259,7 @@ public void testInvalidateApiKeysForRealm() throws InterruptedException, Executi public void testInvalidateApiKeysForUser() throws Exception { int noOfApiKeys = randomIntBetween(3, 5); - List responses = createApiKeys(noOfApiKeys, null); + List responses = createApiKeys(noOfApiKeys, null).v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); PlainActionFuture listener = new PlainActionFuture<>(); @@ -264,7 +270,7 @@ public void testInvalidateApiKeysForUser() throws Exception { } public void testInvalidateApiKeysForRealmAndUser() throws InterruptedException, ExecutionException { - List responses = createApiKeys(1, null); + List responses = createApiKeys(1, null).v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); PlainActionFuture listener = new PlainActionFuture<>(); @@ -275,7 +281,7 @@ public void testInvalidateApiKeysForRealmAndUser() throws InterruptedException, } public void testInvalidateApiKeysForApiKeyId() throws InterruptedException, ExecutionException { - List responses = createApiKeys(1, null); + List responses = createApiKeys(1, null).v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); PlainActionFuture listener = new PlainActionFuture<>(); @@ -285,7 +291,7 @@ public void testInvalidateApiKeysForApiKeyId() throws InterruptedException, Exec } public void testInvalidateApiKeysForApiKeyName() throws InterruptedException, ExecutionException { - List responses = createApiKeys(1, null); + List responses = createApiKeys(1, null).v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); PlainActionFuture listener = new PlainActionFuture<>(); @@ -360,7 +366,7 @@ public void testInvalidatedApiKeysDeletedByRemover() throws Exception { Client client = waitForExpiredApiKeysRemoverTriggerReadyAndGetClient().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); - List createdApiKeys = createApiKeys(2, null); + List createdApiKeys = createApiKeys(2, null).v1(); PlainActionFuture listener = new PlainActionFuture<>(); client.execute(InvalidateApiKeyAction.INSTANCE, InvalidateApiKeyRequest.usingApiKeyId(createdApiKeys.get(0).getId(), false), @@ -446,7 +452,7 @@ public void testExpiredApiKeysBehaviorWhenKeysExpired1WeekBeforeAnd1DayBefore() Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); int noOfKeys = 4; - List createdApiKeys = createApiKeys(noOfKeys, null); + List createdApiKeys = createApiKeys(noOfKeys, null).v1(); Instant created = Instant.now(); PlainActionFuture getApiKeyResponseListener = new PlainActionFuture<>(); @@ -522,7 +528,8 @@ private void refreshSecurityIndex() throws Exception { } public void testActiveApiKeysWithNoExpirationNeverGetDeletedByRemover() throws Exception { - List responses = createApiKeys(2, null); + final Tuple, List>> tuple = createApiKeys(2, null); + List responses = tuple.v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); @@ -537,13 +544,14 @@ public void testActiveApiKeysWithNoExpirationNeverGetDeletedByRemover() throws E PlainActionFuture getApiKeyResponseListener = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingRealmName("file"), getApiKeyResponseListener); GetApiKeyResponse response = getApiKeyResponseListener.get(); - verifyGetResponse(2, responses, response, Collections.singleton(responses.get(0).getId()), + verifyGetResponse(2, responses, tuple.v2(), response, Collections.singleton(responses.get(0).getId()), Collections.singletonList(responses.get(1).getId())); } public void testGetApiKeysForRealm() throws InterruptedException, ExecutionException { int noOfApiKeys = randomIntBetween(3, 5); - List responses = createApiKeys(noOfApiKeys, null); + final Tuple, List>> tuple = createApiKeys(noOfApiKeys, null); + List responses = tuple.v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); boolean invalidate = randomBoolean(); @@ -565,41 +573,45 @@ public void testGetApiKeysForRealm() throws InterruptedException, ExecutionExcep PlainActionFuture listener = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingRealmName("file"), listener); GetApiKeyResponse response = listener.get(); - verifyGetResponse(noOfApiKeys, responses, response, + verifyGetResponse(noOfApiKeys, responses, tuple.v2(), response, expectedValidKeyIds, invalidatedApiKeyIds); } public void testGetApiKeysForUser() throws Exception { int noOfApiKeys = randomIntBetween(3, 5); - List responses = createApiKeys(noOfApiKeys, null); + final Tuple, List>> tuple = createApiKeys(noOfApiKeys, null); + List responses = tuple.v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); PlainActionFuture listener = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingUserName(TEST_SUPERUSER), listener); GetApiKeyResponse response = listener.get(); - verifyGetResponse(noOfApiKeys, responses, response, responses.stream().map(o -> o.getId()).collect(Collectors.toSet()), null); + verifyGetResponse(noOfApiKeys, responses, tuple.v2(), + response, responses.stream().map(o -> o.getId()).collect(Collectors.toSet()), null); } public void testGetApiKeysForRealmAndUser() throws InterruptedException, ExecutionException { - List responses = createApiKeys(1, null); + final Tuple, List>> tuple = createApiKeys(1, null); + List responses = tuple.v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); PlainActionFuture listener = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingRealmAndUserName("file", TEST_SUPERUSER), listener); GetApiKeyResponse response = listener.get(); - verifyGetResponse(1, responses, response, Collections.singleton(responses.get(0).getId()), null); + verifyGetResponse(1, responses, tuple.v2(), response, Collections.singleton(responses.get(0).getId()), null); } public void testGetApiKeysForApiKeyId() throws InterruptedException, ExecutionException { - List responses = createApiKeys(1, null); + final Tuple, List>> tuple = createApiKeys(1, null); + List responses = tuple.v1(); Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING))); PlainActionFuture listener = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyId(responses.get(0).getId(), false), listener); GetApiKeyResponse response = listener.get(); - verifyGetResponse(1, responses, response, Collections.singleton(responses.get(0).getId()), null); + verifyGetResponse(1, responses, tuple.v2(), response, Collections.singleton(responses.get(0).getId()), null); } public void testGetApiKeysForApiKeyName() throws InterruptedException, ExecutionException { @@ -608,52 +620,57 @@ public void testGetApiKeysForApiKeyName() throws InterruptedException, Execution basicAuthHeaderValue(TEST_SUPERUSER, TEST_PASSWORD_SECURE_STRING)); final int noOfApiKeys = randomIntBetween(1, 3); - final List createApiKeyResponses1 = createApiKeys(noOfApiKeys, null); - final List createApiKeyResponses2 = createApiKeys( - headers, noOfApiKeys, "another-test-key-", null, "monitor"); + final Tuple, List>> tuple1 = createApiKeys(noOfApiKeys, null); + final List createApiKeyResponses1 = tuple1.v1(); + final Tuple, List>> tuple2 = + createApiKeys(headers, noOfApiKeys, "another-test-key-", null, "monitor"); + final List createApiKeyResponses2 = tuple2.v1(); Client client = client().filterWithHeader(headers); PlainActionFuture listener = new PlainActionFuture<>(); @SuppressWarnings("unchecked") List responses = randomFrom(createApiKeyResponses1, createApiKeyResponses2); + List> metadatas = responses == createApiKeyResponses1 ? tuple1.v2() : tuple2.v2(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyName(responses.get(0).getName(), false), listener); - verifyGetResponse(1, responses, listener.get(), Collections.singleton(responses.get(0).getId()), null); + verifyGetResponse(1, responses, metadatas, listener.get(), Collections.singleton(responses.get(0).getId()), null); PlainActionFuture listener2 = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyName("test-key*", false), listener2); - verifyGetResponse(noOfApiKeys, createApiKeyResponses1, listener2.get(), + verifyGetResponse(noOfApiKeys, createApiKeyResponses1, tuple1.v2(), listener2.get(), createApiKeyResponses1.stream().map(CreateApiKeyResponse::getId).collect(Collectors.toSet()), null); PlainActionFuture listener3 = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyName("*", false), listener3); responses = Stream.concat(createApiKeyResponses1.stream(), createApiKeyResponses2.stream()).collect(Collectors.toList()); - verifyGetResponse(2 * noOfApiKeys, responses, listener3.get(), + metadatas = Stream.concat(tuple1.v2().stream(), tuple2.v2().stream()).collect(Collectors.toList()); + verifyGetResponse(2 * noOfApiKeys, responses, metadatas, listener3.get(), responses.stream().map(CreateApiKeyResponse::getId).collect(Collectors.toSet()), null); PlainActionFuture listener4 = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyName("does-not-exist*", false), listener4); - verifyGetResponse(0, Collections.emptyList(), listener4.get(), Collections.emptySet(), null); + verifyGetResponse(0, Collections.emptyList(), null, listener4.get(), Collections.emptySet(), null); PlainActionFuture listener5 = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyName("another-test-key*", false), listener5); - verifyGetResponse(noOfApiKeys, createApiKeyResponses2, listener5.get(), + verifyGetResponse(noOfApiKeys, createApiKeyResponses2, tuple2.v2(), listener5.get(), createApiKeyResponses2.stream().map(CreateApiKeyResponse::getId).collect(Collectors.toSet()), null); } public void testGetApiKeysOwnedByCurrentAuthenticatedUser() throws InterruptedException, ExecutionException { int noOfSuperuserApiKeys = randomIntBetween(3, 5); int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); - List defaultUserCreatedKeys = createApiKeys(noOfSuperuserApiKeys, null); + List defaultUserCreatedKeys = createApiKeys(noOfSuperuserApiKeys, null).v1(); String userWithManageApiKeyRole = randomFrom("user_with_manage_api_key_role", "user_with_manage_own_api_key_role"); - List userWithManageApiKeyRoleApiKeys = createApiKeys(userWithManageApiKeyRole, - noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + final Tuple, List>> tuple = + createApiKeys(userWithManageApiKeyRole, noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + List userWithManageApiKeyRoleApiKeys = tuple.v1(); final Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(userWithManageApiKeyRole, TEST_PASSWORD_SECURE_STRING))); PlainActionFuture listener = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.forOwnedApiKeys(), listener); GetApiKeyResponse response = listener.get(); - verifyGetResponse(userWithManageApiKeyRole, noOfApiKeysForUserWithManageApiKeyRole, userWithManageApiKeyRoleApiKeys, + verifyGetResponse(userWithManageApiKeyRole, noOfApiKeysForUserWithManageApiKeyRole, userWithManageApiKeyRoleApiKeys, tuple.v2(), response, userWithManageApiKeyRoleApiKeys.stream().map(o -> o.getId()).collect(Collectors.toSet()), null); } @@ -662,12 +679,17 @@ public void testGetApiKeysOwnedByRunAsUserWhenOwnerIsTrue() throws ExecutionExce int noOfSuperuserApiKeys = randomIntBetween(3, 5); int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); createApiKeys(noOfSuperuserApiKeys, null); - List userWithManageOwnApiKeyRoleApiKeys = createApiKeys("user_with_manage_own_api_key_role", - "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + final Tuple, List>> tuple = createApiKeys("user_with_manage_own_api_key_role", + "user_with_run_as_role", + noOfApiKeysForUserWithManageApiKeyRole, + null, + "monitor"); + List userWithManageOwnApiKeyRoleApiKeys = tuple.v1(); PlainActionFuture listener = new PlainActionFuture<>(); getClientForRunAsUser().execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.forOwnedApiKeys(), listener); GetApiKeyResponse response = listener.get(); - verifyGetResponse("user_with_manage_own_api_key_role", noOfApiKeysForUserWithManageApiKeyRole, userWithManageOwnApiKeyRoleApiKeys, + verifyGetResponse("user_with_manage_own_api_key_role", noOfApiKeysForUserWithManageApiKeyRole, + userWithManageOwnApiKeyRoleApiKeys, tuple.v2(), response, userWithManageOwnApiKeyRoleApiKeys.stream().map(o -> o.getId()).collect(Collectors.toSet()), null); } @@ -676,13 +698,18 @@ public void testGetApiKeysOwnedByRunAsUserWhenRunAsUserInfoIsGiven() throws Exec int noOfSuperuserApiKeys = randomIntBetween(3, 5); int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); createApiKeys(noOfSuperuserApiKeys, null); - List userWithManageOwnApiKeyRoleApiKeys = createApiKeys("user_with_manage_own_api_key_role", - "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + final Tuple, List>> tuple = createApiKeys("user_with_manage_own_api_key_role", + "user_with_run_as_role", + noOfApiKeysForUserWithManageApiKeyRole, + null, + "monitor"); + List userWithManageOwnApiKeyRoleApiKeys = tuple.v1(); PlainActionFuture listener = new PlainActionFuture<>(); getClientForRunAsUser().execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingRealmAndUserName("file", "user_with_manage_own_api_key_role"), listener); GetApiKeyResponse response = listener.get(); - verifyGetResponse("user_with_manage_own_api_key_role", noOfApiKeysForUserWithManageApiKeyRole, userWithManageOwnApiKeyRoleApiKeys, + verifyGetResponse("user_with_manage_own_api_key_role", noOfApiKeysForUserWithManageApiKeyRole, + userWithManageOwnApiKeyRoleApiKeys, tuple.v2(), response, userWithManageOwnApiKeyRoleApiKeys.stream().map(o -> o.getId()).collect(Collectors.toSet()), null); } @@ -692,7 +719,7 @@ public void testGetApiKeysOwnedByRunAsUserWillNotWorkWhenAuthUserInfoIsGiven() t int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); createApiKeys(noOfSuperuserApiKeys, null); final List userWithManageOwnApiKeyRoleApiKeys = createApiKeys("user_with_manage_own_api_key_role", - "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor").v1(); PlainActionFuture listener = new PlainActionFuture<>(); @SuppressWarnings("unchecked") final Tuple invalidRealmAndUserPair = randomFrom( @@ -710,11 +737,14 @@ public void testGetAllApiKeys() throws InterruptedException, ExecutionException int noOfSuperuserApiKeys = randomIntBetween(3, 5); int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); int noOfApiKeysForUserWithManageOwnApiKeyRole = randomIntBetween(3, 7); - List defaultUserCreatedKeys = createApiKeys(noOfSuperuserApiKeys, null); - List userWithManageApiKeyRoleApiKeys = createApiKeys("user_with_manage_api_key_role", - noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); - List userWithManageOwnApiKeyRoleApiKeys = createApiKeys("user_with_manage_own_api_key_role", - noOfApiKeysForUserWithManageOwnApiKeyRole, null, "monitor"); + final Tuple, List>> defaultUserTuple = createApiKeys(noOfSuperuserApiKeys, null); + List defaultUserCreatedKeys = defaultUserTuple.v1(); + final Tuple, List>> userWithManageTuple = + createApiKeys("user_with_manage_api_key_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + List userWithManageApiKeyRoleApiKeys = userWithManageTuple.v1(); + final Tuple, List>> userWithManageOwnTuple = + createApiKeys("user_with_manage_own_api_key_role", noOfApiKeysForUserWithManageOwnApiKeyRole, null, "monitor"); + List userWithManageOwnApiKeyRoleApiKeys = userWithManageOwnTuple.v1(); final Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue("user_with_manage_api_key_role", TEST_PASSWORD_SECURE_STRING))); @@ -725,8 +755,10 @@ public void testGetAllApiKeys() throws InterruptedException, ExecutionException List allApiKeys = new ArrayList<>(); Stream.of(defaultUserCreatedKeys, userWithManageApiKeyRoleApiKeys, userWithManageOwnApiKeyRoleApiKeys).forEach( allApiKeys::addAll); + final List> metadatas = Stream.of(defaultUserTuple.v2(), userWithManageTuple.v2(), userWithManageOwnTuple.v2()) + .flatMap(List::stream).collect(Collectors.toList()); verifyGetResponse(new String[] {TEST_SUPERUSER, "user_with_manage_api_key_role", - "user_with_manage_own_api_key_role" }, totalApiKeys, allApiKeys, response, + "user_with_manage_own_api_key_role" }, totalApiKeys, allApiKeys, metadatas, response, allApiKeys.stream().map(o -> o.getId()).collect(Collectors.toSet()), null); } @@ -734,11 +766,11 @@ public void testGetAllApiKeysFailsForUserWithNoRoleOrRetrieveOwnApiKeyRole() thr int noOfSuperuserApiKeys = randomIntBetween(3, 5); int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); int noOfApiKeysForUserWithManageOwnApiKeyRole = randomIntBetween(3, 7); - List defaultUserCreatedKeys = createApiKeys(noOfSuperuserApiKeys, null); + List defaultUserCreatedKeys = createApiKeys(noOfSuperuserApiKeys, null).v1(); List userWithManageApiKeyRoleApiKeys = createApiKeys("user_with_manage_api_key_role", - noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + noOfApiKeysForUserWithManageApiKeyRole, null, "monitor").v1(); List userWithManageOwnApiKeyRoleApiKeys = createApiKeys("user_with_manage_own_api_key_role", - noOfApiKeysForUserWithManageOwnApiKeyRole, null, "monitor"); + noOfApiKeysForUserWithManageOwnApiKeyRole, null, "monitor").v1(); final String withUser = randomFrom("user_with_manage_own_api_key_role", "user_with_no_api_key_role"); final Client client = client().filterWithHeader( @@ -752,10 +784,10 @@ public void testGetAllApiKeysFailsForUserWithNoRoleOrRetrieveOwnApiKeyRole() thr public void testInvalidateApiKeysOwnedByCurrentAuthenticatedUser() throws InterruptedException, ExecutionException { int noOfSuperuserApiKeys = randomIntBetween(3, 5); int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); - List defaultUserCreatedKeys = createApiKeys(noOfSuperuserApiKeys, null); + List defaultUserCreatedKeys = createApiKeys(noOfSuperuserApiKeys, null).v1(); String userWithManageApiKeyRole = randomFrom("user_with_manage_api_key_role", "user_with_manage_own_api_key_role"); List userWithManageApiKeyRoleApiKeys = createApiKeys(userWithManageApiKeyRole, - noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + noOfApiKeysForUserWithManageApiKeyRole, null, "monitor").v1(); final Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(userWithManageApiKeyRole, TEST_PASSWORD_SECURE_STRING))); @@ -772,7 +804,7 @@ public void testInvalidateApiKeysOwnedByRunAsUserWhenOwnerIsTrue() throws Interr int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); createApiKeys(noOfSuperuserApiKeys, null); List userWithManageApiKeyRoleApiKeys = createApiKeys("user_with_manage_own_api_key_role", - "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor").v1(); PlainActionFuture listener = new PlainActionFuture<>(); getClientForRunAsUser().execute(InvalidateApiKeyAction.INSTANCE, InvalidateApiKeyRequest.forOwnedApiKeys(), listener); InvalidateApiKeyResponse invalidateResponse = listener.get(); @@ -785,7 +817,7 @@ public void testInvalidateApiKeysOwnedByRunAsUserWhenRunAsUserInfoIsGiven() thro int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); createApiKeys(noOfSuperuserApiKeys, null); List userWithManageApiKeyRoleApiKeys = createApiKeys("user_with_manage_own_api_key_role", - "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor").v1(); PlainActionFuture listener = new PlainActionFuture<>(); getClientForRunAsUser().execute(InvalidateApiKeyAction.INSTANCE, InvalidateApiKeyRequest.usingRealmAndUserName("file", "user_with_manage_own_api_key_role"), listener); @@ -799,7 +831,7 @@ public void testInvalidateApiKeysOwnedByRunAsUserWillNotWorkWhenAuthUserInfoIsGi int noOfApiKeysForUserWithManageApiKeyRole = randomIntBetween(3, 5); createApiKeys(noOfSuperuserApiKeys, null); List userWithManageApiKeyRoleApiKeys = createApiKeys("user_with_manage_own_api_key_role", - "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor"); + "user_with_run_as_role", noOfApiKeysForUserWithManageApiKeyRole, null, "monitor").v1(); PlainActionFuture listener = new PlainActionFuture<>(); @SuppressWarnings("unchecked") final Tuple invalidRealmAndUserPair = randomFrom( @@ -815,14 +847,16 @@ public void testInvalidateApiKeysOwnedByRunAsUserWillNotWorkWhenAuthUserInfoIsGi public void testApiKeyAuthorizationApiKeyMustBeAbleToRetrieveItsOwnInformationButNotAnyOtherKeysCreatedBySameOwner() throws InterruptedException, ExecutionException { - List responses = createApiKeys(TEST_SUPERUSER, 2, null, (String[]) null); + final Tuple, List>> tuple = + createApiKeys(TEST_SUPERUSER, 2, null, (String[]) null); + List responses = tuple.v1(); final String base64ApiKeyKeyValue = Base64.getEncoder().encodeToString( (responses.get(0).getId() + ":" + responses.get(0).getKey().toString()).getBytes(StandardCharsets.UTF_8)); Client client = client().filterWithHeader(Map.of("Authorization", "ApiKey " + base64ApiKeyKeyValue)); PlainActionFuture listener = new PlainActionFuture<>(); client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyId(responses.get(0).getId(), randomBoolean()), listener); GetApiKeyResponse response = listener.get(); - verifyGetResponse(1, responses, response, Collections.singleton(responses.get(0).getId()), null); + verifyGetResponse(1, responses, tuple.v2(), response, Collections.singleton(responses.get(0).getId()), null); final PlainActionFuture failureListener = new PlainActionFuture<>(); // for any other API key id, it must deny access @@ -840,7 +874,7 @@ public void testApiKeyAuthorizationApiKeyMustBeAbleToRetrieveItsOwnInformationBu public void testApiKeyWithManageOwnPrivilegeIsAbleToInvalidateItselfButNotAnyOtherKeysCreatedBySameOwner() throws InterruptedException, ExecutionException { - List responses = createApiKeys(TEST_SUPERUSER, 2, null, "manage_own_api_key"); + List responses = createApiKeys(TEST_SUPERUSER, 2, null, "manage_own_api_key").v1(); final String base64ApiKeyKeyValue = Base64.getEncoder().encodeToString( (responses.get(0).getId() + ":" + responses.get(0).getKey().toString()).getBytes(StandardCharsets.UTF_8)); Client client = client().filterWithHeader(Map.of("Authorization", "ApiKey " + base64ApiKeyKeyValue)); @@ -877,6 +911,7 @@ public void testDerivedKeys() throws ExecutionException, InterruptedException { .setName("key-1") .setRoleDescriptors(Collections.singletonList( new RoleDescriptor("role", new String[] { "manage_api_key" }, null, null))) + .setMetadata(ApiKeyTests.randomMetadata()) .get(); assertEquals("key-1", response.getName()); @@ -891,7 +926,7 @@ public void testDerivedKeys() throws ExecutionException, InterruptedException { final String expectedMessage = "creating derived api keys requires an explicit role descriptor that is empty"; final IllegalArgumentException e1 = expectThrows(IllegalArgumentException.class, - () -> new CreateApiKeyRequestBuilder(clientKey1).setName("key-2").get()); + () -> new CreateApiKeyRequestBuilder(clientKey1).setName("key-2").setMetadata(ApiKeyTests.randomMetadata()).get()); assertThat(e1.getMessage(), containsString(expectedMessage)); final IllegalArgumentException e2 = expectThrows(IllegalArgumentException.class, @@ -901,6 +936,7 @@ public void testDerivedKeys() throws ExecutionException, InterruptedException { final IllegalArgumentException e3 = expectThrows(IllegalArgumentException.class, () -> new CreateApiKeyRequestBuilder(clientKey1).setName("key-4") + .setMetadata(ApiKeyTests.randomMetadata()) .setRoleDescriptors(Collections.singletonList( new RoleDescriptor("role", new String[] { "manage_own_api_key" }, null, null) )).get()); @@ -913,10 +949,12 @@ public void testDerivedKeys() throws ExecutionException, InterruptedException { final IllegalArgumentException e4 = expectThrows(IllegalArgumentException.class, () -> new CreateApiKeyRequestBuilder(clientKey1).setName("key-5") + .setMetadata(ApiKeyTests.randomMetadata()) .setRoleDescriptors(roleDescriptors).get()); assertThat(e4.getMessage(), containsString(expectedMessage)); final CreateApiKeyResponse key100Response = new CreateApiKeyRequestBuilder(clientKey1).setName("key-100") + .setMetadata(ApiKeyTests.randomMetadata()) .setRoleDescriptors(Collections.singletonList( new RoleDescriptor("role", null, null, null) )).get(); @@ -944,6 +982,7 @@ public void testAuthenticationReturns429WhenThreadPoolIsSaturated() throws IOExc final CreateApiKeyResponse createApiKeyResponse = new CreateApiKeyRequestBuilder(client) .setName("auth only key") .setRoleDescriptors(Collections.singletonList(descriptor)) + .setMetadata(ApiKeyTests.randomMetadata()) .get(); assertNotNull(createApiKeyResponse.getId()); @@ -1098,6 +1137,7 @@ private Tuple createApiKeyAndAuthenticateWithIt() throws IOExcep final CreateApiKeyResponse createApiKeyResponse = new CreateApiKeyRequestBuilder(client) .setName("test key") + .setMetadata(ApiKeyTests.randomMetadata()) .get(); final String docId = createApiKeyResponse.getId(); final String base64ApiKeyKeyValue = Base64.getEncoder().encodeToString( @@ -1116,79 +1156,98 @@ private void assertApiKeyNotCreated(Client client, String keyName) throws Execut } private void verifyGetResponse(int expectedNumberOfApiKeys, List responses, + List> metadatas, GetApiKeyResponse response, Set validApiKeyIds, List invalidatedApiKeyIds) { - verifyGetResponse(TEST_SUPERUSER, expectedNumberOfApiKeys, responses, response, validApiKeyIds, + verifyGetResponse(TEST_SUPERUSER, expectedNumberOfApiKeys, responses, metadatas, response, validApiKeyIds, invalidatedApiKeyIds); } private void verifyGetResponse(String user, int expectedNumberOfApiKeys, List responses, + List> metadatas, GetApiKeyResponse response, Set validApiKeyIds, List invalidatedApiKeyIds) { - verifyGetResponse(new String[]{user}, expectedNumberOfApiKeys, responses, response, validApiKeyIds, invalidatedApiKeyIds); + verifyGetResponse( + new String[]{user}, expectedNumberOfApiKeys, responses, metadatas, response, validApiKeyIds, invalidatedApiKeyIds); } private void verifyGetResponse(String[] user, int expectedNumberOfApiKeys, List responses, + List> metadatas, GetApiKeyResponse response, Set validApiKeyIds, List invalidatedApiKeyIds) { assertThat(response.getApiKeyInfos().length, equalTo(expectedNumberOfApiKeys)); List expectedIds = responses.stream().filter(o -> validApiKeyIds.contains(o.getId())).map(o -> o.getId()) - .collect(Collectors.toList()); + .collect(Collectors.toList()); List actualIds = Arrays.stream(response.getApiKeyInfos()).filter(o -> o.isInvalidated() == false).map(o -> o.getId()) - .collect(Collectors.toList()); + .collect(Collectors.toList()); assertThat(actualIds, containsInAnyOrder(expectedIds.toArray(Strings.EMPTY_ARRAY))); List expectedNames = responses.stream().filter(o -> validApiKeyIds.contains(o.getId())).map(o -> o.getName()) - .collect(Collectors.toList()); + .collect(Collectors.toList()); List actualNames = Arrays.stream(response.getApiKeyInfos()).filter(o -> o.isInvalidated() == false).map(o -> o.getName()) - .collect(Collectors.toList()); + .collect(Collectors.toList()); assertThat(actualNames, containsInAnyOrder(expectedNames.toArray(Strings.EMPTY_ARRAY))); Set expectedUsernames = (validApiKeyIds.isEmpty()) ? Collections.emptySet() - : Set.of(user); + : Set.of(user); Set actualUsernames = Arrays.stream(response.getApiKeyInfos()).filter(o -> o.isInvalidated() == false) - .map(o -> o.getUsername()).collect(Collectors.toSet()); + .map(o -> o.getUsername()).collect(Collectors.toSet()); assertThat(actualUsernames, containsInAnyOrder(expectedUsernames.toArray(Strings.EMPTY_ARRAY))); if (invalidatedApiKeyIds != null) { List actualInvalidatedApiKeyIds = Arrays.stream(response.getApiKeyInfos()).filter(o -> o.isInvalidated()) - .map(o -> o.getId()).collect(Collectors.toList()); + .map(o -> o.getId()).collect(Collectors.toList()); assertThat(invalidatedApiKeyIds, containsInAnyOrder(actualInvalidatedApiKeyIds.toArray(Strings.EMPTY_ARRAY))); } + if (metadatas != null) { + final HashMap> idToMetadata = IntStream.range(0, responses.size()).collect( + (Supplier>>) HashMap::new, + (m, i) -> m.put(responses.get(i).getId(), metadatas.get(i)), + HashMap::putAll); + for (ApiKey apiKey : response.getApiKeyInfos()) { + final Map metadata = idToMetadata.get(apiKey.getId()); + assertThat(apiKey.getMetadata(), equalTo(metadata == null ? Map.of() : metadata)); + } + } } - private List createApiKeys(int noOfApiKeys, TimeValue expiration) { + private Tuple, List>> createApiKeys(int noOfApiKeys, TimeValue expiration) { return createApiKeys(TEST_SUPERUSER, noOfApiKeys, expiration, "monitor"); } - private List createApiKeys(String user, int noOfApiKeys, TimeValue expiration, String... clusterPrivileges) { + private Tuple, List>> createApiKeys( + String user, int noOfApiKeys, TimeValue expiration, String... clusterPrivileges) { final Map headers = Collections.singletonMap("Authorization", basicAuthHeaderValue(user, TEST_PASSWORD_SECURE_STRING)); return createApiKeys(headers, noOfApiKeys, expiration, clusterPrivileges); } - private List createApiKeys(String owningUser, String authenticatingUser, - int noOfApiKeys, TimeValue expiration, String... clusterPrivileges) { + private Tuple, List>> createApiKeys( + String owningUser, String authenticatingUser, int noOfApiKeys, TimeValue expiration, String... clusterPrivileges) { final Map headers = Map.of( "Authorization", basicAuthHeaderValue(authenticatingUser, TEST_PASSWORD_SECURE_STRING), "es-security-runas-user", owningUser); return createApiKeys(headers, noOfApiKeys, expiration, clusterPrivileges); } - private List createApiKeys(Map headers, - int noOfApiKeys, TimeValue expiration, String... clusterPrivileges) { + private Tuple, List>> createApiKeys( + Map headers, int noOfApiKeys, TimeValue expiration, String... clusterPrivileges) { return createApiKeys(headers, noOfApiKeys, "test-key-", expiration, clusterPrivileges); } - private List createApiKeys(Map headers, int noOfApiKeys, String namePrefix, - TimeValue expiration, String... clusterPrivileges) { + private Tuple, List>> createApiKeys( + Map headers, int noOfApiKeys, String namePrefix, TimeValue expiration, String... clusterPrivileges) { + List> metadatas = new ArrayList<>(noOfApiKeys); List responses = new ArrayList<>(); for (int i = 0; i < noOfApiKeys; i++) { final RoleDescriptor descriptor = new RoleDescriptor("role", clusterPrivileges, null, null); Client client = client().filterWithHeader(headers); + final Map metadata = ApiKeyTests.randomMetadata(); + metadatas.add(metadata); final CreateApiKeyResponse response = new CreateApiKeyRequestBuilder(client) .setName(namePrefix + randomAlphaOfLengthBetween(5, 9) + i).setExpiration(expiration) - .setRoleDescriptors(Collections.singletonList(descriptor)).get(); + .setRoleDescriptors(Collections.singletonList(descriptor)) + .setMetadata(metadata).get(); assertNotNull(response.getId()); assertNotNull(response.getKey()); responses.add(response); } assertThat(responses.size(), is(noOfApiKeys)); - return responses; + return new Tuple<>(responses, metadatas); } /** diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 0cd1aee8ded8a..2dca2473b82e3 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -1294,6 +1294,10 @@ private static XContentBuilder getIndexMappings() { builder.field("dynamic", false); builder.endObject(); + builder.startObject("metadata_flattened"); + builder.field("type", "flattened"); + builder.endObject(); + builder.startObject("enabled"); builder.field("type", "boolean"); builder.endObject(); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 56fda5ae8a0c9..cbeed9dc562b0 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -39,6 +39,7 @@ import org.elasticsearch.common.ParseField; import org.elasticsearch.common.Strings; import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.cache.Cache; import org.elasticsearch.common.cache.CacheBuilder; @@ -140,6 +141,7 @@ public class ApiKeyService { private static final DeprecationLogger deprecationLogger = DeprecationLogger.getLogger(ApiKeyService.class); public static final String API_KEY_ID_KEY = "_security_api_key_id"; public static final String API_KEY_NAME_KEY = "_security_api_key_name"; + public static final String API_KEY_METADATA_KEY = "_security_api_key_metadata"; public static final String API_KEY_REALM_NAME = "_es_api_key"; public static final String API_KEY_REALM_TYPE = "_es_api_key"; public static final String API_KEY_CREATOR_REALM_NAME = "_security_api_key_creator_realm_name"; @@ -261,7 +263,7 @@ private void createApiKeyAndIndexIt(Authentication authentication, CreateApiKeyR final Version version = clusterService.state().nodes().getMinNodeVersion(); try (XContentBuilder builder = newDocument(apiKey, request.getName(), authentication, roleDescriptorSet, created, expiration, - request.getRoleDescriptors(), version)) { + request.getRoleDescriptors(), version, request.getMetadata())) { final IndexRequest indexRequest = client.prepareIndex(SECURITY_MAIN_ALIAS) @@ -290,7 +292,7 @@ private void createApiKeyAndIndexIt(Authentication authentication, CreateApiKeyR */ XContentBuilder newDocument(SecureString apiKey, String name, Authentication authentication, Set userRoles, Instant created, Instant expiration, List keyRoles, - Version version) throws IOException { + Version version, @Nullable Map metadata) throws IOException { XContentBuilder builder = XContentFactory.jsonBuilder(); builder.startObject() .field("doc_type", "api_key") @@ -330,6 +332,7 @@ XContentBuilder newDocument(SecureString apiKey, String name, Authentication aut builder.field("name", name) .field("version", version.id) + .field("metadata_flattened", metadata) .startObject("creator") .field("principal", authentication.getUser().principal()) .field("full_name", authentication.getUser().fullName()) @@ -670,6 +673,9 @@ void validateApiKeyExpiration(ApiKeyDoc apiKeyDoc, ApiKeyCredentials credentials authResultMetadata.put(API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY, apiKeyDoc.limitedByRoleDescriptorsBytes); authResultMetadata.put(API_KEY_ID_KEY, credentials.getId()); authResultMetadata.put(API_KEY_NAME_KEY, apiKeyDoc.name); + if (apiKeyDoc.metadataFlattened != null) { + authResultMetadata.put(API_KEY_METADATA_KEY, apiKeyDoc.metadataFlattened); + } listener.onResponse(AuthenticationResult.success(apiKeyUser, authResultMetadata)); } else { listener.onResponse(AuthenticationResult.unsuccessful("api key is expired", null)); @@ -868,8 +874,10 @@ private void findApiKeys(final BoolQueryBuilder boolQuery, boolean filterOutInva Boolean invalidated = (Boolean) source.get("api_key_invalidated"); String username = (String) ((Map) source.get("creator")).get("principal"); String realm = (String) ((Map) source.get("creator")).get("realm"); + Map metadata = (Map) source.get("metadata_flattened"); return new ApiKey(name, id, Instant.ofEpochMilli(creation), - (expiration != null) ? Instant.ofEpochMilli(expiration) : null, invalidated, username, realm); + (expiration != null) ? Instant.ofEpochMilli(expiration) : null, + invalidated, username, realm, metadata); })); } } @@ -1116,6 +1124,7 @@ private boolean verify(SecureString password) { public static final class ApiKeyDoc { + private static final BytesReference NULL_BYTES = new BytesArray("null"); static final InstantiatingObjectParser PARSER; static { InstantiatingObjectParser.Builder builder = @@ -1131,6 +1140,7 @@ public static final class ApiKeyDoc { parserHelper.declareRawObject(builder, constructorArg(), new ParseField("role_descriptors")); parserHelper.declareRawObject(builder, constructorArg(), new ParseField("limited_by_role_descriptors")); builder.declareObject(constructorArg(), (p, c) -> p.map(), new ParseField("creator")); + parserHelper.declareRawObjectOrNull(builder, optionalConstructorArg(), new ParseField("metadata_flattened")); PARSER = builder.build(); } @@ -1145,6 +1155,8 @@ public static final class ApiKeyDoc { final BytesReference roleDescriptorsBytes; final BytesReference limitedByRoleDescriptorsBytes; final Map creator; + @Nullable + final BytesReference metadataFlattened; public ApiKeyDoc( String docType, @@ -1156,7 +1168,8 @@ public ApiKeyDoc( int version, BytesReference roleDescriptorsBytes, BytesReference limitedByRoleDescriptorsBytes, - Map creator) { + Map creator, + @Nullable BytesReference metadataFlattened) { this.docType = docType; this.creationTime = creationTime; @@ -1168,6 +1181,7 @@ public ApiKeyDoc( this.roleDescriptorsBytes = roleDescriptorsBytes; this.limitedByRoleDescriptorsBytes = limitedByRoleDescriptorsBytes; this.creator = creator; + this.metadataFlattened = NULL_BYTES.equals(metadataFlattened) ? null : metadataFlattened; } public CachedApiKeyDoc toCachedApiKeyDoc() { @@ -1186,7 +1200,8 @@ public CachedApiKeyDoc toCachedApiKeyDoc() { version, creator, roleDescriptorsHash, - limitedByRoleDescriptorsHash); + limitedByRoleDescriptorsHash, + metadataFlattened); } static ApiKeyDoc fromXContent(XContentParser parser) { @@ -1209,6 +1224,8 @@ public static final class CachedApiKeyDoc { final Map creator; final String roleDescriptorsHash; final String limitedByRoleDescriptorsHash; + @Nullable + final BytesReference metadataFlattened; public CachedApiKeyDoc( long creationTime, long expirationTime, @@ -1216,7 +1233,8 @@ public CachedApiKeyDoc( String hash, String name, int version, Map creator, String roleDescriptorsHash, - String limitedByRoleDescriptorsHash) { + String limitedByRoleDescriptorsHash, + @Nullable BytesReference metadataFlattened) { this.creationTime = creationTime; this.expirationTime = expirationTime; this.invalidated = invalidated; @@ -1226,6 +1244,7 @@ public CachedApiKeyDoc( this.creator = creator; this.roleDescriptorsHash = roleDescriptorsHash; this.limitedByRoleDescriptorsHash = limitedByRoleDescriptorsHash; + this.metadataFlattened = metadataFlattened; } public ApiKeyDoc toApiKeyDoc(BytesReference roleDescriptorsBytes, BytesReference limitedByRoleDescriptorsBytes) { @@ -1239,7 +1258,8 @@ public ApiKeyDoc toApiKeyDoc(BytesReference roleDescriptorsBytes, BytesReference version, roleDescriptorsBytes, limitedByRoleDescriptorsBytes, - creator); + creator, + metadataFlattened); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java index ed14cf2c3a48c..373ec9cf99050 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -38,11 +38,13 @@ import org.elasticsearch.test.ClusterServiceUtils; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.VersionUtils; +import org.elasticsearch.test.XContentTestUtils; import org.elasticsearch.threadpool.FixedExecutorBuilder; import org.elasticsearch.threadpool.TestThreadPool; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.ApiKeyTests; import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; @@ -217,7 +219,7 @@ public void testAuthenticateWithApiKey() throws Exception { } else { user = new User("hulk", new String[]{"superuser"}, "Bruce Banner", "hulk@test.com", Map.of(), true); } - mockKeyDocument(service, id, key, user); + final Map metadata = mockKeyDocument(service, id, key, user); final AuthenticationResult auth = tryAuthenticate(service, id, key); assertThat(auth.getStatus(), is(AuthenticationResult.Status.SUCCESS)); @@ -229,23 +231,7 @@ public void testAuthenticateWithApiKey() throws Exception { assertThat(auth.getMetadata().get(ApiKeyService.API_KEY_CREATOR_REALM_TYPE), is("native")); assertThat(auth.getMetadata().get(ApiKeyService.API_KEY_ID_KEY), is(id)); assertThat(auth.getMetadata().get(ApiKeyService.API_KEY_NAME_KEY), is("test")); - } - - public void testAuthenticationIsSkippedIfLicenseDoesNotAllowIt() throws Exception { - final Settings settings = Settings.builder().put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true).build(); - final ApiKeyService service = createApiKeyService(settings); - - final String id = randomAlphaOfLength(12); - final String key = randomAlphaOfLength(16); - - final User user; - if (randomBoolean()) { - user = new User(randomAlphaOfLength(6), new String[] { randomAlphaOfLength(12) }, new User("authenticated_user", - new String[] { "other" })); - } else { - user = new User(randomAlphaOfLength(6), new String[] { randomAlphaOfLength(12) }); - } - mockKeyDocument(service, id, key, user); + checkAuthApiKeyMetadata(metadata, auth); } public void testAuthenticationFailureWithInvalidatedApiKey() throws Exception { @@ -316,7 +302,7 @@ public void testMixingValidAndInvalidCredentials() throws Exception { } else { user = new User("hulk", new String[] { "superuser" }); } - mockKeyDocument(service, id, realKey, user); + final Map metadata = mockKeyDocument(service, id, realKey, user); for (int i = 0; i < 3; i++) { final String wrongKey = "=" + randomAlphaOfLength(14) + "@"; @@ -329,40 +315,44 @@ public void testMixingValidAndInvalidCredentials() throws Exception { assertThat(auth.getStatus(), is(AuthenticationResult.Status.SUCCESS)); assertThat(auth.getUser(), notNullValue()); assertThat(auth.getUser().principal(), is("hulk")); + checkAuthApiKeyMetadata(metadata, auth); } } - private void mockKeyDocument(ApiKeyService service, String id, String key, User user) throws IOException { - mockKeyDocument(service, id, key, user, false, Duration.ofSeconds(3600)); + private Map mockKeyDocument(ApiKeyService service, String id, String key, User user) throws IOException { + return mockKeyDocument(service, id, key, user, false, Duration.ofSeconds(3600)); } - private void mockKeyDocument(ApiKeyService service, String id, String key, User user, boolean invalidated, - Duration expiry) throws IOException { - mockKeyDocument(service, id, key, user, invalidated, expiry, null); + private Map mockKeyDocument(ApiKeyService service, String id, String key, User user, boolean invalidated, + Duration expiry) throws IOException { + return mockKeyDocument(service, id, key, user, invalidated, expiry, null); } - private void mockKeyDocument(ApiKeyService service, String id, String key, User user, boolean invalidated, - Duration expiry, List keyRoles) throws IOException { + private Map mockKeyDocument(ApiKeyService service, String id, String key, User user, boolean invalidated, + Duration expiry, List keyRoles) throws IOException { final Authentication authentication; if (user.isRunAs()) { authentication = new Authentication(user, new RealmRef("authRealm", "test", "foo"), - new RealmRef("realm1", "native", "node01"), Version.CURRENT, - randomFrom(AuthenticationType.REALM, AuthenticationType.TOKEN, AuthenticationType.INTERNAL, - AuthenticationType.ANONYMOUS), Collections.emptyMap()); + new RealmRef("realm1", "native", "node01"), Version.CURRENT, + randomFrom(AuthenticationType.REALM, AuthenticationType.TOKEN, AuthenticationType.INTERNAL, + AuthenticationType.ANONYMOUS), Collections.emptyMap()); } else { authentication = new Authentication(user, new RealmRef("realm1", "native", "node01"), null, - Version.CURRENT, randomFrom(AuthenticationType.REALM, AuthenticationType.TOKEN, AuthenticationType.INTERNAL, - AuthenticationType.ANONYMOUS), Collections.emptyMap()); + Version.CURRENT, randomFrom(AuthenticationType.REALM, AuthenticationType.TOKEN, AuthenticationType.INTERNAL, + AuthenticationType.ANONYMOUS), Collections.emptyMap()); } + @SuppressWarnings("unchecked") + final Map metadata = ApiKeyTests.randomMetadata(); XContentBuilder docSource = service.newDocument(new SecureString(key.toCharArray()), "test", authentication, Collections.singleton(SUPERUSER_ROLE_DESCRIPTOR), Instant.now(), Instant.now().plus(expiry), keyRoles, - Version.CURRENT); + Version.CURRENT, metadata); if (invalidated) { Map map = XContentHelper.convertToMap(BytesReference.bytes(docSource), true, XContentType.JSON).v2(); map.put("api_key_invalidated", true); docSource = XContentBuilder.builder(XContentType.JSON.xContent()).map(map); } SecurityMocks.mockGetRequest(client, id, BytesReference.bytes(docSource)); + return metadata; } private AuthenticationResult tryAuthenticate(ApiKeyService service, String id, String key) throws Exception { @@ -589,7 +579,7 @@ public void testApiKeyServiceDisabled() throws Exception { assertThat(e.getMetadata(FeatureNotEnabledException.DISABLED_FEATURE_METADATA), contains("api_keys")); } - public void testApiKeyCache() { + public void testApiKeyCache() throws IOException { final String apiKey = randomAlphaOfLength(16); Hasher hasher = getFastStoredHashAlgoForTests(); final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray())); @@ -648,6 +638,7 @@ public void testAuthenticateWhileCacheBeingPopulated() throws Exception { final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray())); Map sourceMap = buildApiKeySourceDoc(hash); + final Object metadata = sourceMap.get("metadata_flattened"); ApiKeyService realService = createApiKeyService(Settings.EMPTY); ApiKeyService service = Mockito.spy(realService); @@ -692,15 +683,20 @@ public void testAuthenticateWhileCacheBeingPopulated() throws Exception { hashWait.release(); - assertThat(future1.actionGet(TimeValue.timeValueSeconds(2)).isAuthenticated(), is(true)); - assertThat(future2.actionGet(TimeValue.timeValueMillis(100)).isAuthenticated(), is(true)); + final AuthenticationResult authResult1 = future1.actionGet(TimeValue.timeValueSeconds(2)); + assertThat(authResult1.isAuthenticated(), is(true)); + checkAuthApiKeyMetadata(metadata, authResult1); + + final AuthenticationResult authResult2 = future2.actionGet(TimeValue.timeValueMillis(100)); + assertThat(authResult2.isAuthenticated(), is(true)); + checkAuthApiKeyMetadata(metadata, authResult2); CachedApiKeyHashResult cachedApiKeyHashResult = service.getFromCache(creds.getId()); assertNotNull(cachedApiKeyHashResult); assertThat(cachedApiKeyHashResult.success, is(true)); } - public void testApiKeyCacheDisabled() { + public void testApiKeyCacheDisabled() throws IOException { final String apiKey = randomAlphaOfLength(16); Hasher hasher = getFastStoredHashAlgoForTests(); final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray())); @@ -722,7 +718,7 @@ public void testApiKeyCacheDisabled() { assertNull(service.getRoleDescriptorsBytesCache()); } - public void testApiKeyDocCacheCanBeDisabledSeparately() { + public void testApiKeyDocCacheCanBeDisabledSeparately() throws IOException { final String apiKey = randomAlphaOfLength(16); Hasher hasher = getFastStoredHashAlgoForTests(); final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray())); @@ -755,7 +751,8 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru final String docId = randomAlphaOfLength(16); final String apiKey = randomAlphaOfLength(16); ApiKeyCredentials apiKeyCredentials = new ApiKeyCredentials(docId, new SecureString(apiKey.toCharArray())); - mockKeyDocument(service, docId, apiKey, new User("hulk", "superuser"), false, Duration.ofSeconds(3600)); + final Map metadata = + mockKeyDocument(service, docId, apiKey, new User("hulk", "superuser"), false, Duration.ofSeconds(3600)); PlainActionFuture future = new PlainActionFuture<>(); service.loadApiKeyAndValidateCredentials(threadContext, apiKeyCredentials, future); final ApiKeyService.CachedApiKeyDoc cachedApiKeyDoc = service.getDocCache().get(docId); @@ -771,12 +768,18 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru final List limitedByRoleDescriptors = service.parseRoleDescriptors(docId, limitedByRoleDescriptorsBytes); assertEquals(1, limitedByRoleDescriptors.size()); assertEquals(SUPERUSER_ROLE_DESCRIPTOR, limitedByRoleDescriptors.get(0)); + if (metadata == null) { + assertNull(cachedApiKeyDoc.metadataFlattened); + } else { + assertThat(cachedApiKeyDoc.metadataFlattened, equalTo(XContentTestUtils.convertToXContent(metadata, XContentType.JSON))); + } // 2. A different API Key with the same role descriptors will share the entries in the role descriptor cache final String docId2 = randomAlphaOfLength(16); final String apiKey2 = randomAlphaOfLength(16); ApiKeyCredentials apiKeyCredentials2 = new ApiKeyCredentials(docId2, new SecureString(apiKey2.toCharArray())); - mockKeyDocument(service, docId2, apiKey2, new User("thor", "superuser"), false, Duration.ofSeconds(3600)); + final Map metadata2 = + mockKeyDocument(service, docId2, apiKey2, new User("thor", "superuser"), false, Duration.ofSeconds(3600)); PlainActionFuture future2 = new PlainActionFuture<>(); service.loadApiKeyAndValidateCredentials(threadContext, apiKeyCredentials2, future2); final ApiKeyService.CachedApiKeyDoc cachedApiKeyDoc2 = service.getDocCache().get(docId2); @@ -788,6 +791,11 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru final BytesReference limitedByRoleDescriptorsBytes2 = service.getRoleDescriptorsBytesCache().get(cachedApiKeyDoc2.limitedByRoleDescriptorsHash); assertSame(limitedByRoleDescriptorsBytes, limitedByRoleDescriptorsBytes2); + if (metadata2 == null) { + assertNull(cachedApiKeyDoc2.metadataFlattened); + } else { + assertThat(cachedApiKeyDoc2.metadataFlattened, equalTo(XContentTestUtils.convertToXContent(metadata2, XContentType.JSON))); + } // 3. Different role descriptors will be cached into a separate entry final String docId3 = randomAlphaOfLength(16); @@ -795,8 +803,8 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru ApiKeyCredentials apiKeyCredentials3 = new ApiKeyCredentials(docId3, new SecureString(apiKey3.toCharArray())); final List keyRoles = List.of(RoleDescriptor.parse("key-role", new BytesArray("{\"cluster\":[\"monitor\"]}"), true, XContentType.JSON)); - mockKeyDocument(service, docId3, apiKey3, new User("banner", "superuser"), - false, Duration.ofSeconds(3600), keyRoles); + final Map metadata3 = + mockKeyDocument(service, docId3, apiKey3, new User("banner", "superuser"), false, Duration.ofSeconds(3600), keyRoles); PlainActionFuture future3 = new PlainActionFuture<>(); service.loadApiKeyAndValidateCredentials(threadContext, apiKeyCredentials3, future3); final ApiKeyService.CachedApiKeyDoc cachedApiKeyDoc3 = service.getDocCache().get(docId3); @@ -809,22 +817,32 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru final BytesReference roleDescriptorsBytes3 = service.getRoleDescriptorsBytesCache().get(cachedApiKeyDoc3.roleDescriptorsHash); assertNotSame(roleDescriptorsBytes, roleDescriptorsBytes3); assertEquals(3, service.getRoleDescriptorsBytesCache().count()); + if (metadata3 == null) { + assertNull(cachedApiKeyDoc3.metadataFlattened); + } else { + assertThat(cachedApiKeyDoc3.metadataFlattened, equalTo(XContentTestUtils.convertToXContent(metadata3, XContentType.JSON))); + } // 4. Will fetch document from security index if role descriptors are not found even when // cachedApiKeyDoc is available service.getRoleDescriptorsBytesCache().invalidateAll(); - mockKeyDocument(service, docId, apiKey, new User("hulk", "superuser"), false, Duration.ofSeconds(3600)); + final Map metadata4 = + mockKeyDocument(service, docId, apiKey, new User("hulk", "superuser"), false, Duration.ofSeconds(3600)); PlainActionFuture future4 = new PlainActionFuture<>(); service.loadApiKeyAndValidateCredentials(threadContext, apiKeyCredentials, future4); verify(client, times(4)).get(any(GetRequest.class), any(ActionListener.class)); assertEquals(2, service.getRoleDescriptorsBytesCache().count()); - assertSame(AuthenticationResult.Status.SUCCESS, future4.get().getStatus()); + final AuthenticationResult authResult4 = future4.get(); + assertSame(AuthenticationResult.Status.SUCCESS, authResult4.getStatus()); + checkAuthApiKeyMetadata(metadata4, authResult4); // 5. Cached entries will be used for the same API key doc SecurityMocks.mockGetRequestException(client, new EsRejectedExecutionException("rejected")); PlainActionFuture future5 = new PlainActionFuture<>(); service.loadApiKeyAndValidateCredentials(threadContext, apiKeyCredentials, future5); - assertSame(AuthenticationResult.Status.SUCCESS, future5.get().getStatus()); + final AuthenticationResult authResult5 = future5.get(); + assertSame(AuthenticationResult.Status.SUCCESS, authResult5.getStatus()); + checkAuthApiKeyMetadata(metadata4, authResult5); } public void testWillGetLookedUpByRealmNameIfExists() { @@ -889,6 +907,7 @@ public void testCachedApiKeyValidationWillNotBeBlockedByUnCachedApiKey() throws Hasher hasher = getFastStoredHashAlgoForTests(); final char[] hash = hasher.hash(new SecureString(apiKey1.toCharArray())); Map sourceMap = buildApiKeySourceDoc(hash); + final Object metadata = sourceMap.get("metadata_flattened"); mockSourceDocument(creds.getId(), sourceMap); // Authenticate the key once to cache it @@ -897,6 +916,7 @@ public void testCachedApiKeyValidationWillNotBeBlockedByUnCachedApiKey() throws service.authenticateWithApiKeyIfPresent(threadPool.getThreadContext(), future); final AuthenticationResult authenticationResult = future.get(); assertEquals(AuthenticationResult.Status.SUCCESS, authenticationResult.getStatus()); + checkAuthApiKeyMetadata(metadata,authenticationResult); // Now force the hashing thread pool to saturate so that any un-cached keys cannot be validated final ExecutorService mockExecutorService = mock(ExecutorService.class); @@ -926,6 +946,7 @@ public void testCachedApiKeyValidationWillNotBeBlockedByUnCachedApiKey() throws service.authenticateWithApiKeyIfPresent(threadPool.getThreadContext(), future3); final AuthenticationResult authenticationResult3 = future3.get(); assertEquals(AuthenticationResult.Status.SUCCESS, authenticationResult3.getStatus()); + checkAuthApiKeyMetadata(metadata, authenticationResult3); } public void testApiKeyDocDeserialization() throws IOException { @@ -985,7 +1006,8 @@ public static Authentication createApiKeyAuthentication(ApiKeyService apiKeyServ Version version) throws Exception { XContentBuilder keyDocSource = apiKeyService.newDocument( new SecureString(randomAlphaOfLength(16).toCharArray()), "test", authentication, - userRoles, Instant.now(), Instant.now().plus(Duration.ofSeconds(3600)), keyRoles, Version.CURRENT); + userRoles, Instant.now(), Instant.now().plus(Duration.ofSeconds(3600)), keyRoles, Version.CURRENT, + randomBoolean() ? null : Map.of(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8))); final ApiKeyDoc apiKeyDoc = ApiKeyDoc.fromXContent( XContentHelper.createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, BytesReference.bytes(keyDocSource), XContentType.JSON)); @@ -1067,6 +1089,8 @@ private Map buildApiKeySourceDoc(char[] hash) { creatorMap.put("metadata", Collections.emptyMap()); sourceMap.put("creator", creatorMap); sourceMap.put("api_key_invalidated", false); + //noinspection unchecked + sourceMap.put("metadata_flattened", ApiKeyTests.randomMetadata()); return sourceMap; } @@ -1083,7 +1107,9 @@ private void mockSourceDocument(String id, Map sourceMap) throws } } - private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean invalidated) { + private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean invalidated) throws IOException { + final BytesReference metadataBytes = + XContentTestUtils.convertToXContent(ApiKeyTests.randomMetadata(), XContentType.JSON); return new ApiKeyDoc( "api_key", Clock.systemUTC().instant().toEpochMilli(), @@ -1101,7 +1127,19 @@ private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean inval "realm", "realm1", "realm_type", "realm_type1", "metadata", Map.of() - ) + ), + metadataBytes ); } + + private void checkAuthApiKeyMetadata(Object metadata, AuthenticationResult authResult1) throws IOException { + if (metadata == null) { + assertThat(authResult1.getMetadata().containsKey(ApiKeyService.API_KEY_METADATA_KEY), is(false)); + } else { + //noinspection unchecked + assertThat( + authResult1.getMetadata().get(ApiKeyService.API_KEY_METADATA_KEY), + equalTo(XContentTestUtils.convertToXContent((Map) metadata, XContentType.JSON))); + } + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGetApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGetApiKeyActionTests.java index db12ff34ab341..0300b723e546e 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGetApiKeyActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGetApiKeyActionTests.java @@ -29,6 +29,7 @@ import org.elasticsearch.test.rest.FakeRestRequest; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.xpack.core.security.action.ApiKey; +import org.elasticsearch.xpack.core.security.action.ApiKeyTests; import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest; import org.elasticsearch.xpack.core.security.action.GetApiKeyResponse; @@ -84,8 +85,11 @@ public void sendResponse(RestResponse restResponse) { }; final Instant creation = Instant.now(); final Instant expiration = randomFrom(Arrays.asList(null, Instant.now().plus(10, ChronoUnit.DAYS))); + @SuppressWarnings("unchecked") + final Map metadata = ApiKeyTests.randomMetadata(); final GetApiKeyResponse getApiKeyResponseExpected = new GetApiKeyResponse( - Collections.singletonList(new ApiKey("api-key-name-1", "api-key-id-1", creation, expiration, false, "user-x", "realm-1"))); + Collections.singletonList( + new ApiKey("api-key-name-1", "api-key-id-1", creation, expiration, false, "user-x", "realm-1", metadata))); try (NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { @SuppressWarnings("unchecked") @@ -126,7 +130,8 @@ void doExecute(ActionType action, Request request, ActionListener + { + "name": "my-mixed-api-key-1" + } + - match: { name: "my-mixed-api-key-1" } + - is_true: id + - is_true: api_key + - transform_and_set: { login_creds: "#base64EncodeCredentials(id,api_key)" } + + - do: + headers: + Authorization: ApiKey ${login_creds} + nodes.info: {} + - match: { _nodes.failed: 0 } + + +--- +"Create API key with metadata in a mixed cluster": + + - skip: + features: [headers, node_selector] + + - do: + node_selector: + version: "8.0.0 - " + security.create_api_key: + body: > + { + "name": "my-mixed-api-key-2", + "metadata": {"foo": "bar"} + } + - match: { name: "my-mixed-api-key-2" } + - is_true: id + - is_true: api_key diff --git a/x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/mixed_cluster/120_api_key_auth.yml b/x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/mixed_cluster/120_api_key_auth.yml deleted file mode 100644 index 34b019d0d9911..0000000000000 --- a/x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/mixed_cluster/120_api_key_auth.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -"Test API key authentication will work in a mixed cluster": - - - skip: - features: headers - - - do: - security.create_api_key: - body: > - { - "name": "my-api-key" - } - - match: { name: "my-api-key" } - - is_true: id - - is_true: api_key - - transform_and_set: { login_creds: "#base64EncodeCredentials(id,api_key)" } - - - do: - headers: - Authorization: ApiKey ${login_creds} - nodes.info: {} - - match: { _nodes.failed: 0 } - diff --git a/x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/old_cluster/120_api_key.yml b/x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/old_cluster/120_api_key.yml new file mode 100644 index 0000000000000..20d4e18b62cb6 --- /dev/null +++ b/x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/old_cluster/120_api_key.yml @@ -0,0 +1,16 @@ +--- +"Create API key in the old cluster": + + - skip: + features: headers + + - do: + security.create_api_key: + body: > + { + "name": "my-old-api-key" + } + - match: { name: "my-old-api-key" } + - is_true: id + - is_true: api_key + diff --git a/x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/upgraded_cluster/120_api_key.yml b/x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/upgraded_cluster/120_api_key.yml new file mode 100644 index 0000000000000..3e010cb835df0 --- /dev/null +++ b/x-pack/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/upgraded_cluster/120_api_key.yml @@ -0,0 +1,55 @@ +--- +"Create and Get API key in the upgraded cluster": + + - skip: + features: headers + + - do: + security.create_api_key: + body: > + { + "name": "my-new-api-key", + "metadata": {"application": "myapp"} + } + - match: { name: "my-new-api-key" } + - is_true: id + - is_true: api_key + + - do: + security.get_api_key: + name: "my-old-api-key" + + - length: { api_keys: 1 } + + - match: { api_keys.0.name: "my-old-api-key" } + - match: { api_keys.0.metadata: {} } + + - do: + security.get_api_key: + name: "my-new-api-key" + + - length: { api_keys: 1 } + + - match: { api_keys.0.name: "my-new-api-key" } + - match: { api_keys.0.metadata: { "application": "myapp" } } + + - do: + security.get_api_key: + name: "my-mixed-api-key-1" + + - length: { api_keys: 1 } + + - match: { api_keys.0.name: "my-mixed-api-key-1" } + - match: { api_keys.0.metadata: {} } + + - do: + security.get_api_key: + name: "my-mixed-api-key-2" + + - length: { api_keys: 1 } + + - match: { api_keys.0.name: "my-mixed-api-key-2" } +# We cannot assert metadata for this API key because it is possible +# that the security index is on an old node and the metadata is dropped +# when transfer through the wire. But at least the key will be created +# and retrieved successfully