Browse Source

Support defining workflows restriction for API keys (#96215)

The changes in this PR extend `RoleDescriptor` to support 
specifying `workflows` `restriction` for API keys:

```
POST /_security/api_key
{
  "name": "my_restricted_api_key",
  "role_descriptors": {
    "my_restricted_role": {
      "indices": [
        {
          "names": ["books", "movies"],
          "privileges": ["read"]
        }
      ],
      "restriction": {
        "workflows": ["my_workflow"]
      }
    }
  }
}
```

The workflows will be defined statically and used to restrict 
the API key's access to only specific REST APIs.

To keep the scope of the PR down, I will address below items in follow up PRs:

- Workflow definition and resolution
- Validation logic to verify that a workflow name is valid
- Blocking workflows restriction to be specified via Roles API
- Blocking file-based definitions of roles with workflows restriction
- Enforcing of workflow restrictions for API keys
Slobodan Adamović 2 years ago
parent
commit
3c5d96d2eb
21 changed files with 899 additions and 59 deletions
  1. 2 1
      server/src/main/java/org/elasticsearch/TransportVersion.java
  2. 2 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/PutRoleRequest.java
  3. 3 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/RoleDescriptorRequestValidator.java
  4. 168 10
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java
  5. 6 3
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/store/ReservedRolesStore.java
  6. 1 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/user/CrossClusterAccessUser.java
  7. 1 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTestHelper.java
  8. 115 13
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptorTests.java
  9. 114 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/RoleRestrictionTests.java
  10. 224 1
      x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java
  11. 13 0
      x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/crossclusteraccess/CrossClusterAccessHeadersForCcsRestIT.java
  12. 4 2
      x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/role/RoleWithRemoteIndicesPrivilegesRestIT.java
  13. 1 1
      x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java
  14. 58 2
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java
  15. 2 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStore.java
  16. 164 2
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java
  17. 1 12
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceTests.java
  18. 2 1
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceIntegTests.java
  19. 6 3
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java
  20. 10 5
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStoreTests.java
  21. 2 1
      x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/ApiKeyBackwardsCompatibilityIT.java

+ 2 - 1
server/src/main/java/org/elasticsearch/TransportVersion.java

@@ -125,12 +125,13 @@ public record TransportVersion(int id) implements Comparable<TransportVersion> {
     public static final TransportVersion V_8_500_002 = registerTransportVersion(8_500_002, "055dd314-ff40-4313-b4c6-9fccddfa42a8");
     public static final TransportVersion V_8_500_003 = registerTransportVersion(8_500_003, "30adbe0c-8614-40dd-81b5-44e9c657bb77");
     public static final TransportVersion V_8_500_004 = registerTransportVersion(8_500_004, "6a00db6a-fd66-42a9-97ea-f6cc53169110");
+    public static final TransportVersion V_8_500_005 = registerTransportVersion(8_500_005, "65370d2a-d936-4383-a2e0-8403f708129b");
 
     /**
      * Reference to the most recent transport version.
      * This should be the transport version with the highest id.
      */
-    public static final TransportVersion CURRENT = V_8_500_004;
+    public static final TransportVersion CURRENT = V_8_500_005;
 
     /**
      * Reference to the earliest compatible transport version to this version of the codebase.

+ 2 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/PutRoleRequest.java

@@ -237,7 +237,8 @@ public class PutRoleRequest extends ActionRequest implements WriteRequest<PutRol
             runAs,
             metadata,
             Collections.emptyMap(),
-            remoteIndicesPrivileges.toArray(new RoleDescriptor.RemoteIndicesPrivileges[0])
+            remoteIndicesPrivileges.toArray(new RoleDescriptor.RemoteIndicesPrivileges[0]),
+            null
         );
     }
 }

+ 3 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/RoleDescriptorRequestValidator.java

@@ -85,6 +85,9 @@ public class RoleDescriptorRequestValidator {
                 validationException
             );
         }
+        if (roleDescriptor.hasWorkflowsRestriction()) {
+            // TODO: Validate workflow names here!
+        }
         return validationException;
     }
 }

+ 168 - 10
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java

@@ -8,6 +8,7 @@ package org.elasticsearch.xpack.core.security.authz;
 
 import org.elasticsearch.ElasticsearchParseException;
 import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.TransportVersion;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.ValidationException;
 import org.elasticsearch.common.bytes.BytesArray;
@@ -55,6 +56,8 @@ import static org.elasticsearch.transport.RemoteClusterPortSettings.TRANSPORT_VE
  */
 public class RoleDescriptor implements ToXContentObject, Writeable {
 
+    public static final TransportVersion WORKFLOWS_RESTRICTION_VERSION = TransportVersion.V_8_500_005;
+
     public static final String ROLE_TYPE = "role";
 
     private final String name;
@@ -64,6 +67,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
     private final ApplicationResourcePrivileges[] applicationPrivileges;
     private final String[] runAs;
     private final RemoteIndicesPrivileges[] remoteIndicesPrivileges;
+    private final Restriction restriction;
     private final Map<String, Object> metadata;
     private final Map<String, Object> transientMetadata;
 
@@ -89,7 +93,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
 
     /**
      * @deprecated Use {@link #RoleDescriptor(String, String[], IndicesPrivileges[], ApplicationResourcePrivileges[],
-     * ConfigurableClusterPrivilege[], String[], Map, Map, RemoteIndicesPrivileges[])}
+     * ConfigurableClusterPrivilege[], String[], Map, Map, RemoteIndicesPrivileges[], Restriction)}
      */
     @Deprecated
     public RoleDescriptor(
@@ -104,7 +108,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
 
     /**
      * @deprecated Use {@link #RoleDescriptor(String, String[], IndicesPrivileges[], ApplicationResourcePrivileges[],
-     * ConfigurableClusterPrivilege[], String[], Map, Map, RemoteIndicesPrivileges[])}
+     * ConfigurableClusterPrivilege[], String[], Map, Map, RemoteIndicesPrivileges[], Restriction)}
      */
     @Deprecated
     public RoleDescriptor(
@@ -115,7 +119,18 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         @Nullable Map<String, Object> metadata,
         @Nullable Map<String, Object> transientMetadata
     ) {
-        this(name, clusterPrivileges, indicesPrivileges, null, null, runAs, metadata, transientMetadata, RemoteIndicesPrivileges.NONE);
+        this(
+            name,
+            clusterPrivileges,
+            indicesPrivileges,
+            null,
+            null,
+            runAs,
+            metadata,
+            transientMetadata,
+            RemoteIndicesPrivileges.NONE,
+            Restriction.NONE
+        );
     }
 
     public RoleDescriptor(
@@ -137,7 +152,8 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
             runAs,
             metadata,
             transientMetadata,
-            RemoteIndicesPrivileges.NONE
+            RemoteIndicesPrivileges.NONE,
+            Restriction.NONE
         );
     }
 
@@ -150,7 +166,8 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         @Nullable String[] runAs,
         @Nullable Map<String, Object> metadata,
         @Nullable Map<String, Object> transientMetadata,
-        @Nullable RemoteIndicesPrivileges[] remoteIndicesPrivileges
+        @Nullable RemoteIndicesPrivileges[] remoteIndicesPrivileges,
+        @Nullable Restriction restriction
     ) {
         this.name = name;
         this.clusterPrivileges = clusterPrivileges != null ? clusterPrivileges : Strings.EMPTY_ARRAY;
@@ -163,6 +180,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
             ? Collections.unmodifiableMap(transientMetadata)
             : Collections.singletonMap("enabled", true);
         this.remoteIndicesPrivileges = remoteIndicesPrivileges != null ? remoteIndicesPrivileges : RemoteIndicesPrivileges.NONE;
+        this.restriction = restriction != null ? restriction : Restriction.NONE;
     }
 
     public RoleDescriptor(StreamInput in) throws IOException {
@@ -184,6 +202,11 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         } else {
             this.remoteIndicesPrivileges = RemoteIndicesPrivileges.NONE;
         }
+        if (in.getTransportVersion().onOrAfter(WORKFLOWS_RESTRICTION_VERSION)) {
+            this.restriction = new Restriction(in);
+        } else {
+            this.restriction = Restriction.NONE;
+        }
     }
 
     public String getName() {
@@ -235,13 +258,26 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
             || hasConfigurableClusterPrivileges()
             || hasApplicationPrivileges()
             || hasRunAs()
-            || hasRemoteIndicesPrivileges();
+            || hasRemoteIndicesPrivileges()
+            || hasWorkflowsRestriction();
     }
 
     public String[] getRunAs() {
         return this.runAs;
     }
 
+    public Restriction getRestriction() {
+        return restriction;
+    }
+
+    public boolean hasRestriction() {
+        return restriction != null && false == restriction.isEmpty();
+    }
+
+    public boolean hasWorkflowsRestriction() {
+        return hasRestriction() && restriction.hasWorkflows();
+    }
+
     public Map<String, Object> getMetadata() {
         return metadata;
     }
@@ -276,7 +312,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         for (RemoteIndicesPrivileges group : remoteIndicesPrivileges) {
             sb.append(group.toString()).append(",");
         }
-        sb.append("]");
+        sb.append("], restriction=").append(restriction);
         sb.append("]");
         return sb.toString();
     }
