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 7c11aee6d469f..4bea988c44cef 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 @@ -92,6 +92,8 @@ import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountSettings; import org.elasticsearch.xpack.core.security.authc.support.Hasher; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore; +import org.elasticsearch.xpack.core.security.support.MetadataUtils; import org.elasticsearch.xpack.core.security.user.User; import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; import org.elasticsearch.xpack.security.support.FeatureNotEnabledException; @@ -588,11 +590,19 @@ public void getRoleForApiKey(Authentication authentication, ActionListener authnRoleDescriptorsList = parseRoleDescriptors(apiKeyId, authnRoleDescriptors); + final List authnRoleDescriptorsList = parseRoleDescriptors( + apiKeyId, + authnRoleDescriptors, + ApiKeyRoleType.LIMITED_BY + ); listener.onResponse(new ApiKeyRoleDescriptors(apiKeyId, authnRoleDescriptorsList, null)); } else { - final List roleDescriptorList = parseRoleDescriptors(apiKeyId, roleDescriptors); - final List authnRoleDescriptorsList = parseRoleDescriptors(apiKeyId, authnRoleDescriptors); + final List roleDescriptorList = parseRoleDescriptors(apiKeyId, roleDescriptors, ApiKeyRoleType.ASSIGNED); + final List authnRoleDescriptorsList = parseRoleDescriptors( + apiKeyId, + authnRoleDescriptors, + ApiKeyRoleType.LIMITED_BY + ); listener.onResponse(new ApiKeyRoleDescriptors(apiKeyId, roleDescriptorList, authnRoleDescriptorsList)); } } @@ -642,11 +652,15 @@ public List getLimitedByRoleDescriptors() { } } - private List parseRoleDescriptors(final String apiKeyId, final Map roleDescriptors) { - if (roleDescriptors == null) { + private List parseRoleDescriptors( + final String apiKeyId, + final Map roleDescriptorsMap, + ApiKeyRoleType roleType + ) { + if (roleDescriptorsMap == null) { return null; } - return roleDescriptors.entrySet().stream().map(entry -> { + final List roleDescriptors = roleDescriptorsMap.entrySet().stream().map(entry -> { final String name = entry.getKey(); @SuppressWarnings("unchecked") final Map rdMap = (Map) entry.getValue(); @@ -666,9 +680,10 @@ private List parseRoleDescriptors(final String apiKeyId, final M throw new UncheckedIOException(e); } }).collect(Collectors.toList()); + return roleType == ApiKeyRoleType.LIMITED_BY ? maybeReplaceSuperuserRoleDescriptor(apiKeyId, roleDescriptors) : roleDescriptors; } - public List parseRoleDescriptors(final String apiKeyId, BytesReference bytesReference) { + public List parseRoleDescriptors(final String apiKeyId, BytesReference bytesReference, ApiKeyRoleType roleType) { if (bytesReference == null) { return Collections.emptyList(); } @@ -691,7 +706,40 @@ public List parseRoleDescriptors(final String apiKeyId, BytesRef } catch (IOException e) { throw new UncheckedIOException(e); } - return Collections.unmodifiableList(roleDescriptors); + return roleType == ApiKeyRoleType.LIMITED_BY ? maybeReplaceSuperuserRoleDescriptor(apiKeyId, roleDescriptors) : roleDescriptors; + } + + // package private for tests + static final RoleDescriptor LEGACY_SUPERUSER_ROLE_DESCRIPTOR = new RoleDescriptor( + "superuser", + new String[] { "all" }, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder().indices("*").privileges("all").allowRestrictedIndices(true).build() }, + new RoleDescriptor.ApplicationResourcePrivileges[] { + RoleDescriptor.ApplicationResourcePrivileges.builder().application("*").privileges("*").resources("*").build() }, + null, + new String[] { "*" }, + MetadataUtils.DEFAULT_RESERVED_METADATA, + Collections.emptyMap() + ); + + // This method should only be called to replace the superuser role descriptor for the limited-by roles of an API Key. + // We do not replace assigned roles because they are created explicitly by users. + // Before #82049, it is possible to specify a role descriptor for API keys that is identical to the builtin superuser role + // (including the _reserved metadata field). + private List maybeReplaceSuperuserRoleDescriptor(String apiKeyId, List roleDescriptors) { + // Scan through all the roles because superuser can be one of the roles that a user has. Unlike building the Role object, + // capturing role descriptors does not preempt for superuser. + return roleDescriptors.stream().map(rd -> { + // Since we are only replacing limited-by roles and all limited-by roles are looked up with role providers, + // it is technically possible to just check the name of superuser and the _reserved metadata field. + // But the gain is not much since role resolving is cached and comparing the whole role descriptor is still safer. + if (rd.equals(LEGACY_SUPERUSER_ROLE_DESCRIPTOR)) { + logger.debug("replacing superuser role for API key [{}]", apiKeyId); + return ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR; + } + return rd; + }).toList(); } /** @@ -1709,4 +1757,18 @@ public void invalidateAll() { roleDescriptorsBytesCache.invalidateAll(); } } + + /** + * The type of one set of API key roles. + */ + public enum ApiKeyRoleType { + /** + * Roles directly specified by the creator user on API key creation + */ + ASSIGNED, + /** + * Roles captured for the owner user as the upper bound of the assigned roles + */ + LIMITED_BY; + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java index 61475b3eb9119..f1e65fb17e9a1 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java @@ -452,7 +452,11 @@ private void buildAndCacheRoleForApiKey(Authentication authentication, boolean l final Role existing = roleCache.get(roleKey); if (existing == null) { final long invalidationCounter = numInvalidation.get(); - final List roleDescriptors = apiKeyService.parseRoleDescriptors(apiKeyIdAndBytes.v1(), apiKeyIdAndBytes.v2()); + final List roleDescriptors = apiKeyService.parseRoleDescriptors( + apiKeyIdAndBytes.v1(), + apiKeyIdAndBytes.v2(), + limitedBy ? ApiKeyService.ApiKeyRoleType.LIMITED_BY : ApiKeyService.ApiKeyRoleType.ASSIGNED + ); buildThenMaybeCacheRole(roleKey, roleDescriptors, Collections.emptySet(), true, invalidationCounter, roleActionListener); } else { roleActionListener.onResponse(existing); 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 d1595a96c2312..1e4f693aeaa55 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 @@ -116,6 +116,7 @@ import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.SECURITY_MAIN_ALIAS; import static org.elasticsearch.xpack.security.Security.SECURITY_CRYPTO_THREAD_POOL_NAME; import static org.elasticsearch.xpack.security.authc.ApiKeyService.API_KEY_METADATA_KEY; +import static org.elasticsearch.xpack.security.authc.ApiKeyService.LEGACY_SUPERUSER_ROLE_DESCRIPTOR; import static org.hamcrest.Matchers.anEmptyMap; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; @@ -561,21 +562,22 @@ public void testValidateApiKey() throws Exception { } public void testGetRolesForApiKeyNotInContext() throws Exception { + final boolean useLegacySuperuserRole = randomBoolean(); + final RoleDescriptor superuserRoleDescriptor = useLegacySuperuserRole + ? LEGACY_SUPERUSER_ROLE_DESCRIPTOR + : SUPERUSER_ROLE_DESCRIPTOR; Map superUserRdMap; try (XContentBuilder builder = JsonXContent.contentBuilder()) { superUserRdMap = XContentHelper.convertToMap( XContentType.JSON.xContent(), - BytesReference.bytes(SUPERUSER_ROLE_DESCRIPTOR.toXContent(builder, ToXContent.EMPTY_PARAMS, true)).streamInput(), + BytesReference.bytes(superuserRoleDescriptor.toXContent(builder, ToXContent.EMPTY_PARAMS, true)).streamInput(), false ); } Map authMetadata = new HashMap<>(); authMetadata.put(ApiKeyService.API_KEY_ID_KEY, randomAlphaOfLength(12)); - authMetadata.put(API_KEY_ROLE_DESCRIPTORS_KEY, Collections.singletonMap(SUPERUSER_ROLE_DESCRIPTOR.getName(), superUserRdMap)); - authMetadata.put( - API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY, - Collections.singletonMap(SUPERUSER_ROLE_DESCRIPTOR.getName(), superUserRdMap) - ); + authMetadata.put(API_KEY_ROLE_DESCRIPTORS_KEY, Collections.singletonMap(superuserRoleDescriptor.getName(), superUserRdMap)); + authMetadata.put(API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY, Collections.singletonMap(superuserRoleDescriptor.getName(), superUserRdMap)); final Authentication authentication = new Authentication( new User("joe"), @@ -591,7 +593,12 @@ public void testGetRolesForApiKeyNotInContext() throws Exception { service.getRoleForApiKey(authentication, roleFuture); ApiKeyRoleDescriptors result = roleFuture.get(); assertThat(result.getRoleDescriptors().size(), is(1)); - assertThat(result.getRoleDescriptors().get(0).getName(), is("superuser")); + // Assigned role descriptor should not change + assertThat(result.getRoleDescriptors().get(0), equalTo(superuserRoleDescriptor)); + + assertThat(result.getLimitedByRoleDescriptors().size(), is(1)); + // Limited role descriptor should always be the updated superuser role descriptor + assertThat(result.getLimitedByRoleDescriptors().get(0), equalTo(SUPERUSER_ROLE_DESCRIPTOR)); } @SuppressWarnings("unchecked") @@ -702,11 +709,11 @@ public void testGetApiKeyIdAndRoleBytes() { public void testParseRoleDescriptors() { ApiKeyService service = createApiKeyService(Settings.EMPTY); final String apiKeyId = randomAlphaOfLength(12); - List roleDescriptors = service.parseRoleDescriptors(apiKeyId, null); + List roleDescriptors = service.parseRoleDescriptors(apiKeyId, null, randomApiKeyRoleType()); assertTrue(roleDescriptors.isEmpty()); BytesReference roleBytes = new BytesArray("{\"a role\": {\"cluster\": [\"all\"]}}"); - roleDescriptors = service.parseRoleDescriptors(apiKeyId, roleBytes); + roleDescriptors = service.parseRoleDescriptors(apiKeyId, roleBytes, randomApiKeyRoleType()); assertEquals(1, roleDescriptors.size()); assertEquals("a role", roleDescriptors.get(0).getName()); assertArrayEquals(new String[] { "all" }, roleDescriptors.get(0).getClusterPrivileges()); @@ -720,12 +727,19 @@ public void testParseRoleDescriptors() { + "\"privileges\":[\"*\"],\"resources\":[\"*\"]}],\"run_as\":[\"*\"],\"metadata\":{\"_reserved\":true}," + "\"transient_metadata\":{}}}\n" ); - roleDescriptors = service.parseRoleDescriptors(apiKeyId, roleBytes); + final ApiKeyService.ApiKeyRoleType apiKeyRoleType = randomApiKeyRoleType(); + roleDescriptors = service.parseRoleDescriptors(apiKeyId, roleBytes, apiKeyRoleType); assertEquals(2, roleDescriptors.size()); assertEquals( Set.of("reporting_user", "superuser"), roleDescriptors.stream().map(RoleDescriptor::getName).collect(Collectors.toSet()) ); + assertThat( + roleDescriptors.get(1), + equalTo( + apiKeyRoleType == ApiKeyService.ApiKeyRoleType.LIMITED_BY ? SUPERUSER_ROLE_DESCRIPTOR : LEGACY_SUPERUSER_ROLE_DESCRIPTOR + ) + ); } public void testApiKeyServiceDisabled() throws Exception { @@ -1122,7 +1136,11 @@ public void testApiKeyDocCache() throws IOException, ExecutionException, Interru final BytesReference limitedByRoleDescriptorsBytes = service.getRoleDescriptorsBytesCache() .get(cachedApiKeyDoc.limitedByRoleDescriptorsHash); assertNotNull(limitedByRoleDescriptorsBytes); - final List limitedByRoleDescriptors = service.parseRoleDescriptors(docId, limitedByRoleDescriptorsBytes); + final List limitedByRoleDescriptors = service.parseRoleDescriptors( + docId, + limitedByRoleDescriptorsBytes, + ApiKeyService.ApiKeyRoleType.LIMITED_BY + ); assertEquals(1, limitedByRoleDescriptors.size()); assertEquals(SUPERUSER_ROLE_DESCRIPTOR, limitedByRoleDescriptors.get(0)); if (metadata == null) { @@ -1695,4 +1713,8 @@ private void checkAuthApiKeyMetadata(Object metadata, AuthenticationResult ); } } + + private ApiKeyService.ApiKeyRoleType randomApiKeyRoleType() { + return randomFrom(ApiKeyService.ApiKeyRoleType.values()); + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java index b7acef5ba4856..ff880128629c1 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java @@ -1620,8 +1620,8 @@ public void testCacheEntryIsReusedForIdenticalApiKeyRoles() { roleFuture.actionGet(); assertThat(effectiveRoleDescriptors.get(), is(nullValue())); verify(apiKeyService, times(2)).getApiKeyIdAndRoleBytes(eq(authentication), anyBoolean()); - verify(apiKeyService).parseRoleDescriptors("key-id-1", roleBytes); - verify(apiKeyService).parseRoleDescriptors("key-id-1", limitedByRoleBytes); + verify(apiKeyService).parseRoleDescriptors("key-id-1", roleBytes, ApiKeyService.ApiKeyRoleType.ASSIGNED); + verify(apiKeyService).parseRoleDescriptors("key-id-1", limitedByRoleBytes, ApiKeyService.ApiKeyRoleType.LIMITED_BY); // Different API key with the same roles should read from cache authentication = new Authentication( @@ -1645,7 +1645,7 @@ public void testCacheEntryIsReusedForIdenticalApiKeyRoles() { roleFuture.actionGet(); assertThat(effectiveRoleDescriptors.get(), is(nullValue())); verify(apiKeyService, times(2)).getApiKeyIdAndRoleBytes(eq(authentication), anyBoolean()); - verify(apiKeyService, never()).parseRoleDescriptors(eq("key-id-2"), any(BytesReference.class)); + verify(apiKeyService, never()).parseRoleDescriptors(eq("key-id-2"), any(BytesReference.class), any()); // Different API key with the same limitedBy role should read from cache, new role should be built final BytesArray anotherRoleBytes = new BytesArray("{\"b role\": {\"cluster\": [\"manage_security\"]}}"); @@ -1670,7 +1670,7 @@ public void testCacheEntryIsReusedForIdenticalApiKeyRoles() { roleFuture.actionGet(); assertThat(effectiveRoleDescriptors.get(), is(nullValue())); verify(apiKeyService).getApiKeyIdAndRoleBytes(eq(authentication), eq(false)); - verify(apiKeyService).parseRoleDescriptors("key-id-3", anotherRoleBytes); + verify(apiKeyService).parseRoleDescriptors("key-id-3", anotherRoleBytes, ApiKeyService.ApiKeyRoleType.ASSIGNED); } private Authentication createAuthentication() { diff --git a/x-pack/qa/full-cluster-restart/src/test/java/org/elasticsearch/xpack/restart/FullClusterRestartIT.java b/x-pack/qa/full-cluster-restart/src/test/java/org/elasticsearch/xpack/restart/FullClusterRestartIT.java index 790b524085e89..a40f5959b2bd2 100644 --- a/x-pack/qa/full-cluster-restart/src/test/java/org/elasticsearch/xpack/restart/FullClusterRestartIT.java +++ b/x-pack/qa/full-cluster-restart/src/test/java/org/elasticsearch/xpack/restart/FullClusterRestartIT.java @@ -17,6 +17,7 @@ import org.elasticsearch.client.RestClient; import org.elasticsearch.cluster.metadata.DataStreamTestHelper; import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.common.xcontent.support.XContentMapValues; @@ -32,6 +33,7 @@ import org.elasticsearch.xcontent.XContentParser; import org.elasticsearch.xcontent.XContentType; import org.elasticsearch.xcontent.json.JsonXContent; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.slm.SnapshotLifecyclePolicy; import org.elasticsearch.xpack.core.slm.SnapshotLifecycleStats; import org.hamcrest.Matcher; @@ -329,6 +331,90 @@ public void testServiceAccountApiKey() throws IOException { } } + public void testApiKeySuperuser() throws IOException { + if (isRunningAgainstOldCluster()) { + final Request createUserRequest = new Request("PUT", "/_security/user/api_key_super_creator"); + createUserRequest.setJsonEntity(""" + { + "password" : "l0ng-r4nd0m-p@ssw0rd", + "roles" : [ "superuser", "monitoring_user" ] + }"""); + client().performRequest(createUserRequest); + + // Create API key + final Request createApiKeyRequest = new Request("PUT", "/_security/api_key"); + createApiKeyRequest.setOptions( + RequestOptions.DEFAULT.toBuilder() + .addHeader( + "Authorization", + UsernamePasswordToken.basicAuthHeaderValue( + "api_key_super_creator", + new SecureString("l0ng-r4nd0m-p@ssw0rd".toCharArray()) + ) + ) + ); + createApiKeyRequest.setJsonEntity(""" + { + "name": "super_legacy_key" + }"""); + final Map createApiKeyResponse = entityAsMap(client().performRequest(createApiKeyRequest)); + final byte[] keyBytes = (createApiKeyResponse.get("id") + ":" + createApiKeyResponse.get("api_key")).getBytes( + StandardCharsets.UTF_8 + ); + final String apiKeyAuthHeader = "ApiKey " + Base64.getEncoder().encodeToString(keyBytes); + // Save the API key info across restart + final Request saveApiKeyRequest = new Request("PUT", "/api_keys/_doc/super_legacy_key"); + saveApiKeyRequest.setJsonEntity("{\"auth_header\":\"" + apiKeyAuthHeader + "\"}"); + assertOK(client().performRequest(saveApiKeyRequest)); + + if (getOldClusterVersion().before(Version.V_8_0_0)) { + final Request indexRequest = new Request("POST", ".security/_doc"); + indexRequest.setJsonEntity(""" + { + "doc_type": "foo" + }"""); + indexRequest.setOptions( + expectWarnings( + "this request accesses system indices: [.security-7], but in a future major " + + "version, direct access to system indices will be prevented by default" + ).toBuilder().addHeader("Authorization", apiKeyAuthHeader) + ); + assertOK(client().performRequest(indexRequest)); + } + } else { + final Request getRequest = new Request("GET", "/api_keys/_doc/super_legacy_key"); + final Map getResponseMap = responseAsMap(client().performRequest(getRequest)); + @SuppressWarnings("unchecked") + final String apiKeyAuthHeader = ((Map) getResponseMap.get("_source")).get("auth_header"); + + // read is ok + final Request searchRequest = new Request("GET", ".security/_search"); + searchRequest.setOptions( + expectWarnings( + "this request accesses system indices: [.security-7], but in a future major " + + "version, direct access to system indices will be prevented by default" + ).toBuilder().addHeader("Authorization", apiKeyAuthHeader) + ); + assertOK(client().performRequest(searchRequest)); + + // write must not be allowed + final Request indexRequest = new Request("POST", ".security/_doc"); + indexRequest.setJsonEntity(""" + { + "doc_type": "foo" + }"""); + indexRequest.setOptions( + expectWarnings( + "this request accesses system indices: [.security-7], but in a future major " + + "version, direct access to system indices will be prevented by default" + ).toBuilder().addHeader("Authorization", apiKeyAuthHeader) + ); + final ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(indexRequest)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat(e.getMessage(), containsString("is unauthorized")); + } + } + /** * Tests that a RollUp job created on a old cluster is correctly restarted after the upgrade. */