Browse Source

[ML] Create inference_user and inference_admin roles (#106371)

Defines new inference_user and inference_admin roles with the 
related cluster privileges manage_inference and monitor_inference.
inference_user can list the models and preform inference, 
inference_admin can do the same plus create and delete models
David Kyle 1 year ago
parent
commit
2087b65523

+ 3 - 1
docs/reference/inference/delete-inference.asciidoc

@@ -17,13 +17,15 @@ own model, use the <<ml-df-trained-models-apis>>.
 ==== {api-request-title}
 
 `DELETE /_inference/<model_id>`
+
 `DELETE /_inference/<task_type>/<model_id>`
 
 [discrete]
 [[delete-inference-api-prereqs]]
 ==== {api-prereq-title}
 
-* Requires the `manage` <<privileges-list-cluster,cluster privilege>>.
+* Requires the `manage_inference` <<privileges-list-cluster,cluster privilege>>
+(the built-in `inference_admin` role grants this privilege)
 
 
 [discrete]

+ 2 - 1
docs/reference/inference/get-inference.asciidoc

@@ -28,7 +28,8 @@ own model, use the <<ml-df-trained-models-apis>>.
 [[get-inference-api-prereqs]]
 ==== {api-prereq-title}
 
-* Requires the `manage` <<privileges-list-cluster,cluster privilege>>.
+* Requires the `monitor_inference` <<privileges-list-cluster,cluster privilege>>
+(the built-in `inference_admin` and `inference_user` roles grant this privilege)
 
 [discrete]
 [[get-inference-api-desc]]

+ 3 - 2
docs/reference/inference/post-inference.asciidoc

@@ -17,6 +17,7 @@ own model, use the <<ml-df-trained-models-apis>>.
 ==== {api-request-title}
 
 `POST /_inference/<model_id>`
+
 `POST /_inference/<task_type>/<model_id>`
 
 
@@ -24,8 +25,8 @@ own model, use the <<ml-df-trained-models-apis>>.
 [[post-inference-api-prereqs]]
 ==== {api-prereq-title}
 
-* Requires the `manage` <<privileges-list-cluster,cluster privilege>>.
-
+* Requires the `monitor_inference` <<privileges-list-cluster,cluster privilege>>
+(the built-in `inference_admin` and `inference_user` roles grant this privilege)
 
 [discrete]
 [[post-inference-api-desc]]

+ 2 - 1
docs/reference/inference/put-inference.asciidoc

@@ -25,7 +25,8 @@ or if you want to use non-NLP models, use the <<ml-df-trained-models-apis>>.
 [[put-inference-api-prereqs]]
 ==== {api-prereq-title}
 
-* Requires the `manage` <<privileges-list-cluster,cluster privilege>>.
+* Requires the `manage_inference` <<privileges-list-cluster,cluster privilege>>
+(the built-in `inference_admin` role grants this privilege)
 
 
 [discrete]

+ 2 - 0
docs/reference/rest-api/security/get-builtin-privileges.asciidoc

@@ -78,6 +78,7 @@ A successful call returns an object with "cluster" and "index" fields.
     "manage_enrich",
     "manage_ilm",
     "manage_index_templates",
+    "manage_inference",
     "manage_ingest_pipelines",
     "manage_logstash_pipelines",
     "manage_ml",
@@ -99,6 +100,7 @@ A successful call returns an object with "cluster" and "index" fields.
     "monitor",
     "monitor_data_frame_transforms",
     "monitor_enrich",
+    "monitor_inference",
     "monitor_ml",
     "monitor_rollup",
     "monitor_snapshot",

+ 8 - 0
docs/reference/security/authorization/built-in-roles.asciidoc

@@ -69,6 +69,14 @@ Grants full access to all features in {kib} (including Solutions) and read-only
 Grants access to manage *all* enrich indices (`.enrich-*`) and *all* operations on
 ingest pipelines.
 
+[[built-in-roles-inference-admin]] `inference_admin`::
+Provides all of the privileges of the `inference_user` role and the full
+use of the {inference} APIs. Grants the `manage_inference` cluster privilege.
+
+[[built-in-roles-inference-user]] `inference_user`::
+Provides the minimum privileges required to view {inference} configurations
+and perform inference. Grants the `monintor_inference` cluster privilege.
+
 [[built-in-roles-ingest-user]] `ingest_admin` ::
 Grants access to manage *all* index templates and *all* ingest pipeline configurations.
 +

+ 18 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java

@@ -97,6 +97,10 @@ public class ClusterPrivilegeResolver {
         GetComponentTemplateAction.NAME,
         GetComposableIndexTemplateAction.NAME
     );
+    private static final Set<String> MONITOR_INFERENCE_PATTERN = Set.of(
+        "cluster:monitor/xpack/inference*",
+        "cluster:monitor/xpack/ml/trained_models/deployment/infer"
+    );
     private static final Set<String> MONITOR_ML_PATTERN = Set.of("cluster:monitor/xpack/ml/*");
     private static final Set<String> MONITOR_TEXT_STRUCTURE_PATTERN = Set.of("cluster:monitor/text_structure/*");
     private static final Set<String> MONITOR_TRANSFORM_PATTERN = Set.of("cluster:monitor/data_frame/*", "cluster:monitor/transform/*");
@@ -110,6 +114,13 @@ public class ClusterPrivilegeResolver {
         "indices:admin/index_template/*"
     );
     private static final Predicate<String> ACTION_MATCHER = Automatons.predicate(ALL_CLUSTER_PATTERN);
+    private static final Set<String> MANAGE_INFERENCE_PATTERN = Set.of(
+        "cluster:admin/xpack/inference/*",
+        "cluster:monitor/xpack/inference*", // no trailing slash to match the POST InferenceAction name
+        "cluster:admin/xpack/ml/trained_models/deployment/start",
+        "cluster:admin/xpack/ml/trained_models/deployment/stop",
+        "cluster:monitor/xpack/ml/trained_models/deployment/infer"
+    );
     private static final Set<String> MANAGE_ML_PATTERN = Set.of("cluster:admin/xpack/ml/*", "cluster:monitor/xpack/ml/*");
     private static final Set<String> MANAGE_TRANSFORM_PATTERN = Set.of(
         "cluster:admin/data_frame/*",
@@ -182,6 +193,10 @@ public class ClusterPrivilegeResolver {
     public static final NamedClusterPrivilege NONE = new ActionClusterPrivilege("none", Set.of(), Set.of());
     public static final NamedClusterPrivilege ALL = new ActionClusterPrivilege("all", ALL_CLUSTER_PATTERN);
     public static final NamedClusterPrivilege MONITOR = new ActionClusterPrivilege("monitor", MONITOR_PATTERN);
+    public static final NamedClusterPrivilege MONITOR_INFERENCE = new ActionClusterPrivilege(
+        "monitor_inference",
+        MONITOR_INFERENCE_PATTERN
+    );
     public static final NamedClusterPrivilege MONITOR_ML = new ActionClusterPrivilege("monitor_ml", MONITOR_ML_PATTERN);
     public static final NamedClusterPrivilege MONITOR_TRANSFORM_DEPRECATED = new ActionClusterPrivilege(
         "monitor_data_frame_transforms",
@@ -199,6 +214,7 @@ public class ClusterPrivilegeResolver {
     public static final NamedClusterPrivilege MONITOR_ROLLUP = new ActionClusterPrivilege("monitor_rollup", MONITOR_ROLLUP_PATTERN);
     public static final NamedClusterPrivilege MONITOR_ENRICH = new ActionClusterPrivilege("monitor_enrich", MONITOR_ENRICH_PATTERN);
     public static final NamedClusterPrivilege MANAGE = new ActionClusterPrivilege("manage", ALL_CLUSTER_PATTERN, ALL_SECURITY_PATTERN);
+    public static final NamedClusterPrivilege MANAGE_INFERENCE = new ActionClusterPrivilege("manage_inference", MANAGE_INFERENCE_PATTERN);
     public static final NamedClusterPrivilege MANAGE_ML = new ActionClusterPrivilege("manage_ml", MANAGE_ML_PATTERN);
     public static final NamedClusterPrivilege MANAGE_TRANSFORM_DEPRECATED = new ActionClusterPrivilege(
         "manage_data_frame_transforms",
@@ -348,6 +364,7 @@ public class ClusterPrivilegeResolver {
             NONE,
             ALL,
             MONITOR,
+            MONITOR_INFERENCE,
             MONITOR_ML,
             MONITOR_TEXT_STRUCTURE,
             MONITOR_TRANSFORM_DEPRECATED,
@@ -356,6 +373,7 @@ public class ClusterPrivilegeResolver {
             MONITOR_ROLLUP,
             MONITOR_ENRICH,
             MANAGE,
+            MANAGE_INFERENCE,
             MANAGE_ML,
             MANAGE_TRANSFORM_DEPRECATED,
             MANAGE_TRANSFORM,

+ 26 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/store/ReservedRolesStore.java

@@ -373,6 +373,32 @@ public class ReservedRolesStore implements BiConsumer<Set<String>, ActionListene
                     null
                 )
             ),
+            entry(
+                "inference_admin",
+                new RoleDescriptor(
+                    "inference_admin",
+                    new String[] { "manage_inference" },
+                    null,
+                    null,
+                    null,
+                    null,
+                    MetadataUtils.DEFAULT_RESERVED_METADATA,
+                    null
+                )
+            ),
+            entry(
+                "inference_user",
+                new RoleDescriptor(
+                    "inference_user",
+                    new String[] { "monitor_inference" },
+                    null,
+                    null,
+                    null,
+                    null,
+                    MetadataUtils.DEFAULT_RESERVED_METADATA,
+                    null
+                )
+            ),
             entry(
                 "machine_learning_user",
                 new RoleDescriptor(

+ 42 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/store/ReservedRolesStoreTests.java

@@ -266,6 +266,8 @@ public class ReservedRolesStoreTests extends ESTestCase {
         assertThat(ReservedRolesStore.isReserved("transport_client"), is(true));
         assertThat(ReservedRolesStore.isReserved("kibana_admin"), is(true));
         assertThat(ReservedRolesStore.isReserved("kibana_user"), is(true));
+        assertThat(ReservedRolesStore.isReserved("inference_admin"), is(true));
+        assertThat(ReservedRolesStore.isReserved("inference_user"), is(true));
         assertThat(ReservedRolesStore.isReserved("ingest_admin"), is(true));
         assertThat(ReservedRolesStore.isReserved("monitoring_user"), is(true));
         assertThat(ReservedRolesStore.isReserved("reporting_user"), is(true));
@@ -3877,6 +3879,46 @@ public class ReservedRolesStoreTests extends ESTestCase {
         assertOnlyReadAllowed(role, ".enrich-foo");
     }
 
+    public void testInferenceAdminRole() {
+        final TransportRequest request = mock(TransportRequest.class);
+        final Authentication authentication = AuthenticationTestHelper.builder().build();
+
+        RoleDescriptor roleDescriptor = ReservedRolesStore.roleDescriptor("inference_admin");
+        assertNotNull(roleDescriptor);
+        assertThat(roleDescriptor.getMetadata(), hasEntry("_reserved", true));
+
+        Role role = Role.buildFromRoleDescriptor(roleDescriptor, new FieldPermissionsCache(Settings.EMPTY), RESTRICTED_INDICES);
+        assertTrue(role.cluster().check("cluster:monitor/xpack/inference", request, authentication));
+        assertTrue(role.cluster().check("cluster:monitor/xpack/inference/get", request, authentication));
+        assertTrue(role.cluster().check("cluster:admin/xpack/inference/put", request, authentication));
+        assertTrue(role.cluster().check("cluster:admin/xpack/inference/delete", request, authentication));
+        assertTrue(role.cluster().check("cluster:monitor/xpack/ml/trained_models/deployment/infer", request, authentication));
+        assertTrue(role.cluster().check("cluster:admin/xpack/ml/trained_models/deployment/start", request, authentication));
+        assertTrue(role.cluster().check("cluster:admin/xpack/ml/trained_models/deployment/stop", request, authentication));
+        assertFalse(role.runAs().check(randomAlphaOfLengthBetween(1, 30)));
+        assertNoAccessAllowed(role, ".inference");
+    }
+
+    public void testInferenceUserRole() {
+        final TransportRequest request = mock(TransportRequest.class);
+        final Authentication authentication = AuthenticationTestHelper.builder().build();
+
+        RoleDescriptor roleDescriptor = ReservedRolesStore.roleDescriptor("inference_user");
+        assertNotNull(roleDescriptor);
+        assertThat(roleDescriptor.getMetadata(), hasEntry("_reserved", true));
+
+        Role role = Role.buildFromRoleDescriptor(roleDescriptor, new FieldPermissionsCache(Settings.EMPTY), RESTRICTED_INDICES);
+        assertTrue(role.cluster().check("cluster:monitor/xpack/inference", request, authentication));
+        assertTrue(role.cluster().check("cluster:monitor/xpack/inference/get", request, authentication));
+        assertFalse(role.cluster().check("cluster:admin/xpack/inference/put", request, authentication));
+        assertFalse(role.cluster().check("cluster:admin/xpack/inference/delete", request, authentication));
+        assertTrue(role.cluster().check("cluster:monitor/xpack/ml/trained_models/deployment/infer", request, authentication));
+        assertFalse(role.cluster().check("cluster:admin/xpack/ml/trained_models/deployment/start", request, authentication));
+        assertFalse(role.cluster().check("cluster:admin/xpack/ml/trained_models/deployment/stop", request, authentication));
+        assertFalse(role.runAs().check(randomAlphaOfLengthBetween(1, 30)));
+        assertNoAccessAllowed(role, ".inference");
+    }
+
     private IndexAbstraction mockIndexAbstraction(String name) {
         IndexAbstraction mock = mock(IndexAbstraction.class);
         when(mock.getName()).thenReturn(name);

+ 129 - 0
x-pack/plugin/inference/qa/inference-service-tests/src/javaRestTest/java/org/elasticsearch/xpack/inference/InferencePermissionsIT.java

@@ -0,0 +1,129 @@
+/*
+ * 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.inference;
+
+import org.apache.http.HttpHost;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.ResponseException;
+import org.elasticsearch.client.RestClient;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.util.concurrent.ThreadContext;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.test.cluster.ElasticsearchCluster;
+import org.elasticsearch.test.cluster.local.distribution.DistributionType;
+import org.elasticsearch.test.rest.ESRestTestCase;
+import org.junit.ClassRule;
+
+import java.io.IOException;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class InferencePermissionsIT extends ESRestTestCase {
+
+    private static final String PASSWORD = "secret-test-password";
+
+    @ClassRule
+    public static ElasticsearchCluster cluster = ElasticsearchCluster.local()
+        .distribution(DistributionType.DEFAULT)
+        .setting("xpack.license.self_generated.type", "trial")
+        .setting("xpack.security.enabled", "true")
+        .plugin("inference-service-test")
+        .user("x_pack_rest_user", "x-pack-test-password")
+        .user("test_inference_admin", PASSWORD, "inference_admin", false)
+        .user("test_inference_user", PASSWORD, "inference_user", false)
+        .user("test_no_privileged", PASSWORD, "", false)
+        .build();
+
+    @Override
+    protected String getTestRestCluster() {
+        return cluster.getHttpAddresses();
+    }
+
+    @Override
+    protected Settings restClientSettings() {
+        // use the privileged users here but not in the tests
+        String token = basicAuthHeaderValue("x_pack_rest_user", new SecureString("x-pack-test-password".toCharArray()));
+        return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build();
+    }
+
+    public void testPermissions() throws IOException {
+        var putRequest = new Request("PUT", "_inference/sparse_embedding/permissions_test");
+        putRequest.setJsonEntity(InferenceBaseRestTest.mockSparseServiceModelConfig());
+        var getAllRequest = new Request("GET", "_inference/sparse_embedding/_all");
+        var deleteRequest = new Request("DELETE", "_inference/sparse_embedding/permissions_test");
+
+        var putModelForTestingInference = new Request("PUT", "_inference/sparse_embedding/model_to_test_user_priv");
+        putModelForTestingInference.setJsonEntity(InferenceBaseRestTest.mockSparseServiceModelConfig());
+
+        var inferRequest = new Request("POST", "_inference/sparse_embedding/model_to_test_user_priv");
+        var bodyBuilder = new StringBuilder("{\"input\": [");
+        for (var in : new String[] { "foo", "bar" }) {
+            bodyBuilder.append('"').append(in).append('"').append(',');
+        }
+        // remove last comma
+        bodyBuilder.deleteCharAt(bodyBuilder.length() - 1);
+        bodyBuilder.append("]}");
+        inferRequest.setJsonEntity(bodyBuilder.toString());
+
+        var deleteInferenceModel = new Request("DELETE", "_inference/sparse_embedding/model_to_test_user_priv");
+
+        try (RestClient inferenceAdminClient = buildClient(inferenceAdminClientSettings(), getClusterHosts().toArray(new HttpHost[0]))) {
+            makeRequest(inferenceAdminClient, putRequest, true);
+            makeRequest(inferenceAdminClient, getAllRequest, true);
+            makeRequest(inferenceAdminClient, deleteRequest, true);
+            // create a model now as the other clients don't have the privilege to do so
+            makeRequest(inferenceAdminClient, putModelForTestingInference, true);
+            makeRequest(inferenceAdminClient, inferRequest, true);
+        }
+
+        try (RestClient inferenceUserClient = buildClient(inferenceUserClientSettings(), getClusterHosts().toArray(new HttpHost[0]))) {
+            makeRequest(inferenceUserClient, putRequest, false);
+            makeRequest(inferenceUserClient, getAllRequest, true);
+            makeRequest(inferenceUserClient, inferRequest, true);
+            makeRequest(inferenceUserClient, deleteInferenceModel, false);
+        }
+
+        try (RestClient unprivilegedClient = buildClient(unprivilegedUserClientSettings(), getClusterHosts().toArray(new HttpHost[0]))) {
+            makeRequest(unprivilegedClient, putRequest, false);
+            makeRequest(unprivilegedClient, getAllRequest, false);
+            makeRequest(unprivilegedClient, inferRequest, false);
+            makeRequest(unprivilegedClient, deleteInferenceModel, false);
+        }
+    }
+
+    private Settings inferenceAdminClientSettings() {
+        String token = basicAuthHeaderValue("test_inference_admin", new SecureString(PASSWORD.toCharArray()));
+        return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build();
+    }
+
+    private Settings inferenceUserClientSettings() {
+        String token = basicAuthHeaderValue("test_inference_user", new SecureString(PASSWORD.toCharArray()));
+        return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build();
+    }
+
+    private Settings unprivilegedUserClientSettings() {
+        String token = basicAuthHeaderValue("test_no_privileged", new SecureString(PASSWORD.toCharArray()));
+        return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build();
+    }
+
+    /*
+     * This makes the given request with the given client. It asserts a 200 response if expectSuccess is true, and asserts an exception
+     * with a 403 response if expectStatus is false.
+     */
+    private void makeRequest(RestClient client, Request request, boolean expectSuccess) throws IOException {
+        if (expectSuccess) {
+            Response response = client.performRequest(request);
+            assertThat(response.getStatusLine().getStatusCode(), equalTo(RestStatus.OK.getStatus()));
+        } else {
+            ResponseException exception = expectThrows(ResponseException.class, () -> client.performRequest(request));
+            assertThat(exception.getResponse().getStatusLine().getStatusCode(), equalTo(RestStatus.FORBIDDEN.getStatus()));
+        }
+    }
+}

+ 1 - 1
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml

@@ -15,5 +15,5 @@ setup:
   # This is fragile - it needs to be updated every time we add a new cluster/index privilege
   # I would much prefer we could just check that specific entries are in the array, but we don't have
   # an assertion for that
-  - length: { "cluster" : 55 }
+  - length: { "cluster" : 57 }
   - length: { "index" : 22 }