@@ -295,7 +331,8 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         if (Arrays.equals(applicationPrivileges, that.applicationPrivileges) == false) return false;
         if (metadata.equals(that.getMetadata()) == false) return false;
         if (Arrays.equals(runAs, that.runAs) == false) return false;
-        return Arrays.equals(remoteIndicesPrivileges, that.remoteIndicesPrivileges);
+        if (Arrays.equals(remoteIndicesPrivileges, that.remoteIndicesPrivileges) == false) return false;
+        return restriction.equals(that.restriction);
     }
 
     @Override
@@ -308,6 +345,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         result = 31 * result + Arrays.hashCode(runAs);
         result = 31 * result + metadata.hashCode();
         result = 31 * result + Arrays.hashCode(remoteIndicesPrivileges);
+        result = 31 * result + restriction.hashCode();
         return result;
     }
 
@@ -318,7 +356,8 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
             && applicationPrivileges.length == 0
             && runAs.length == 0
             && metadata.size() == 0
-            && remoteIndicesPrivileges.length == 0;
+            && remoteIndicesPrivileges.length == 0
+            && restriction.isEmpty();
     }
 
     @Override
@@ -358,6 +397,9 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         if (hasRemoteIndicesPrivileges()) {
             builder.xContentList(Fields.REMOTE_INDICES.getPreferredName(), remoteIndicesPrivileges);
         }
+        if (hasRestriction()) {
+            builder.field(Fields.RESTRICTION.getPreferredName(), restriction);
+        }
         return builder.endObject();
     }
 
@@ -377,6 +419,9 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         if (out.getTransportVersion().onOrAfter(TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY_CCS)) {
             out.writeArray(remoteIndicesPrivileges);
         }
+        if (out.getTransportVersion().onOrAfter(WORKFLOWS_RESTRICTION_VERSION)) {
+            restriction.writeTo(out);
+        }
     }
 
     public static RoleDescriptor parse(String name, BytesReference source, boolean allow2xFormat, XContentType xContentType)
@@ -418,6 +463,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         List<ConfigurableClusterPrivilege> configurableClusterPrivileges = Collections.emptyList();
         ApplicationResourcePrivileges[] applicationPrivileges = null;
         String[] runAsUsers = null;
+        Restriction restriction = null;
         Map<String, Object> metadata = null;
         while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
             if (token == XContentParser.Token.FIELD_NAME) {
@@ -457,6 +503,8 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
                     } else if (untrustedRemoteClusterEnabled
                         && Fields.REMOTE_INDICES.match(currentFieldName, parser.getDeprecationHandler())) {
                             remoteIndicesPrivileges = parseRemoteIndices(name, parser);
+                        } else if (Fields.RESTRICTION.match(currentFieldName, parser.getDeprecationHandler())) {
+                            restriction = Restriction.parse(name, parser);
                         } else if (Fields.TYPE.match(currentFieldName, parser.getDeprecationHandler())) {
                             // don't need it
                         } else {
@@ -476,7 +524,8 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
             runAsUsers,
             metadata,
             null,
-            remoteIndicesPrivileges
+            remoteIndicesPrivileges,
+            restriction
         );
     }
 
@@ -1513,8 +1562,115 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
                 }
                 return applicationPrivileges;
             }
+        }
+    }
+
+    public static class Restriction implements Writeable, ToXContentObject {
+
+        public static final Restriction NONE = new Restriction((String[]) null);
+
+        private final String[] workflows;
+
+        public Restriction(String[] workflows) {
+            assert workflows == null || workflows.length > 0 : "workflows cannot be an empty array";
+            this.workflows = workflows;
+        }
+
+        public Restriction(StreamInput in) throws IOException {
+            this(in.readOptionalStringArray());
+        }
+
+        public boolean hasWorkflows() {
+            return workflows != null && workflows.length > 0;
+        }
+
+        public String[] getWorkflows() {
+            return workflows;
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.array(Fields.WORKFLOWS.getPreferredName(), workflows);
+            return builder.endObject();
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            out.writeOptionalStringArray(workflows);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Restriction that = (Restriction) o;
+            return Arrays.equals(workflows, that.workflows);
+        }
+
+        @Override
+        public int hashCode() {
+            return Arrays.hashCode(workflows);
+        }
+
+        public boolean isEmpty() {
+            return workflows == null || workflows.length == 0;
+        }
+
+        @Override
+        public String toString() {
+            StringBuilder sb = new StringBuilder(getClass().getSimpleName()).append("[workflows=[")
+                .append(Strings.arrayToCommaDelimitedString(workflows))
+                .append("]]");
+            return sb.toString();
+        }
+
+        static Restriction parse(String roleName, XContentParser parser) throws IOException {
+            // advance to the START_OBJECT token if needed
+            XContentParser.Token token = parser.currentToken() == null ? parser.nextToken() : parser.currentToken();
+            if (token != XContentParser.Token.START_OBJECT) {
+                throw new ElasticsearchParseException(
+                    "failed to parse restriction for role [{}]. "
+                        + "expected field [{}] value to be an object, but found an element of type [{}]",
+                    roleName,
+                    parser.currentName(),
+                    token
+                );
+            }
+            String currentFieldName = null;
+            String[] workflows = null;
+            while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+                if (token == XContentParser.Token.FIELD_NAME) {
+                    currentFieldName = parser.currentName();
+                } else if (Fields.WORKFLOWS.match(currentFieldName, parser.getDeprecationHandler())) {
+                    workflows = readWorkflowsStringArray(roleName, parser);
+                } else {
+                    throw new ElasticsearchParseException(
+                        "failed to parse restriction for role [{}]. unexpected field [{}]",
+                        roleName,
+                        currentFieldName
+                    );
+                }
+            }
+            if (workflows != null && workflows.length <= 0) {
+                throw new ElasticsearchParseException(
+                    "failed to parse restriction for role [{}]. [{}] cannot be an empty array",
+                    roleName,
+                    Fields.WORKFLOWS
+                );
+            }
+            return new Restriction(workflows);
+        }
 
+        private static String[] readWorkflowsStringArray(String roleName, XContentParser parser) throws IOException {
+            try {
+                return XContentUtils.readStringArray(parser, false);
+            } catch (ElasticsearchParseException e) {
+                // re-wrap in order to add the role name
+                throw new ElasticsearchParseException("failed to parse restriction for role [{}]. {}", e, roleName, e.getMessage());
+            }
         }
+
     }
 
     public interface Fields {
@@ -1539,5 +1695,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
         ParseField METADATA = new ParseField("metadata");
         ParseField TRANSIENT_METADATA = new ParseField("transient_metadata");
         ParseField TYPE = new ParseField("type");
+        ParseField RESTRICTION = new ParseField("restriction");
+        ParseField WORKFLOWS = new ParseField("workflows");
     }
 }

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

@@ -97,7 +97,8 @@ public class ReservedRolesStore implements BiConsumer<Set<String>, ActionListene
                         .build(),
                     "*"
                 ) }
-            : null
+            : null,
+        null
     );
     private static final Map<String, RoleDescriptor> RESERVED_ROLES = initializeReservedRoles();
 
@@ -163,7 +164,8 @@ public class ReservedRolesStore implements BiConsumer<Set<String>, ActionListene
                             getRemoteIndicesReadPrivileges(".monitoring-*"),
                             getRemoteIndicesReadPrivileges("/metrics-(beats|elasticsearch|enterprisesearch|kibana|logstash).*/"),
                             getRemoteIndicesReadPrivileges("metricbeat-*") }
-                        : null
+                        : null,
+                    null
                 )
             )
             .put(
@@ -938,7 +940,8 @@ public class ReservedRolesStore implements BiConsumer<Set<String>, ActionListene
                     getRemoteIndicesReadPrivileges("metrics-apm.*"),
                     getRemoteIndicesReadPrivileges("traces-apm.*"),
                     getRemoteIndicesReadPrivileges("traces-apm-*") }
-                : null
+                : null,
+            null
         );
     }
 

+ 1 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/user/CrossClusterAccessUser.java

@@ -35,6 +35,7 @@ public class CrossClusterAccessUser extends InternalUser {
         null,
         null,
         null,
+        null,
         null
     );
 

+ 1 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTestHelper.java

@@ -312,6 +312,7 @@ public class AuthenticationTestHelper {
                         null,
                         null,
                         null,
+                        null,
                         null
                     )
                 )

