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 dca1547f15890..7862b4d85f579 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 @@ -1291,6 +1291,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