+ 115 - 13
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptorTests.java

@@ -48,6 +48,7 @@ import java.util.Set;
 
 import static org.elasticsearch.transport.RemoteClusterPortSettings.TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY_CCS;
 import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder;
+import static org.elasticsearch.xpack.core.security.authz.RoleDescriptor.WORKFLOWS_RESTRICTION_VERSION;
 import static org.hamcrest.Matchers.arrayContaining;
 import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.containsString;
@@ -145,13 +146,13 @@ public class RoleDescriptorTests extends ESTestCase {
                     + ", indicesPrivileges=[IndicesPrivileges[indices=[i1,i2], allowRestrictedIndices=[false], privileges=[read]"
                     + ", field_security=[grant=[body,title], except=null], query={\"match_all\": {}}],]"
                     + ", applicationPrivileges=[ApplicationResourcePrivileges[application=my_app, privileges=[read,write], resources=[*]],]"
-                    + ", runAs=[sudo], metadata=[{}], remoteIndicesPrivileges=[]]"
+                    + ", runAs=[sudo], metadata=[{}], remoteIndicesPrivileges=[], restriction=Restriction[workflows=[]]]"
             )
         );
     }
 
     public void testToXContentRoundtrip() throws Exception {
-        final RoleDescriptor descriptor = randomRoleDescriptor(true, true);
+        final RoleDescriptor descriptor = randomRoleDescriptor(true, true, true);
         final XContentType xContentType = randomFrom(XContentType.values());
         final BytesReference xContentValue = toShuffledXContent(descriptor, xContentType, ToXContent.EMPTY_PARAMS, false);
         final RoleDescriptor parsed = RoleDescriptor.parse(descriptor.getName(), xContentValue, false, xContentType);
@@ -233,7 +234,10 @@ public class RoleDescriptorTests extends ESTestCase {
                   },
                   "clusters": ["*"]
                 }
-              ]
+              ],
+              "restriction":{
+                "workflows": ["search_application", "search_analytics"]
+              }
             }""";
         rd = RoleDescriptor.parse("test", new BytesArray(q), false, XContentType.JSON);
         assertEquals("test", rd.getName());
@@ -244,7 +248,9 @@ public class RoleDescriptorTests extends ESTestCase {
         assertArrayEquals(new String[] { "r1", "*-*" }, rd.getRemoteIndicesPrivileges()[1].remoteClusters());
         assertArrayEquals(new String[] { "*" }, rd.getRemoteIndicesPrivileges()[2].remoteClusters());
         assertArrayEquals(new String[] { "m", "n" }, rd.getRunAs());
-
+        assertThat(rd.hasRestriction(), equalTo(true));
+        assertThat(rd.getRestriction().hasWorkflows(), equalTo(true));
+        assertArrayEquals(new String[] { "search_application", "search_analytics" }, rd.getRestriction().getWorkflows());
         q = """
             {
               "cluster": [ "a", "b" ],
@@ -397,6 +403,44 @@ public class RoleDescriptorTests extends ESTestCase {
             () -> RoleDescriptor.parse("test", new BytesArray(badJson), false, XContentType.JSON)
         );
         assertThat(ex.getMessage(), containsString("not_supported"));
+
+        rd = RoleDescriptor.parse("test_empty_restriction", new BytesArray("""
+            {
+              "index": [{"names": "idx1", "privileges": [ "p1", "p2" ]}],
+              "restriction":{}
+            }"""), false, XContentType.JSON);
+        assertThat(rd.getName(), equalTo("test_empty_restriction"));
+        assertThat(rd.hasRestriction(), equalTo(false));
+        assertThat(rd.hasWorkflowsRestriction(), equalTo(false));
+
+        final ElasticsearchParseException pex1 = expectThrows(
+            ElasticsearchParseException.class,
+            () -> RoleDescriptor.parse("test_null_workflows", new BytesArray("""
+                {
+                  "index": [{"names": ["idx1"], "privileges": [ "p1", "p2" ]}],
+                  "restriction":{"workflows":null}
+                }"""), false, XContentType.JSON)
+        );
+        assertThat(
+            pex1.getMessage(),
+            containsString(
+                "failed to parse restriction for role [test_null_workflows]. could not parse [workflows] field. "
+                    + "expected a string array but found null value instead"
+            )
+        );
+
+        final ElasticsearchParseException pex2 = expectThrows(
+            ElasticsearchParseException.class,
+            () -> RoleDescriptor.parse("test_empty_workflows", new BytesArray("""
+                {
+                  "index": [{"names": ["idx1"], "privileges": [ "p1", "p2" ]}],
+                  "restriction":{"workflows":[]}
+                }"""), false, XContentType.JSON)
+        );
+        assertThat(
+            pex2.getMessage(),
+            containsString("failed to parse restriction for role [test_empty_workflows]. [workflows] cannot be an empty array")
+        );
     }
 
     public void testParsingFieldPermissionsUsesCache() throws IOException {
@@ -446,11 +490,12 @@ public class RoleDescriptorTests extends ESTestCase {
     public void testSerializationForCurrentVersion() throws Exception {
         final TransportVersion version = TransportVersionUtils.randomCompatibleVersion(random());
         final boolean canIncludeRemoteIndices = version.onOrAfter(TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY_CCS);
+        final boolean canIncludeWorkflows = version.onOrAfter(WORKFLOWS_RESTRICTION_VERSION);
         logger.info("Testing serialization with version {}", version);
         BytesStreamOutput output = new BytesStreamOutput();
         output.setTransportVersion(version);
 
-        final RoleDescriptor descriptor = randomRoleDescriptor(true, canIncludeRemoteIndices);
+        final RoleDescriptor descriptor = randomRoleDescriptor(true, canIncludeRemoteIndices, canIncludeWorkflows);
         descriptor.writeTo(output);
         final NamedWriteableRegistry registry = new NamedWriteableRegistry(new XPackClientPlugin().getNamedWriteables());
         StreamInput streamInput = new NamedWriteableAwareStreamInput(
@@ -475,7 +520,7 @@ public class RoleDescriptorTests extends ESTestCase {
         final BytesStreamOutput output = new BytesStreamOutput();
         output.setTransportVersion(version);
 
-        final RoleDescriptor descriptor = randomRoleDescriptor(true, true);
+        final RoleDescriptor descriptor = randomRoleDescriptor(true, true, false);
         descriptor.writeTo(output);
         final NamedWriteableRegistry registry = new NamedWriteableRegistry(new XPackClientPlugin().getNamedWriteables());
         StreamInput streamInput = new NamedWriteableAwareStreamInput(
@@ -497,6 +542,49 @@ public class RoleDescriptorTests extends ESTestCase {
                         descriptor.getRunAs(),
                         descriptor.getMetadata(),
                         descriptor.getTransientMetadata(),
+                        null,
+                        descriptor.getRestriction()
+                    )
+                )
+            );
+        } else {
+            assertThat(descriptor, equalTo(serialized));
+        }
+    }
+
+    public void testSerializationWithWorkflowsRestrictionAndUnsupportedVersions() throws IOException {
+        final TransportVersion versionBeforeWorkflowsRestriction = TransportVersionUtils.getPreviousVersion(WORKFLOWS_RESTRICTION_VERSION);
+        final TransportVersion version = TransportVersionUtils.randomVersionBetween(
+            random(),
+            TransportVersion.V_7_17_0,
+            versionBeforeWorkflowsRestriction
+        );
+        final BytesStreamOutput output = new BytesStreamOutput();
+        output.setTransportVersion(version);
+
+        final RoleDescriptor descriptor = randomRoleDescriptor(true, false, true);
+        descriptor.writeTo(output);
+        final NamedWriteableRegistry registry = new NamedWriteableRegistry(new XPackClientPlugin().getNamedWriteables());
+        StreamInput streamInput = new NamedWriteableAwareStreamInput(
+            ByteBufferStreamInput.wrap(BytesReference.toBytes(output.bytes())),
+            registry
+        );
+        streamInput.setTransportVersion(version);
+        final RoleDescriptor serialized = new RoleDescriptor(streamInput);
+        if (descriptor.hasWorkflowsRestriction()) {
+            assertThat(
+                serialized,
+                equalTo(
+                    new RoleDescriptor(
+                        descriptor.getName(),
+                        descriptor.getClusterPrivileges(),
+                        descriptor.getIndicesPrivileges(),
+                        descriptor.getApplicationPrivileges(),
+                        descriptor.getConditionalClusterPrivileges(),
+                        descriptor.getRunAs(),
+                        descriptor.getMetadata(),
+                        descriptor.getTransientMetadata(),
+                        descriptor.getRemoteIndicesPrivileges(),
                         null
                     )
                 )
@@ -885,11 +973,13 @@ public class RoleDescriptorTests extends ESTestCase {
                 new String[0],
                 new HashMap<>(),
                 new HashMap<>(),
-                new RoleDescriptor.RemoteIndicesPrivileges[0]
+                new RoleDescriptor.RemoteIndicesPrivileges[0],
+                null
             ).isEmpty()
         );
 
         final List<Boolean> booleans = Arrays.asList(
+            randomBoolean(),
             randomBoolean(),
             randomBoolean(),
             randomBoolean(),
@@ -922,7 +1012,8 @@ public class RoleDescriptorTests extends ESTestCase {
             booleans.get(6)
                 ? new RoleDescriptor.RemoteIndicesPrivileges[0]
                 : new RoleDescriptor.RemoteIndicesPrivileges[] {
-                    RoleDescriptor.RemoteIndicesPrivileges.builder("rmt").indices("idx").privileges("foo").build() }
+                    RoleDescriptor.RemoteIndicesPrivileges.builder("rmt").indices("idx").privileges("foo").build() },
+            booleans.get(7) ? null : RoleRestrictionTests.randomWorkflowsRestriction(1, 2)
         );
 
         if (booleans.stream().anyMatch(e -> e.equals(false))) {
@@ -934,8 +1025,18 @@ public class RoleDescriptorTests extends ESTestCase {
 
     public void testHasPrivilegesOtherThanIndex() {
         assertThat(
-            new RoleDescriptor("name", null, randomBoolean() ? null : randomIndicesPrivileges(1, 5), null, null, null, null, null, null)
-                .hasPrivilegesOtherThanIndex(),
+            new RoleDescriptor(
+                "name",
+                null,
+                randomBoolean() ? null : randomIndicesPrivileges(1, 5),
+                null,
+                null,
+                null,
+                null,
+                null,
+                null,
+                null
+            ).hasPrivilegesOtherThanIndex(),
             is(false)
         );
         final RoleDescriptor roleDescriptor = randomRoleDescriptor();
@@ -959,10 +1060,10 @@ public class RoleDescriptorTests extends ESTestCase {
     }
 
     public static RoleDescriptor randomRoleDescriptor(boolean allowReservedMetadata) {
-        return randomRoleDescriptor(allowReservedMetadata, false);
+        return randomRoleDescriptor(allowReservedMetadata, false, false);
     }
 
-    public static RoleDescriptor randomRoleDescriptor(boolean allowReservedMetadata, boolean allowRemoteIndices) {
+    public static RoleDescriptor randomRoleDescriptor(boolean allowReservedMetadata, boolean allowRemoteIndices, boolean allowWorkflows) {
         final RoleDescriptor.RemoteIndicesPrivileges[] remoteIndexPrivileges;
         if (false == allowRemoteIndices || randomBoolean()) {
             remoteIndexPrivileges = null;
@@ -979,7 +1080,8 @@ public class RoleDescriptorTests extends ESTestCase {
             generateRandomStringArray(5, randomIntBetween(2, 8), false, true),
             randomRoleDescriptorMetadata(allowReservedMetadata),
             Map.of(),
-            remoteIndexPrivileges
+            remoteIndexPrivileges,
+            allowWorkflows ? RoleRestrictionTests.randomWorkflowsRestriction(1, 3) : null
         );
     }
 

+ 114 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/RoleRestrictionTests.java

@@ -0,0 +1,114 @@
+/*
+ * 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.authz;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xcontent.ToXContent;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor.Restriction;
+
+import java.io.IOException;
+
+import static org.hamcrest.Matchers.arrayContaining;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+
+public class RoleRestrictionTests extends ESTestCase {
+
+    public void testParse() throws Exception {
+        final String json = """
+            {
+                "workflows": ["search_application", "search_analytics"]
+            }
+            """;
+        Restriction r = Restriction.parse("test_restriction", createJsonParser(json));
+        assertThat(r.getWorkflows(), arrayContaining("search_application", "search_analytics"));
+        assertThat(r.hasWorkflows(), equalTo(true));
+        assertThat(r.isEmpty(), equalTo(false));
+
+        // tests that "restriction": {} is allowed
+        r = Restriction.parse("test_restriction", createJsonParser("{}"));
+        assertThat(r.hasWorkflows(), equalTo(false));
+        assertThat(r.isEmpty(), equalTo(true));
+
+        var e = expectThrows(ElasticsearchParseException.class, () -> Restriction.parse("test_restriction", createJsonParser("""
+             {
+                 "workflows": []
+             }
+            """)));
+        assertThat(
+            e.getMessage(),
+            containsString("failed to parse restriction for role [test_restriction]. [workflows] cannot be an empty array")
+        );
+
+        e = expectThrows(ElasticsearchParseException.class, () -> Restriction.parse("test_restriction", createJsonParser("""
+             {
+                 "workflows": null
+             }
+            """)));
+        assertThat(
+            e.getMessage(),
+            containsString(
+                "failed to parse restriction for role [test_restriction]. could not parse [workflows] field. "
+                    + "expected a string array but found null value instead"
+            )
+        );
+    }
+
+    public void testToXContent() throws Exception {
+        final Restriction restriction = randomWorkflowsRestriction(1, 5);
+        final XContentType xContentType = randomFrom(XContentType.values());
+        final BytesReference xContentValue = toShuffledXContent(restriction, xContentType, ToXContent.EMPTY_PARAMS, false);
+        final XContentParser parser = xContentType.xContent().createParser(XContentParserConfiguration.EMPTY, xContentValue.streamInput());
+        final Restriction parsed = Restriction.parse(randomAlphaOfLengthBetween(3, 6), parser);
+        assertThat(parsed, equalTo(restriction));
+    }
+
+    public void testSerialization() throws IOException {
+        final BytesStreamOutput out = new BytesStreamOutput();
+        final Restriction original = randomWorkflowsRestriction(1, 3);
+        original.writeTo(out);
+
+        StreamInput in = out.bytes().streamInput();
+        final Restriction actual = new Restriction(in);
+
+        assertThat(actual, equalTo(original));
+    }
+
+    public void testIsEmpty() {
+        String[] workflows = null;
+        Restriction r = new Restriction(workflows);
+        assertThat(r.isEmpty(), equalTo(true));
+        assertThat(r.hasWorkflows(), equalTo(false));
+
+        workflows = randomWorkflowNames(1, 2);
+        r = new Restriction(workflows);
+        assertThat(r.isEmpty(), equalTo(false));
+        assertThat(r.hasWorkflows(), equalTo(true));
+    }
+
+    private static XContentParser createJsonParser(String json) throws IOException {
+        return XContentType.JSON.xContent().createParser(XContentParserConfiguration.EMPTY, new BytesArray(json).streamInput());
+    }
+
+    public static Restriction randomWorkflowsRestriction(int min, int max) {
+        return new Restriction(randomWorkflowNames(min, max));
+    }
+
+    public static String[] randomWorkflowNames(int min, int max) {
+        // TODO: Change this to use actual workflow names instead of random ones.
+        return randomArray(min, max, String[]::new, () -> randomAlphaOfLengthBetween(3, 6));
+    }
+}

+ 224 - 1
x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java

@@ -7,6 +7,7 @@
 
 package org.elasticsearch.xpack.security.apikey;
 
+import org.apache.http.client.methods.HttpGet;
 import org.elasticsearch.client.Request;
 import org.elasticsearch.client.RequestOptions;
 import org.elasticsearch.client.Response;
@@ -43,6 +44,7 @@ import static org.elasticsearch.test.SecuritySettingsSourceField.ES_TEST_ROOT_RO
 import static org.elasticsearch.xpack.core.security.authc.AuthenticationServiceField.RUN_AS_USER_HEADER;
 import static org.hamcrest.Matchers.anEmptyMap;
 import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.emptyString;
@@ -1140,6 +1142,226 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         assertThat(e10.getMessage(), containsString("authentication via API key not supported: only the owner user can update an API key"));
     }
 
+    public void testWorkflowsRestrictionSupportForApiKeys() throws IOException {
+        final Request createApiKeyRequest = new Request("POST", "_security/api_key");
+        createApiKeyRequest.setJsonEntity("""
+            {
+                "name": "key1",
+                "role_descriptors":{
+                    "r1": {
+                        "restriction": {
+                            "workflows": ["search_application"]
+                        }
+                    }
+                }
+            }""");
+        Response response = performRequestWithManageOwnApiKeyUser(createApiKeyRequest);
+        String apiKeyId = assertOKAndCreateObjectPath(response).evaluate("id");
+        assertThat(apiKeyId, notNullValue());
+        fetchAndAssertApiKeyContainsWorkflows(apiKeyId, "r1", "search_application");
+
+        final Request grantApiKeyRequest = new Request("POST", "_security/api_key/grant");
+        grantApiKeyRequest.setJsonEntity(Strings.format("""
+            {
+               "grant_type":"password",
+               "username":"%s",
+               "password":"end-user-password",
+               "api_key":{
+                  "name":"key2",
+                  "role_descriptors":{
+                     "r1":{
+                        "restriction": {
+                            "workflows": ["search_application"]
+                        }
+                     }
+                  }
+               }
+            }""", MANAGE_OWN_API_KEY_USER));
+        response = adminClient().performRequest(grantApiKeyRequest);
+        String grantedApiKeyId = assertOKAndCreateObjectPath(response).evaluate("id");
+        fetchAndAssertApiKeyContainsWorkflows(grantedApiKeyId, "r1", "search_application");
+
+        final Request updateApiKeyRequest = new Request("PUT", "_security/api_key/" + apiKeyId);
+        updateApiKeyRequest.setJsonEntity("""
+            {
+              "role_descriptors": {
+                "r1": {
+                  "restriction": {
+                   "workflows": ["search_application", "search_analytics"]
+                  }
+                }
+              }
+            }""");
+        response = performRequestWithManageOwnApiKeyUser(updateApiKeyRequest);
+        assertThat(assertOKAndCreateObjectPath(response).evaluate("updated"), equalTo(true));
+        fetchAndAssertApiKeyContainsWorkflows(apiKeyId, "r1", "search_application", "search_analytics");
+
+        final Request bulkUpdateApiKeyRequest = new Request("POST", "_security/api_key/_bulk_update");
+        bulkUpdateApiKeyRequest.setJsonEntity(Strings.format("""
+            {
+              "ids": ["%s"],
+              "role_descriptors": {
+                "r1": {
+                  "restriction": {
+                     "workflows": ["search_application"]
+                  }
+                }
+              }
+            }""", apiKeyId));
+        response = performRequestWithManageOwnApiKeyUser(bulkUpdateApiKeyRequest);
+        assertThat(assertOKAndCreateObjectPath(response).evaluate("updated"), contains(apiKeyId));
+        fetchAndAssertApiKeyContainsWorkflows(apiKeyId, "r1", "search_application");
+
+        final Request removeRestrictionRequest = new Request("PUT", "_security/api_key/" + apiKeyId);
+        removeRestrictionRequest.setJsonEntity("""
+            {
+              "role_descriptors": {
+                "r1": {
+                }
+              }
+            }""");
+        response = performRequestWithManageOwnApiKeyUser(removeRestrictionRequest);
+        assertThat(assertOKAndCreateObjectPath(response).evaluate("updated"), equalTo(true));
+        fetchAndAssertApiKeyDoesNotContainWorkflows(apiKeyId, "r1");
+    }
+
+    public void testWorkflowsRestrictionValidation() throws IOException {
+        final Request createInvalidApiKeyRequest = new Request("POST", "_security/api_key");
+        final boolean secondRoleWithWorkflowsRestriction = randomBoolean();
+        final String r1 = """
+                "r1": {
+                    "restriction": {
+                        "workflows": ["search_application"]
+                    }
+                }
+            """;
+        final String r2 = secondRoleWithWorkflowsRestriction ? """
+            "r2": {
+                "restriction": {
+                    "workflows": ["search_analytics"]
+                }
+            }
+            """ : """
+            "r2": {}
+            """;
+        createInvalidApiKeyRequest.setJsonEntity(Strings.format("""
+            {
+                "name": "key1",
+                "role_descriptors":{
+                    %s,
+                    %s
+                }
+            }""", r1, r2));
+        var e = expectThrows(ResponseException.class, () -> performRequestWithManageOwnApiKeyUser(createInvalidApiKeyRequest));
+        if (secondRoleWithWorkflowsRestriction) {
+            assertThat(e.getMessage(), containsString("more than one role descriptor with restriction is not supported"));
+        } else {
+            assertThat(e.getMessage(), containsString("combining role descriptors with and without restriction is not supported"));
+        }
+
+        final Request grantApiKeyRequest = new Request("POST", "_security/api_key/grant");
+        grantApiKeyRequest.setJsonEntity(Strings.format("""
+            {
+               "grant_type":"password",
+               "username":"%s",
+               "password":"end-user-password",
+               "api_key":{
+                  "name":"key2",
+                  "role_descriptors":{
+                     %s,
+                     %s
+                  }
+               }
+            }""", MANAGE_OWN_API_KEY_USER, r1, r2));
+        e = expectThrows(ResponseException.class, () -> adminClient().performRequest(grantApiKeyRequest));
+        if (secondRoleWithWorkflowsRestriction) {
+            assertThat(e.getMessage(), containsString("more than one role descriptor with restriction is not supported"));
+        } else {
+            assertThat(e.getMessage(), containsString("combining role descriptors with and without restriction is not supported"));
+        }
+
+        final Request createApiKeyRequest = new Request("POST", "_security/api_key");
+        createApiKeyRequest.setJsonEntity("""
+            {
+                "name": "key1",
+                "role_descriptors":{
+                    "r1": {
+                        "restriction": {
+                            "workflows": ["search_application"]
+                        }
+                    }
+                }
+            }""");
+        Response response = performRequestWithManageOwnApiKeyUser(createApiKeyRequest);
+        assertOK(response);
+        String apiKeyId = ObjectPath.createFromResponse(response).evaluate("id");
+        assertThat(apiKeyId, notNullValue());
+
+        final Request updateApiKeyRequest = new Request("PUT", "_security/api_key/" + apiKeyId);
+        updateApiKeyRequest.setJsonEntity(Strings.format("""
+            {
+                "role_descriptors": {
+                    %s,
+                    %s
+              }
+            }""", r1, r2));
+        e = expectThrows(ResponseException.class, () -> performRequestWithManageOwnApiKeyUser(updateApiKeyRequest));
+        if (secondRoleWithWorkflowsRestriction) {
+            assertThat(e.getMessage(), containsString("more than one role descriptor with restriction is not supported"));
+        } else {
+            assertThat(e.getMessage(), containsString("combining role descriptors with and without restriction is not supported"));
+        }
+
+        final Request bulkUpdateApiKeyRequest = new Request("POST", "_security/api_key/_bulk_update");
+        bulkUpdateApiKeyRequest.setJsonEntity(Strings.format("""
+            {
+                "ids": ["%s"],
+                "role_descriptors": {
+                    %s,
+                    %s
+                }
+            }""", apiKeyId, r1, r2));
+        e = expectThrows(ResponseException.class, () -> performRequestWithManageOwnApiKeyUser(bulkUpdateApiKeyRequest));
+        if (secondRoleWithWorkflowsRestriction) {
+            assertThat(e.getMessage(), containsString("more than one role descriptor with restriction is not supported"));
+        } else {
+            assertThat(e.getMessage(), containsString("combining role descriptors with and without restriction is not supported"));
+        }
+    }
+
+    private Response performRequestWithManageOwnApiKeyUser(Request request) throws IOException {
+        request.setOptions(
+            RequestOptions.DEFAULT.toBuilder()
+                .addHeader("Authorization", headerFromRandomAuthMethod(MANAGE_OWN_API_KEY_USER, END_USER_PASSWORD))
+        );
+        return client().performRequest(request);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void fetchAndAssertApiKeyContainsWorkflows(String apiKeyId, String roleName, String... expectedWorkflows) throws IOException {
+        Response getApiKeyResponse = fetchApiKey(apiKeyId);
+        List<String> actualWorkflows = assertOKAndCreateObjectPath(getApiKeyResponse).evaluate(
+            "api_keys.0.role_descriptors." + roleName + ".restriction.workflows"
+        );
+        assertThat(actualWorkflows, containsInAnyOrder(expectedWorkflows));
+    }
+
+    @SuppressWarnings("unchecked")
+    private void fetchAndAssertApiKeyDoesNotContainWorkflows(String apiKeyId, String roleName) throws IOException {
+        Response getApiKeyResponse = fetchApiKey(apiKeyId);
+        Map<String, ?> restriction = assertOKAndCreateObjectPath(getApiKeyResponse).evaluate(
+            "api_keys.0.role_descriptors." + roleName + ".restriction"
+        );
+        assertThat(restriction, nullValue());
+    }
+
+    private Response fetchApiKey(String apiKeyId) throws IOException {
+        Request getApiKeyRequest = new Request(HttpGet.METHOD_NAME, "_security/api_key?id=" + apiKeyId);
+        Response getApiKeyResponse = adminClient().performRequest(getApiKeyRequest);
+        assertOK(getApiKeyResponse);
+        return getApiKeyResponse;
+    }
+
     private void assertBadCreateCrossClusterApiKeyRequest(String body, String expectedErrorMessage) throws IOException {
         final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key");
         createRequest.setJsonEntity(body);
@@ -1345,7 +1567,8 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
             null,
             null,
             new RoleDescriptor.RemoteIndicesPrivileges[] {
-                RoleDescriptor.RemoteIndicesPrivileges.builder(remoteIndicesClusterAliases).indices("*").privileges("read").build() }
+                RoleDescriptor.RemoteIndicesPrivileges.builder(remoteIndicesClusterAliases).indices("*").privileges("read").build() },
+            null
         );
         getSecurityClient().putRole(role);
     }

+ 13 - 0
x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/crossclusteraccess/CrossClusterAccessHeadersForCcsRestIT.java

@@ -198,6 +198,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                         null,
                         null,
                         null,
+                        null,
                         null
                     )
                 )
@@ -265,6 +266,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                         null,
                         null,
                         null,
+                        null,
                         null
                     )
                 )
@@ -295,6 +297,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         )
@@ -400,6 +403,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         ),
@@ -418,6 +422,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         )
@@ -444,6 +449,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         ),
@@ -465,6 +471,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         )
@@ -543,6 +550,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         ),
@@ -561,6 +569,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         )
@@ -583,6 +592,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         )
@@ -657,6 +667,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         ),
@@ -675,6 +686,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         )
@@ -697,6 +709,7 @@ public class CrossClusterAccessHeadersForCcsRestIT extends SecurityOnTrialLicens
                                 null,
                                 null,
                                 null,
+                                null,
                                 null
                             )
                         )

+ 4 - 2
x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/role/RoleWithRemoteIndicesPrivilegesRestIT.java

@@ -94,7 +94,8 @@ public class RoleWithRemoteIndicesPrivilegesRestIT extends SecurityOnTrialLicens
                         .query("{\"match\":{\"field\":\"a\"}}")
                         .privileges("read")
                         .grantedFields("field")
-                        .build() }
+                        .build() },
+                null
             )
         );
 
@@ -166,7 +167,8 @@ public class RoleWithRemoteIndicesPrivilegesRestIT extends SecurityOnTrialLicens
                         .privileges("read")
                         .query("{\"match\":{\"field\":\"a\"}}")
                         .grantedFields("field")
-                        .build() }
+                        .build() },
+                null
             )
         );
     }

+ 1 - 1
x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java

@@ -2664,7 +2664,7 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
                 new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null),
                 randomValueOtherThanMany(
                     rd -> RoleDescriptorRequestValidator.validate(rd) != null,
-                    () -> RoleDescriptorTests.randomRoleDescriptor(false, allowRemoteIndices)
+                    () -> RoleDescriptorTests.randomRoleDescriptor(false, allowRemoteIndices, false)
                 )
             );
             case 2 -> null;

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

@@ -147,6 +147,7 @@ import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin;
 import static org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder.CCR_CLUSTER_PRIVILEGE_NAMES;
 import static org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder.CCS_AND_CCR_CLUSTER_PRIVILEGE_NAMES;
 import static org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder.CCS_CLUSTER_PRIVILEGE_NAMES;
+import static org.elasticsearch.xpack.core.security.authz.RoleDescriptor.WORKFLOWS_RESTRICTION_VERSION;
 import static org.elasticsearch.xpack.security.Security.SECURITY_CRYPTO_THREAD_POOL_NAME;
 import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_MAIN_ALIAS;
 
@@ -331,6 +332,15 @@ public class ApiKeyService {
                 );
                 return;
             }
+            final IllegalArgumentException workflowsValidationException = validateWorkflowsRestrictionConstraints(
+                transportVersion,
+                request.getRoleDescriptors(),
+                userRoleDescriptors
+            );
+            if (workflowsValidationException != null) {
+                listener.onFailure(workflowsValidationException);
+                return;
+            }
 
             final Set<RoleDescriptor> filteredUserRoleDescriptors = maybeRemoveRemoteIndicesPrivileges(
                 userRoleDescriptors,
@@ -350,6 +360,43 @@ public class ApiKeyService {
         return roleDescriptors != null && roleDescriptors.stream().anyMatch(RoleDescriptor::hasRemoteIndicesPrivileges);
     }
 
+    private static IllegalArgumentException validateWorkflowsRestrictionConstraints(
+        TransportVersion transportVersion,
+        List<RoleDescriptor> requestRoleDescriptors,
+        Set<RoleDescriptor> userRoleDescriptors
+    ) {
+        if (getNumberOfRoleDescriptorsWithRestriction(userRoleDescriptors) > 0L) {
+            return new IllegalArgumentException("owner user role descriptors must not include restriction");
+        }
+        final long numberOfRoleDescriptorsWithRestriction = getNumberOfRoleDescriptorsWithRestriction(requestRoleDescriptors);
+        if (numberOfRoleDescriptorsWithRestriction > 0L) {
+            // creating/updating API keys with restrictions is not allowed in a mixed cluster.
+            if (transportVersion.before(WORKFLOWS_RESTRICTION_VERSION)) {
+                return new IllegalArgumentException(
+                    "all nodes must have transport version ["
+                        + WORKFLOWS_RESTRICTION_VERSION
+                        + "] or higher to support restrictions for API keys"
+                );
+            }
+            // It's only allowed to create/update API keys with a single role descriptor that is restricted.
+            if (numberOfRoleDescriptorsWithRestriction != 1L) {
+                return new IllegalArgumentException("more than one role descriptor with restriction is not supported");
+            }
+            // Combining roles with and without restriction is not allowed either.
+            if (numberOfRoleDescriptorsWithRestriction != requestRoleDescriptors.size()) {
+                return new IllegalArgumentException("combining role descriptors with and without restriction is not supported");
+            }
+        }
+        return null;
+    }
+
+    private static long getNumberOfRoleDescriptorsWithRestriction(Collection<RoleDescriptor> roleDescriptors) {
+        if (roleDescriptors == null || roleDescriptors.isEmpty()) {
+            return 0L;
+        }
+        return roleDescriptors.stream().filter(RoleDescriptor::hasRestriction).count();
+    }
+
     private void createApiKeyAndIndexIt(
         Authentication authentication,
         AbstractCreateApiKeyRequest request,
@@ -421,7 +468,6 @@ public class ApiKeyService {
         assert request.getType() != ApiKey.Type.CROSS_CLUSTER || userRoleDescriptors.isEmpty()
             : "owner user role descriptor must be empty for cross-cluster API keys";
         ensureEnabled();
-
         if (authentication == null) {
             listener.onFailure(new IllegalArgumentException("authentication must be provided"));
             return;
@@ -445,6 +491,15 @@ public class ApiKeyService {
             );
             return;
         }
+        final Exception workflowsValidationException = validateWorkflowsRestrictionConstraints(
+            transportVersion,
+            request.getRoleDescriptors(),
+            userRoleDescriptors
+        );
+        if (workflowsValidationException != null) {
+            listener.onFailure(workflowsValidationException);
+            return;
+        }
 
         final String[] apiKeyIds = request.getIds().toArray(String[]::new);
         final Set<RoleDescriptor> filteredUserRoleDescriptors = maybeRemoveRemoteIndicesPrivileges(
@@ -577,7 +632,8 @@ public class ApiKeyService {
                         roleDescriptor.getRunAs(),
                         roleDescriptor.getMetadata(),
                         roleDescriptor.getTransientMetadata(),
-                        null
+                        null,
+                        roleDescriptor.getRestriction()
                     );
                 }
                 return roleDescriptor;

+ 2 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStore.java

@@ -481,7 +481,8 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
                     roleDescriptor.getRunAs(),
                     roleDescriptor.getMetadata(),
                     transientMap,
-                    roleDescriptor.getRemoteIndicesPrivileges()
+                    roleDescriptor.getRemoteIndicesPrivileges(),
+                    roleDescriptor.getRestriction()
                 );
             } else {
                 return roleDescriptor;

+ 164 - 2
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java

@@ -104,6 +104,7 @@ import org.elasticsearch.xpack.core.security.authc.support.AuthenticationContext
 import org.elasticsearch.xpack.core.security.authc.support.Hasher;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptorTests;
+import org.elasticsearch.xpack.core.security.authz.RoleRestrictionTests;
 import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilege;
 import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver;
 import org.elasticsearch.xpack.core.security.authz.store.RoleReference;
@@ -145,6 +146,7 @@ import java.util.concurrent.Semaphore;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
@@ -158,12 +160,14 @@ import static org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClu
 import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.API_KEY_ID_KEY;
 import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.API_KEY_METADATA_KEY;
 import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.API_KEY_TYPE_KEY;
+import static org.elasticsearch.xpack.core.security.authz.RoleDescriptor.WORKFLOWS_RESTRICTION_VERSION;
 import static org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR;
 import static org.elasticsearch.xpack.core.security.test.TestRestrictedIndices.INTERNAL_SECURITY_MAIN_INDEX_7;
 import static org.elasticsearch.xpack.security.Security.SECURITY_CRYPTO_THREAD_POOL_NAME;
 import static org.elasticsearch.xpack.security.authc.ApiKeyService.LEGACY_SUPERUSER_ROLE_DESCRIPTOR;
 import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_MAIN_ALIAS;
 import static org.hamcrest.Matchers.anEmptyMap;
+import static org.hamcrest.Matchers.arrayContaining;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.containsString;
@@ -1120,6 +1124,36 @@ public class ApiKeyServiceTests extends ESTestCase {
                 apiKeyRoleType == RoleReference.ApiKeyRoleType.LIMITED_BY ? SUPERUSER_ROLE_DESCRIPTOR : LEGACY_SUPERUSER_ROLE_DESCRIPTOR
             )
         );
+
+        // Tests parsing of role descriptor with and without workflows restriction.
+        roleBytes = new BytesArray("""
+                {
+                    "role_with_restriction":{
+                        "indices":[{"names":["books"],"privileges":["read"]}],
+                        "restriction":{"workflows":["search_application"]}
+                    },
+                    "role_without_restriction":{
+                        "indices":[{"names":["movies"],"privileges":["read"]}]
+                    }
+                }
+            """);
+        roleDescriptors = service.parseRoleDescriptorsBytes(apiKeyId, roleBytes, apiKeyRoleType);
+        assertEquals(2, roleDescriptors.size());
+        Map<String, RoleDescriptor> roleDescriptorsByName = roleDescriptors.stream()
+            .collect(Collectors.toMap(RoleDescriptor::getName, Function.identity()));
+        assertEquals(Set.of("role_with_restriction", "role_without_restriction"), roleDescriptorsByName.keySet());
+
+        RoleDescriptor roleWithRestriction = roleDescriptorsByName.get("role_with_restriction");
+        assertThat(roleWithRestriction.hasRestriction(), equalTo(true));
+        assertThat(roleWithRestriction.getRestriction().isEmpty(), equalTo(false));
+        assertThat(roleWithRestriction.getRestriction().hasWorkflows(), equalTo(true));
+        assertThat(roleWithRestriction.getRestriction().getWorkflows(), arrayContaining("search_application"));
+
+        RoleDescriptor roleWithoutRestriction = roleDescriptorsByName.get("role_without_restriction");
+        assertThat(roleWithoutRestriction.hasRestriction(), equalTo(false));
+        assertThat(roleWithoutRestriction.getRestriction().isEmpty(), equalTo(true));
+        assertThat(roleWithoutRestriction.getRestriction().hasWorkflows(), equalTo(false));
+        assertThat(roleWithoutRestriction.getRestriction().getWorkflows(), nullValue());
     }
 
     public void testApiKeyServiceDisabled() throws Exception {
@@ -2276,7 +2310,7 @@ public class ApiKeyServiceTests extends ESTestCase {
     public void testMaybeRemoveRemoteIndicesPrivilegesWithUnsupportedVersion() {
         final String apiKeyId = randomAlphaOfLengthBetween(5, 8);
         final Set<RoleDescriptor> userRoleDescriptors = Set.copyOf(
-            randomList(2, 5, () -> RoleDescriptorTests.randomRoleDescriptor(randomBoolean(), randomBoolean()))
+            randomList(2, 5, () -> RoleDescriptorTests.randomRoleDescriptor(randomBoolean(), randomBoolean(), randomBoolean()))
         );
 
         // Selecting random unsupported version.
@@ -2490,6 +2524,118 @@ public class ApiKeyServiceTests extends ESTestCase {
         assertThat(auth3.getMetadata(), hasEntry(API_KEY_TYPE_KEY, apiKeyDoc3.type.value()));
     }
 
+    public void testCreateOrUpdateApiKeyWithWorkflowsRestrictionForUnsupportedVersion() {
+        final Authentication authentication = AuthenticationTestHelper.builder().build();
+        final ClusterService clusterService = mock(ClusterService.class);
+        when(clusterService.getClusterSettings()).thenReturn(
+            new ClusterSettings(Settings.EMPTY, Set.of(ApiKeyService.DELETE_RETENTION_PERIOD))
+        );
+        final ClusterState clusterState = mock(ClusterState.class);
+        when(clusterService.state()).thenReturn(clusterState);
+        final TransportVersion minTransportVersion = TransportVersionUtils.randomVersionBetween(
+            random(),
+            TransportVersion.MINIMUM_COMPATIBLE,
+            TransportVersionUtils.getPreviousVersion(WORKFLOWS_RESTRICTION_VERSION)
+        );
+        when(clusterState.getMinTransportVersion()).thenReturn(minTransportVersion);
+
+        final ApiKeyService service = new ApiKeyService(
+            Settings.EMPTY,
+            clock,
+            client,
+            securityIndex,
+            clusterService,
+            cacheInvalidatorRegistry,
+            threadPool
+        );
+
+        final List<RoleDescriptor> roleDescriptorsWithWorkflowsRestriction = randomList(
+            1,
+            3,
+            () -> randomRoleDescriptorWithWorkflowsRestriction()
+        );
+
+        final AbstractCreateApiKeyRequest createRequest = mock(AbstractCreateApiKeyRequest.class);
+        when(createRequest.getType()).thenReturn(ApiKey.Type.REST);
+        when(createRequest.getRoleDescriptors()).thenReturn(roleDescriptorsWithWorkflowsRestriction);
+
+        final PlainActionFuture<CreateApiKeyResponse> createFuture = new PlainActionFuture<>();
+        service.createApiKey(authentication, createRequest, Set.of(), createFuture);
+        final IllegalArgumentException e1 = expectThrows(IllegalArgumentException.class, createFuture::actionGet);
+        assertThat(
+            e1.getMessage(),
+            containsString("all nodes must have transport version [8500005] or higher to support restrictions for API keys")
+        );
+
+        final BulkUpdateApiKeyRequest updateRequest = new BulkUpdateApiKeyRequest(
+            randomList(1, 3, () -> randomAlphaOfLengthBetween(3, 5)),
+            roleDescriptorsWithWorkflowsRestriction,
+            Map.of()
+        );
+        final PlainActionFuture<BulkUpdateApiKeyResponse> updateFuture = new PlainActionFuture<>();
+        service.updateApiKeys(authentication, updateRequest, Set.of(), updateFuture);
+        final IllegalArgumentException e2 = expectThrows(IllegalArgumentException.class, createFuture::actionGet);
+        assertThat(
+            e2.getMessage(),
+            containsString("all nodes must have transport version [8500005] or higher to support restrictions for API keys")
+        );
+    }
+
+    public void testValidateOwnerUserRoleDescriptorsWithWorkflowsRestriction() {
+        final Authentication authentication = AuthenticationTestHelper.builder().build();
+        final ClusterService clusterService = mock(ClusterService.class);
+        when(clusterService.getClusterSettings()).thenReturn(
+            new ClusterSettings(Settings.EMPTY, Set.of(ApiKeyService.DELETE_RETENTION_PERIOD))
+        );
+        final ClusterState clusterState = mock(ClusterState.class);
+        when(clusterService.state()).thenReturn(clusterState);
+        final TransportVersion minTransportVersion = TransportVersionUtils.randomVersionBetween(
+            random(),
+            WORKFLOWS_RESTRICTION_VERSION,
+            TransportVersion.CURRENT
+        );
+        when(clusterState.getMinTransportVersion()).thenReturn(minTransportVersion);
+        final ApiKeyService service = new ApiKeyService(
+            Settings.EMPTY,
+            clock,
+            client,
+            securityIndex,
+            clusterService,
+            cacheInvalidatorRegistry,
+            threadPool
+        );
+
+        final Set<RoleDescriptor> userRoleDescriptorsWithWorkflowsRestriction = randomSet(
+            1,
+            2,
+            () -> randomRoleDescriptorWithWorkflowsRestriction()
+        );
+        final List<RoleDescriptor> requestRoleDescriptors = randomList(
+            0,
+            1,
+            () -> RoleDescriptorTests.randomRoleDescriptor(randomBoolean(), randomBoolean(), randomBoolean())
+        );
+
+        final AbstractCreateApiKeyRequest createRequest = mock(AbstractCreateApiKeyRequest.class);
+        when(createRequest.getType()).thenReturn(ApiKey.Type.REST);
+        when(createRequest.getRoleDescriptors()).thenReturn(requestRoleDescriptors);
+
+        final PlainActionFuture<CreateApiKeyResponse> createFuture = new PlainActionFuture<>();
+        service.createApiKey(authentication, createRequest, userRoleDescriptorsWithWorkflowsRestriction, createFuture);
+        final IllegalArgumentException e1 = expectThrows(IllegalArgumentException.class, createFuture::actionGet);
+        assertThat(e1.getMessage(), containsString("owner user role descriptors must not include restriction"));
+
+        final BulkUpdateApiKeyRequest updateRequest = new BulkUpdateApiKeyRequest(
+            randomList(1, 3, () -> randomAlphaOfLengthBetween(3, 5)),
+            requestRoleDescriptors,
+            Map.of()
+        );
+        final PlainActionFuture<BulkUpdateApiKeyResponse> updateFuture = new PlainActionFuture<>();
+        service.updateApiKeys(authentication, updateRequest, userRoleDescriptorsWithWorkflowsRestriction, updateFuture);
+        final IllegalArgumentException e2 = expectThrows(IllegalArgumentException.class, createFuture::actionGet);
+        assertThat(e2.getMessage(), containsString("owner user role descriptors must not include restriction"));
+    }
+
     private static RoleDescriptor randomRoleDescriptorWithRemoteIndexPrivileges() {
         return new RoleDescriptor(
             randomAlphaOfLengthBetween(3, 90),
@@ -2500,7 +2646,23 @@ public class ApiKeyServiceTests extends ESTestCase {
             generateRandomStringArray(5, randomIntBetween(2, 8), false, true),
             RoleDescriptorTests.randomRoleDescriptorMetadata(randomBoolean()),
             Map.of(),
-            RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 3)
+            RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 3),
+            RoleRestrictionTests.randomWorkflowsRestriction(1, 3)
+        );
+    }
+
+    private static RoleDescriptor randomRoleDescriptorWithWorkflowsRestriction() {
+        return new RoleDescriptor(
+            randomAlphaOfLengthBetween(3, 90),
+            randomSubsetOf(ClusterPrivilegeResolver.names()).toArray(String[]::new),
+            RoleDescriptorTests.randomIndicesPrivileges(0, 3),
+            RoleDescriptorTests.randomApplicationPrivileges(),
+            RoleDescriptorTests.randomClusterPrivileges(),
+            generateRandomStringArray(5, randomIntBetween(2, 8), false, true),
+            RoleDescriptorTests.randomRoleDescriptorMetadata(randomBoolean()),
+            Map.of(),
+            null,
+            RoleRestrictionTests.randomWorkflowsRestriction(1, 3)
         );
     }
 

+ 1 - 12
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceTests.java

@@ -157,18 +157,7 @@ public class CrossClusterAccessAuthenticationServiceTests extends ESTestCase {
                 // Invalid internal user
                 AuthenticationTestHelper.builder().internal(InternalUsers.XPACK_USER).build(),
                 new RoleDescriptorsIntersection(
-                    new RoleDescriptor(
-                        "invalid_role",
-                        new String[] { "all" },
-                        null,
-                        null,
-                        null,
-                        null,
-                        null,
-                        null,
-                        null
-
-                    )
+                    new RoleDescriptor("invalid_role", new String[] { "all" }, null, null, null, null, null, null, null, null)
                 )
             )
         );

+ 2 - 1
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceIntegTests.java

@@ -75,7 +75,8 @@ public class AuthorizationServiceIntegTests extends SecurityIntegTestCase {
                             .privileges(shuffledList(List.of("read", "write")))
                             .build(),
                         randomNonEmptySubsetOf(List.of(concreteClusterAlias, "*")).toArray(new String[0])
-                    ) }
+                    ) },
+                null
             )
         );
         final String nodeName = internalCluster().getRandomNodeName();

+ 6 - 3
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java

@@ -956,7 +956,8 @@ public class CompositeRolesStoreTests extends ESTestCase {
             null,
             new RoleDescriptor.RemoteIndicesPrivileges[] {
                 RoleDescriptor.RemoteIndicesPrivileges.builder("remote-*", "remote").indices("abc-*", "xyz-*").privileges("read").build(),
-                RoleDescriptor.RemoteIndicesPrivileges.builder("remote-*").indices("remote-idx-1-*").privileges("read").build(), }
+                RoleDescriptor.RemoteIndicesPrivileges.builder("remote-*").indices("remote-idx-1-*").privileges("read").build(), },
+            null
         );
 
         ConfigurableClusterPrivilege ccp2 = new MockConfigurableClusterPrivilege() {
@@ -983,7 +984,8 @@ public class CompositeRolesStoreTests extends ESTestCase {
             null,
             new RoleDescriptor.RemoteIndicesPrivileges[] {
                 RoleDescriptor.RemoteIndicesPrivileges.builder("*").indices("remote-idx-2-*").privileges("read").build(),
-                RoleDescriptor.RemoteIndicesPrivileges.builder("remote-*").indices("remote-idx-3-*").privileges("read").build() }
+                RoleDescriptor.RemoteIndicesPrivileges.builder("remote-*").indices("remote-idx-3-*").privileges("read").build() },
+            null
         );
 
         FieldPermissionsCache cache = new FieldPermissionsCache(Settings.EMPTY);
@@ -2096,6 +2098,7 @@ public class CompositeRolesStoreTests extends ESTestCase {
                             null,
                             null,
                             null,
+                            null,
                             null
                         )
                     )
@@ -2788,7 +2791,7 @@ public class CompositeRolesStoreTests extends ESTestCase {
         final RoleDescriptor.RemoteIndicesPrivileges[] rips,
         final IndicesPrivileges[] ips
     ) {
-        return new RoleDescriptor(name, null, ips, null, null, null, null, null, rips);
+        return new RoleDescriptor(name, null, ips, null, null, null, null, null, rips, null);
     }
 
     private Role buildRole(final RoleDescriptor... roleDescriptors) {

+ 10 - 5
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStoreTests.java

@@ -119,7 +119,8 @@ public class NativeRolesStoreTests extends ESTestCase {
             generateRandomStringArray(5, randomIntBetween(2, 8), true, true),
             RoleDescriptorTests.randomRoleDescriptorMetadata(ESTestCase.randomBoolean()),
             null,
-            TcpTransport.isUntrustedRemoteClusterEnabled() ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 2) : null
+            TcpTransport.isUntrustedRemoteClusterEnabled() ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 2) : null,
+            null
         );
         assertFalse(flsRole.getTransientMetadata().containsKey("unlicensed_features"));
 
@@ -134,7 +135,8 @@ public class NativeRolesStoreTests extends ESTestCase {
             generateRandomStringArray(5, randomIntBetween(2, 8), true, true),
             RoleDescriptorTests.randomRoleDescriptorMetadata(ESTestCase.randomBoolean()),
             null,
-            TcpTransport.isUntrustedRemoteClusterEnabled() ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 2) : null
+            TcpTransport.isUntrustedRemoteClusterEnabled() ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 2) : null,
+            null
         );
         assertFalse(dlsRole.getTransientMetadata().containsKey("unlicensed_features"));
 
@@ -154,7 +156,8 @@ public class NativeRolesStoreTests extends ESTestCase {
             generateRandomStringArray(5, randomIntBetween(2, 8), true, true),
             RoleDescriptorTests.randomRoleDescriptorMetadata(ESTestCase.randomBoolean()),
             null,
-            TcpTransport.isUntrustedRemoteClusterEnabled() ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 2) : null
+            TcpTransport.isUntrustedRemoteClusterEnabled() ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 2) : null,
+            null
         );
         assertFalse(flsDlsRole.getTransientMetadata().containsKey("unlicensed_features"));
 
@@ -167,7 +170,8 @@ public class NativeRolesStoreTests extends ESTestCase {
             generateRandomStringArray(5, randomIntBetween(2, 8), false, true),
             RoleDescriptorTests.randomRoleDescriptorMetadata(ESTestCase.randomBoolean()),
             null,
-            TcpTransport.isUntrustedRemoteClusterEnabled() ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 2) : null
+            TcpTransport.isUntrustedRemoteClusterEnabled() ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 2) : null,
+            null
         );
         assertFalse(noFlsDlsRole.getTransientMetadata().containsKey("unlicensed_features"));
 
@@ -357,7 +361,8 @@ public class NativeRolesStoreTests extends ESTestCase {
             null,
             null,
             new RoleDescriptor.RemoteIndicesPrivileges[] {
-                RoleDescriptor.RemoteIndicesPrivileges.builder("remote").privileges("read").indices("index").build() }
+                RoleDescriptor.RemoteIndicesPrivileges.builder("remote").privileges("read").indices("index").build() },
+            null
         );
         PlainActionFuture<Boolean> future = new PlainActionFuture<>();
         rolesStore.putRole(putRoleRequest, remoteIndicesRole, future);

+ 2 - 1
x-pack/qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/ApiKeyBackwardsCompatibilityIT.java

@@ -358,7 +358,8 @@ public class ApiKeyBackwardsCompatibilityIT extends AbstractUpgradeTestCase {
             generateRandomStringArray(5, randomIntBetween(2, 8), false, true),
             RoleDescriptorTests.randomRoleDescriptorMetadata(false),
             Map.of(),
-            includeRemoteIndices ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 3, excludedPrivileges) : null
+            includeRemoteIndices ? RoleDescriptorTests.randomRemoteIndicesPrivileges(1, 3, excludedPrivileges) : null,
+            null
         );
     }
 }