Browse Source

Security global privilege for writing profile data of applications (#83728)

This PR adds a new global privilege which can be used to restrict writes
for user profile data. The privilege is configurable for the names of
the top level keys in the profile data maps (`data` and `access`), which
by convetion are "application" names. Lastly it adds such a privilege,
for the `kibana-*` application namespace, to the `kibana_system`
built-in role.

Eg:

```
{
  "global": {
    "application": {
      "manage": {
        "applications": [...]
      }
    },
    "profile": {
      "write": {
          "applications": [...]
        }
      }
    }
}
```

Notes: * for every role there can be only one list of application names
for the write profile privilege, and the list does not support excludes
(and it supports wildcards) * there is no validation that the privilege
refers to valid application names (eg empty application name)
Albert Zaharovits 3 years ago
parent
commit
476240e208
20 changed files with 796 additions and 59 deletions
  1. 5 0
      docs/changelog/83728.yaml
  2. 6 3
      x-pack/docs/en/security/authorization/built-in-roles.asciidoc
  3. 14 7
      x-pack/docs/en/security/authorization/managing-roles.asciidoc
  4. 5 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java
  5. 2 2
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/profile/UpdateProfileDataRequest.java
  6. 19 3
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleDescriptor.java
  7. 2 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ConfigurableClusterPrivilege.java
  8. 131 11
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ConfigurableClusterPrivileges.java
  9. 4 4
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/store/ReservedRolesStore.java
  10. 29 4
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/role/PutRoleRequestTests.java
  11. 9 5
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ConfigurableClusterPrivilegesTests.java
  12. 1 1
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageApplicationPrivilegesTests.java
  13. 270 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/WriteProfileDataPrivilegesTests.java
  14. 86 1
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/store/ReservedRolesStoreTests.java
  15. 5 3
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestGetUserPrivilegesAction.java
  16. 21 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecuritySystemIndices.java
  17. 15 1
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailTests.java
  18. 157 10
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java
  19. 12 2
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/user/RestGetUserPrivilegesActionTests.java
  20. 3 1
      x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/audit/logfile/audited_roles.txt

+ 5 - 0
docs/changelog/83728.yaml

@@ -0,0 +1,5 @@
+pr: 83728
+summary: Security global privilege for updating profile data of applications
+area: Authorization
+type: enhancement
+issues: []

+ 6 - 3
x-pack/docs/en/security/authorization/built-in-roles.asciidoc

@@ -84,8 +84,11 @@ This role does not have access to editing tools in {kib}.
 [[built-in-roles-kibana-system]] `kibana_system` ::
 Grants access necessary for the {kib} system user to read from and write to the
 {kib} indices, manage index templates and tokens, and check the availability of
-the {es} cluster. This role grants read access to the `.monitoring-*` indices
-and read and write access to the `.reporting-*` indices. For more information,
+the {es} cluster. It also permits
+<<security-user-profile-apis,activating, searching, and retrieving user profiles>>,
+as well as updating user profile data for the `kibana-*` namespace.
+This role grants read access to the `.monitoring-*` indices and read and write
+access to the `.reporting-*` indices. For more information,
 see {kibana-ref}/using-kibana-with-security.html[Configuring Security in {kib}].
 +
 NOTE: This role should not be assigned to users as the granted permissions may
@@ -172,7 +175,7 @@ Grants full access to cluster management and data indices. This role also grants
 direct read-only access to restricted indices like `.security`. A user with the
 `superuser` role can <<run-as-privilege, impersonate>> any other user in the system.
 +
-On {ecloud}, all standard users, including those with the `superuser` role are 
+On {ecloud}, all standard users, including those with the `superuser` role are
 restricted from performing <<operator-only-functionality,operator-only>> actions.
 +
 IMPORTANT: This role can manage security and create roles with unlimited privileges.

+ 14 - 7
x-pack/docs/en/security/authorization/managing-roles.asciidoc

@@ -101,25 +101,32 @@ multiple data streams, indices, and aliases.
 
 [[roles-global-priv]]
 ==== Global Privileges
-The following describes the structure of a global privileges entry:
+The following describes the structure of the global privileges entry:
 
 [source,js]
 -------
 {
   "application": {
     "manage": {    <1>
-        "applications": [ ... ] <2>
+      "applications": [ ... ] <2>
+    }
+  },
+  "profile": {
+    "write": { <3>
+      "applications": [ ... ] <4>
     }
   }
 }
 -------
 // NOTCONSOLE
 
-<1> The only supported global privilege is the ability to manage application
-    privileges
+<1> The privilege for the ability to manage application privileges
 <2> The list of application names that may be managed. This list supports
     wildcards (e.g. `"myapp-*"`) and regular expressions (e.g.
     `"/app[0-9]*/"`)
+<3> The privilege for the ability to write the `access` and `data` of any user profile
+<4> The list of names, wildcards and regular expressions to which the write
+privilege is restricted to
 
 [[roles-application-priv]]
 ==== Application Privileges
@@ -195,7 +202,7 @@ see <<custom-roles-authorization>>.
 === Role management UI
 
 You can manage users and roles easily in {kib}. To
-manage roles, log in to {kib} and go to *Management / Security / Roles*. 
+manage roles, log in to {kib} and go to *Management / Security / Roles*.
 
 [discrete]
 [[roles-management-api]]
@@ -203,8 +210,8 @@ manage roles, log in to {kib} and go to *Management / Security / Roles*.
 
 The _Role Management APIs_ enable you to add, update, remove and retrieve roles
 dynamically. When you use the APIs to manage roles in the `native` realm, the
-roles are stored in an internal {es} index. For more information and examples, 
-see <<security-role-apis>>. 
+roles are stored in an internal {es} index. For more information and examples,
+see <<security-role-apis>>.
 
 [discrete]
 [[roles-management-file]]

+ 5 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java

@@ -445,6 +445,11 @@ public class XPackClientPlugin extends Plugin implements ActionPlugin, NetworkPl
                     ConfigurableClusterPrivileges.ManageApplicationPrivileges.WRITEABLE_NAME,
                     ConfigurableClusterPrivileges.ManageApplicationPrivileges::createFrom
                 ),
+                new NamedWriteableRegistry.Entry(
+                    ConfigurableClusterPrivilege.class,
+                    ConfigurableClusterPrivileges.WriteProfileDataPrivileges.WRITEABLE_NAME,
+                    ConfigurableClusterPrivileges.WriteProfileDataPrivileges::createFrom
+                ),
                 // security : role-mappings
                 new NamedWriteableRegistry.Entry(RoleMapperExpression.class, AllExpression.NAME, AllExpression::new),
                 new NamedWriteableRegistry.Entry(RoleMapperExpression.class, AnyExpression.NAME, AnyExpression::new),

+ 2 - 2
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/profile/UpdateProfileDataRequest.java

@@ -81,7 +81,7 @@ public class UpdateProfileDataRequest extends ActionRequest {
         return refreshPolicy;
     }
 
-    public Set<String> applicationNames() {
+    public Set<String> getApplicationNames() {
         final Set<String> names = new HashSet<>(access.keySet());
         names.addAll(data.keySet());
         return Set.copyOf(names);
@@ -90,7 +90,7 @@ public class UpdateProfileDataRequest extends ActionRequest {
     @Override
     public ActionRequestValidationException validate() {
         ActionRequestValidationException validationException = null;
-        final Set<String> applicationNames = applicationNames();
+        final Set<String> applicationNames = getApplicationNames();
         if (applicationNames.isEmpty()) {
             validationException = addValidationError("update request is empty", validationException);
         }

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

@@ -37,6 +37,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -110,9 +111,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
     ) {
         this.name = name;
         this.clusterPrivileges = clusterPrivileges != null ? clusterPrivileges : Strings.EMPTY_ARRAY;
-        this.configurableClusterPrivileges = configurableClusterPrivileges != null
-            ? configurableClusterPrivileges
-            : ConfigurableClusterPrivileges.EMPTY_ARRAY;
+        this.configurableClusterPrivileges = sortConfigurableClusterPrivileges(configurableClusterPrivileges);
         this.indicesPrivileges = indicesPrivileges != null ? indicesPrivileges : IndicesPrivileges.NONE;
         this.applicationPrivileges = applicationPrivileges != null ? applicationPrivileges : ApplicationResourcePrivileges.NONE;
         this.runAs = runAs != null ? runAs : Strings.EMPTY_ARRAY;
@@ -669,6 +668,23 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
             .build();
     }
 
+    private static ConfigurableClusterPrivilege[] sortConfigurableClusterPrivileges(
+        ConfigurableClusterPrivilege[] configurableClusterPrivileges
+    ) {
+        if (null == configurableClusterPrivileges) {
+            return ConfigurableClusterPrivileges.EMPTY_ARRAY;
+        } else if (configurableClusterPrivileges.length < 2) {
+            return configurableClusterPrivileges;
+        } else {
+            ConfigurableClusterPrivilege[] configurableClusterPrivilegesCopy = Arrays.copyOf(
+                configurableClusterPrivileges,
+                configurableClusterPrivileges.length
+            );
+            Arrays.sort(configurableClusterPrivilegesCopy, Comparator.comparingInt(o -> o.getCategory().ordinal()));
+            return configurableClusterPrivilegesCopy;
+        }
+    }
+
     private static void checkIfExceptFieldsIsSubsetOfGrantedFields(String roleName, String[] grantedFields, String[] deniedFields) {
         try {
             FieldPermissions.buildPermittedFieldsAutomaton(grantedFields, deniedFields);

+ 2 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ConfigurableClusterPrivilege.java

@@ -40,7 +40,8 @@ public interface ConfigurableClusterPrivilege extends NamedWriteable, ToXContent
      * from the categories.
      */
     enum Category {
-        APPLICATION(new ParseField("application"));
+        APPLICATION(new ParseField("application")),
+        PROFILE(new ParseField("profile"));
 
         public final ParseField field;
 

+ 131 - 11
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ConfigurableClusterPrivileges.java

@@ -18,6 +18,8 @@ import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentParseException;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xpack.core.security.action.privilege.ApplicationPrivilegesRequest;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataAction;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
 import org.elasticsearch.xpack.core.security.authz.permission.ClusterPermission;
 import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivilege.Category;
 import org.elasticsearch.xpack.core.security.support.StringMatcher;
@@ -30,6 +32,7 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.function.Predicate;
 
@@ -94,13 +97,25 @@ public final class ConfigurableClusterPrivileges {
         while (parser.nextToken() != XContentParser.Token.END_OBJECT) {
             expectedToken(parser.currentToken(), parser, XContentParser.Token.FIELD_NAME);
 
-            expectFieldName(parser, Category.APPLICATION.field);
-            expectedToken(parser.nextToken(), parser, XContentParser.Token.START_OBJECT);
-            expectedToken(parser.nextToken(), parser, XContentParser.Token.FIELD_NAME);
+            expectFieldName(parser, Category.APPLICATION.field, Category.PROFILE.field);
+            if (Category.APPLICATION.field.match(parser.currentName(), parser.getDeprecationHandler())) {
+                expectedToken(parser.nextToken(), parser, XContentParser.Token.START_OBJECT);
+                while (parser.nextToken() != XContentParser.Token.END_OBJECT) {
+                    expectedToken(parser.currentToken(), parser, XContentParser.Token.FIELD_NAME);
 
-            expectFieldName(parser, ManageApplicationPrivileges.Fields.MANAGE);
-            privileges.add(ManageApplicationPrivileges.parse(parser));
-            expectedToken(parser.nextToken(), parser, XContentParser.Token.END_OBJECT);
+                    expectFieldName(parser, ManageApplicationPrivileges.Fields.MANAGE);
+                    privileges.add(ManageApplicationPrivileges.parse(parser));
+                }
+            } else {
+                assert Category.PROFILE.field.match(parser.currentName(), parser.getDeprecationHandler());
+                expectedToken(parser.nextToken(), parser, XContentParser.Token.START_OBJECT);
+                while (parser.nextToken() != XContentParser.Token.END_OBJECT) {
+                    expectedToken(parser.currentToken(), parser, XContentParser.Token.FIELD_NAME);
+
+                    expectFieldName(parser, WriteProfileDataPrivileges.Fields.WRITE);
+                    privileges.add(WriteProfileDataPrivileges.parse(parser));
+                }
+            }
         }
 
         return privileges;
@@ -131,6 +146,114 @@ public final class ConfigurableClusterPrivileges {
         }
     }
 
+    /**
+     * The {@link WriteProfileDataPrivileges} privilege is a {@link ConfigurableClusterPrivilege} that grants the
+     * ability to write the {@code data} and {@code access} sections of any user profile.
+     * The privilege is namespace configurable such that only specific top-level keys in the {@code data} and {@code access}
+     * dictionary permit writes (wildcards and regexps are supported, but exclusions are not).
+     */
+    public static class WriteProfileDataPrivileges implements ConfigurableClusterPrivilege {
+        public static final String WRITEABLE_NAME = "write-profile-data-privileges";
+
+        private final Set<String> applicationNames;
+        private final Predicate<String> applicationPredicate;
+        private final Predicate<TransportRequest> requestPredicate;
+
+        public WriteProfileDataPrivileges(Set<String> applicationNames) {
+            this.applicationNames = Collections.unmodifiableSet(applicationNames);
+            this.applicationPredicate = StringMatcher.of(applicationNames);
+            this.requestPredicate = request -> {
+                if (request instanceof final UpdateProfileDataRequest updateProfileRequest) {
+                    assert null == updateProfileRequest.validate();
+                    final Collection<String> requestApplicationNames = updateProfileRequest.getApplicationNames();
+                    return requestApplicationNames.stream().allMatch(application -> applicationPredicate.test(application));
+                }
+                return false;
+            };
+        }
+
+        @Override
+        public Category getCategory() {
+            return Category.PROFILE;
+        }
+
+        public Collection<String> getApplicationNames() {
+            return this.applicationNames;
+        }
+
+        @Override
+        public String getWriteableName() {
+            return WRITEABLE_NAME;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            out.writeCollection(this.applicationNames, StreamOutput::writeString);
+        }
+
+        public static WriteProfileDataPrivileges createFrom(StreamInput in) throws IOException {
+            final Set<String> applications = in.readSet(StreamInput::readString);
+            return new WriteProfileDataPrivileges(applications);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            return builder.field(Fields.WRITE.getPreferredName(), Map.of(Fields.APPLICATIONS.getPreferredName(), applicationNames));
+        }
+
+        public static WriteProfileDataPrivileges parse(XContentParser parser) throws IOException {
+            expectedToken(parser.currentToken(), parser, XContentParser.Token.FIELD_NAME);
+            expectFieldName(parser, Fields.WRITE);
+            expectedToken(parser.nextToken(), parser, XContentParser.Token.START_OBJECT);
+            expectedToken(parser.nextToken(), parser, XContentParser.Token.FIELD_NAME);
+            expectFieldName(parser, Fields.APPLICATIONS);
+            expectedToken(parser.nextToken(), parser, XContentParser.Token.START_ARRAY);
+            final String[] applications = XContentUtils.readStringArray(parser, false);
+            expectedToken(parser.nextToken(), parser, XContentParser.Token.END_OBJECT);
+            return new WriteProfileDataPrivileges(new LinkedHashSet<>(Arrays.asList(applications)));
+        }
+
+        @Override
+        public String toString() {
+            return "{"
+                + getCategory()
+                + ":"
+                + Fields.WRITE.getPreferredName()
+                + ":"
+                + Fields.APPLICATIONS.getPreferredName()
+                + "="
+                + Strings.collectionToDelimitedString(applicationNames, ",")
+                + "}";
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o == null || getClass() != o.getClass()) {
+                return false;
+            }
+            final WriteProfileDataPrivileges that = (WriteProfileDataPrivileges) o;
+            return this.applicationNames.equals(that.applicationNames);
+        }
+
+        @Override
+        public int hashCode() {
+            return applicationNames.hashCode();
+        }
+
+        @Override
+        public ClusterPermission.Builder buildPermission(ClusterPermission.Builder builder) {
+            return builder.add(this, Set.of(UpdateProfileDataAction.NAME), requestPredicate);
+        }
+
+        private interface Fields {
+            ParseField WRITE = new ParseField("write");
+            ParseField APPLICATIONS = new ParseField("applications");
+        }
+    }
+
     /**
      * The {@code ManageApplicationPrivileges} privilege is a {@link ConfigurableClusterPrivilege} that grants the
      * ability to execute actions related to the management of application privileges (Get, Put, Delete) for a subset
@@ -164,7 +287,7 @@ public final class ConfigurableClusterPrivileges {
         }
 
         public Collection<String> getApplicationNames() {
-            return Collections.unmodifiableCollection(this.applicationNames);
+            return this.applicationNames;
         }
 
         @Override
@@ -184,10 +307,7 @@ public final class ConfigurableClusterPrivileges {
 
         @Override
         public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
-            return builder.field(
-                Fields.MANAGE.getPreferredName(),
-                Collections.singletonMap(Fields.APPLICATIONS.getPreferredName(), applicationNames)
-            );
+            return builder.field(Fields.MANAGE.getPreferredName(), Map.of(Fields.APPLICATIONS.getPreferredName(), applicationNames));
         }
 
         public static ManageApplicationPrivileges parse(XContentParser parser) throws IOException {

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

@@ -22,10 +22,10 @@ import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyActio
 import org.elasticsearch.xpack.core.security.action.privilege.GetBuiltinPrivilegesAction;
 import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileAction;
 import org.elasticsearch.xpack.core.security.action.profile.GetProfileAction;
-import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataAction;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivilege;
 import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivileges.ManageApplicationPrivileges;
+import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivileges.WriteProfileDataPrivileges;
 import org.elasticsearch.xpack.core.security.support.MetadataUtils;
 import org.elasticsearch.xpack.core.security.user.KibanaSystemUser;
 import org.elasticsearch.xpack.core.security.user.UsernamesField;
@@ -667,8 +667,6 @@ public class ReservedRolesStore implements BiConsumer<Set<String>, ActionListene
                 "delegate_pki",
                 GetProfileAction.NAME,
                 ActivateProfileAction.NAME,
-                // TODO: this cluster action will be replaced with a special privilege that grants write access to a subset of namespaces
-                UpdateProfileDataAction.NAME,
                 // To facilitate ML UI functionality being controlled using Kibana security privileges
                 "manage_ml",
                 // The symbolic constant for this one is in SecurityActionMapper, so not accessible from X-Pack core
@@ -780,7 +778,9 @@ public class ReservedRolesStore implements BiConsumer<Set<String>, ActionListene
                     .privileges("create_index", "delete_index", "read", "index")
                     .build(), },
             null,
-            new ConfigurableClusterPrivilege[] { new ManageApplicationPrivileges(Collections.singleton("kibana-*")) },
+            new ConfigurableClusterPrivilege[] {
+                new ManageApplicationPrivileges(Set.of("kibana-*")),
+                new WriteProfileDataPrivileges(Set.of("kibana-*")) },
             null,
             MetadataUtils.DEFAULT_RESERVED_METADATA,
             null

+ 29 - 4
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/role/PutRoleRequestTests.java

@@ -21,6 +21,7 @@ import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.VersionUtils;
 import org.elasticsearch.xpack.core.XPackClientPlugin;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor.ApplicationResourcePrivileges;
+import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivilege;
 import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivileges;
 
 import java.io.IOException;
@@ -183,10 +184,34 @@ public class PutRoleRequestTests extends ESTestCase {
                 .build();
         }
         request.addApplicationPrivileges(applicationPrivileges);
-
-        if (randomBoolean()) {
-            final String[] appNames = randomArray(1, 4, String[]::new, stringWithInitialLowercase);
-            request.conditionalCluster(new ConfigurableClusterPrivileges.ManageApplicationPrivileges(Sets.newHashSet(appNames)));
+        switch (randomIntBetween(0, 3)) {
+            case 0:
+                request.conditionalCluster(new ConfigurableClusterPrivilege[0]);
+                break;
+            case 1:
+                request.conditionalCluster(
+                    new ConfigurableClusterPrivileges.ManageApplicationPrivileges(
+                        Sets.newHashSet(randomArray(0, 3, String[]::new, stringWithInitialLowercase))
+                    )
+                );
+                break;
+            case 2:
+                request.conditionalCluster(
+                    new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(
+                        Sets.newHashSet(randomArray(0, 3, String[]::new, stringWithInitialLowercase))
+                    )
+                );
+                break;
+            case 3:
+                request.conditionalCluster(
+                    new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(
+                        Sets.newHashSet(randomArray(0, 3, String[]::new, stringWithInitialLowercase))
+                    ),
+                    new ConfigurableClusterPrivileges.ManageApplicationPrivileges(
+                        Sets.newHashSet(randomArray(0, 3, String[]::new, stringWithInitialLowercase))
+                    )
+                );
+                break;
         }
 
         request.runAs(generateRandomStringArray(4, 3, false, true));

+ 9 - 5
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ConfigurableClusterPrivilegesTests.java

@@ -62,10 +62,14 @@ public class ConfigurableClusterPrivilegesTests extends ESTestCase {
     }
 
     private ConfigurableClusterPrivilege[] buildSecurityPrivileges() {
-        return buildSecurityPrivileges(randomIntBetween(4, 7));
-    }
-
-    private ConfigurableClusterPrivilege[] buildSecurityPrivileges(int applicationNameLength) {
-        return new ConfigurableClusterPrivilege[] { ManageApplicationPrivilegesTests.buildPrivileges(applicationNameLength) };
+        return switch (randomIntBetween(0, 3)) {
+            case 0 -> new ConfigurableClusterPrivilege[0];
+            case 1 -> new ConfigurableClusterPrivilege[] { ManageApplicationPrivilegesTests.buildPrivileges() };
+            case 2 -> new ConfigurableClusterPrivilege[] { WriteProfileDataPrivilegesTests.buildPrivileges() };
+            case 3 -> new ConfigurableClusterPrivilege[] {
+                ManageApplicationPrivilegesTests.buildPrivileges(),
+                WriteProfileDataPrivilegesTests.buildPrivileges() };
+            default -> throw new IllegalStateException("Unexpected value");
+        };
     }
 }

+ 1 - 1
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageApplicationPrivilegesTests.java

@@ -149,7 +149,7 @@ public class ManageApplicationPrivilegesTests extends ESTestCase {
         return new ManageApplicationPrivileges(new LinkedHashSet<>(original.getApplicationNames()));
     }
 
-    private ManageApplicationPrivileges buildPrivileges() {
+    static ManageApplicationPrivileges buildPrivileges() {
         return buildPrivileges(randomIntBetween(4, 7));
     }
 

+ 270 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/WriteProfileDataPrivilegesTests.java

@@ -0,0 +1,270 @@
+/*
+ * 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.privilege;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.action.support.WriteRequest;
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.NamedWriteableAwareStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.util.set.Sets;
+import org.elasticsearch.common.xcontent.LoggingDeprecationHandler;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.EqualsHashCodeTestUtils;
+import org.elasticsearch.transport.TransportRequest;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xcontent.ToXContent;
+import org.elasticsearch.xcontent.XContent;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParseException;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.core.XPackClientPlugin;
+import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileAction;
+import org.elasticsearch.xpack.core.security.action.profile.GetProfileAction;
+import org.elasticsearch.xpack.core.security.action.profile.SearchProfilesAction;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataAction;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.core.security.authz.permission.ClusterPermission;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.elasticsearch.xcontent.DeprecationHandler.THROW_UNSUPPORTED_OPERATION;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.mockito.Mockito.mock;
+
+public class WriteProfileDataPrivilegesTests extends ESTestCase {
+
+    public void testSerialization() throws Exception {
+        final ConfigurableClusterPrivileges.WriteProfileDataPrivileges original = buildPrivileges();
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            original.writeTo(out);
+            final NamedWriteableRegistry registry = new NamedWriteableRegistry(new XPackClientPlugin().getNamedWriteables());
+            try (StreamInput in = new NamedWriteableAwareStreamInput(out.bytes().streamInput(), registry)) {
+                final ConfigurableClusterPrivileges.WriteProfileDataPrivileges copy =
+                    ConfigurableClusterPrivileges.WriteProfileDataPrivileges.createFrom(in);
+                assertThat(copy, equalTo(original));
+                assertThat(original, equalTo(copy));
+            }
+        }
+    }
+
+    public void testGenerateAndParseXContent() throws Exception {
+        final XContent xContent = randomFrom(XContentType.values()).xContent();
+        try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            final XContentBuilder builder = new XContentBuilder(xContent, out);
+
+            final ConfigurableClusterPrivileges.WriteProfileDataPrivileges original = buildPrivileges();
+            builder.startObject();
+            original.toXContent(builder, ToXContent.EMPTY_PARAMS);
+            builder.endObject();
+            builder.flush();
+
+            final byte[] bytes = out.toByteArray();
+            try (XContentParser parser = xContent.createParser(NamedXContentRegistry.EMPTY, THROW_UNSUPPORTED_OPERATION, bytes)) {
+                assertThat(parser.nextToken(), equalTo(XContentParser.Token.START_OBJECT));
+                assertThat(parser.nextToken(), equalTo(XContentParser.Token.FIELD_NAME));
+                final ConfigurableClusterPrivileges.WriteProfileDataPrivileges clone =
+                    ConfigurableClusterPrivileges.WriteProfileDataPrivileges.parse(parser);
+                assertThat(parser.nextToken(), equalTo(XContentParser.Token.END_OBJECT));
+
+                assertThat(clone, equalTo(original));
+                assertThat(original, equalTo(clone));
+            }
+        }
+    }
+
+    public void testActionAndRequestPredicate() {
+        final String prefix = randomAlphaOfLengthBetween(0, 3);
+        final String name = randomAlphaOfLengthBetween(0, 5);
+        String other = randomAlphaOfLengthBetween(0, 7);
+        if (other.startsWith(prefix) || other.equals(name)) {
+            other = null;
+        }
+        final ConfigurableClusterPrivileges.WriteProfileDataPrivileges writeProfileDataPrivileges =
+            new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(Sets.newHashSet(prefix + "*", name));
+        final ClusterPermission writeProfileDataPermission = writeProfileDataPrivileges.buildPermission(ClusterPermission.builder())
+            .build();
+        assertThat(writeProfileDataPermission, notNullValue());
+
+        final Authentication authentication = mock(Authentication.class);
+        // request application name matches privilege wildcard
+        UpdateProfileDataRequest updateProfileDataRequest = randomBoolean()
+            ? newUpdateProfileDataRequest(Set.of(prefix + randomAlphaOfLengthBetween(0, 2)), Set.of())
+            : newUpdateProfileDataRequest(Set.of(), Set.of(prefix + randomAlphaOfLengthBetween(0, 2)));
+        assertTrue(
+            writeProfileDataPermission.check("cluster:admin/xpack/security/profile/put/data", updateProfileDataRequest, authentication)
+        );
+        // request application name matches privilege name
+        updateProfileDataRequest = randomBoolean()
+            ? newUpdateProfileDataRequest(Set.of(name), Set.of())
+            : newUpdateProfileDataRequest(Set.of(), Set.of(name));
+        assertTrue(
+            writeProfileDataPermission.check("cluster:admin/xpack/security/profile/put/data", updateProfileDataRequest, authentication)
+        );
+        // different action name
+        assertFalse(
+            writeProfileDataPermission.check(
+                randomFrom(ActivateProfileAction.NAME, GetProfileAction.NAME, SearchProfilesAction.NAME),
+                updateProfileDataRequest,
+                authentication
+            )
+        );
+        if (other != null) {
+            updateProfileDataRequest = randomBoolean()
+                ? newUpdateProfileDataRequest(
+                    randomBoolean() ? Set.of(prefix + randomAlphaOfLengthBetween(0, 2), other) : Set.of(other),
+                    Set.of()
+                )
+                : newUpdateProfileDataRequest(
+                    Set.of(),
+                    randomBoolean() ? Set.of(prefix + randomAlphaOfLengthBetween(0, 2), other) : Set.of(other)
+                );
+            assertFalse(writeProfileDataPermission.check(UpdateProfileDataAction.NAME, updateProfileDataRequest, authentication));
+            updateProfileDataRequest = randomBoolean()
+                ? newUpdateProfileDataRequest(randomBoolean() ? Set.of(name, other) : Set.of(other), Set.of())
+                : newUpdateProfileDataRequest(Set.of(), randomBoolean() ? Set.of(name, other) : Set.of(other));
+            assertFalse(writeProfileDataPermission.check(UpdateProfileDataAction.NAME, updateProfileDataRequest, authentication));
+        }
+        assertFalse(writeProfileDataPermission.check(UpdateProfileDataAction.NAME, mock(TransportRequest.class), authentication));
+    }
+
+    public void testParseAbnormals() throws Exception {
+        final String nullApplications = "{\"write\":{\"applications\":null}}";
+        try (
+            XContentParser parser = XContentType.JSON.xContent()
+                .createParser(
+                    NamedXContentRegistry.EMPTY,
+                    LoggingDeprecationHandler.INSTANCE,
+                    new ByteArrayInputStream(nullApplications.getBytes(StandardCharsets.UTF_8))
+                )
+        ) {
+            parser.nextToken(); // {
+            parser.nextToken(); // "write" field
+            expectThrows(XContentParseException.class, () -> ConfigurableClusterPrivileges.WriteProfileDataPrivileges.parse(parser));
+            parser.nextToken();
+        }
+        final String emptyApplications = "{\"write\":{\"applications\":[]}}";
+        try (
+            XContentParser parser = XContentType.JSON.xContent()
+                .createParser(
+                    NamedXContentRegistry.EMPTY,
+                    LoggingDeprecationHandler.INSTANCE,
+                    new ByteArrayInputStream(emptyApplications.getBytes(StandardCharsets.UTF_8))
+                )
+        ) {
+            parser.nextToken(); // {
+            parser.nextToken(); // "write" field
+            ConfigurableClusterPrivileges.WriteProfileDataPrivileges priv = ConfigurableClusterPrivileges.WriteProfileDataPrivileges.parse(
+                parser
+            );
+            parser.nextToken();
+            assertThat(priv.getApplicationNames().size(), is(0));
+            UpdateProfileDataRequest updateProfileDataRequest = randomBoolean()
+                ? newUpdateProfileDataRequest(Set.of(randomAlphaOfLengthBetween(0, 2)), Set.of())
+                : newUpdateProfileDataRequest(Set.of(), Set.of(randomAlphaOfLengthBetween(0, 2)));
+            ClusterPermission perm = priv.buildPermission(ClusterPermission.builder()).build();
+            assertFalse(perm.check(UpdateProfileDataAction.NAME, updateProfileDataRequest, mock(Authentication.class)));
+        }
+        final String aNullApplication = "{\"write\":{\"applications\":[null]}}";
+        try (
+            XContentParser parser = XContentType.JSON.xContent()
+                .createParser(
+                    NamedXContentRegistry.EMPTY,
+                    LoggingDeprecationHandler.INSTANCE,
+                    new ByteArrayInputStream(aNullApplication.getBytes(StandardCharsets.UTF_8))
+                )
+        ) {
+            parser.nextToken(); // {
+            parser.nextToken(); // "write" field
+            expectThrows(ElasticsearchParseException.class, () -> ConfigurableClusterPrivileges.WriteProfileDataPrivileges.parse(parser));
+            parser.nextToken();
+        }
+        final String anEmptyApplication = "{\"write\":{\"applications\":[\"\"]}}";
+        try (
+            XContentParser parser = XContentType.JSON.xContent()
+                .createParser(
+                    NamedXContentRegistry.EMPTY,
+                    LoggingDeprecationHandler.INSTANCE,
+                    new ByteArrayInputStream(anEmptyApplication.getBytes(StandardCharsets.UTF_8))
+                )
+        ) {
+            parser.nextToken(); // {
+            parser.nextToken(); // "write" field
+            ConfigurableClusterPrivileges.WriteProfileDataPrivileges priv = ConfigurableClusterPrivileges.WriteProfileDataPrivileges.parse(
+                parser
+            );
+            parser.nextToken();
+            assertThat(priv.getApplicationNames().size(), is(1));
+            assertThat(priv.getApplicationNames().stream().findFirst().get(), is(""));
+            UpdateProfileDataRequest updateProfileDataRequest = randomBoolean()
+                ? newUpdateProfileDataRequest(Set.of(randomAlphaOfLengthBetween(1, 2)), Set.of())
+                : newUpdateProfileDataRequest(Set.of(), Set.of(randomAlphaOfLengthBetween(1, 2)));
+            ClusterPermission perm = priv.buildPermission(ClusterPermission.builder()).build();
+            assertFalse(perm.check(UpdateProfileDataAction.NAME, updateProfileDataRequest, mock(Authentication.class)));
+            updateProfileDataRequest = randomBoolean()
+                ? newUpdateProfileDataRequest(Set.of(""), Set.of())
+                : newUpdateProfileDataRequest(Set.of(), Set.of(""));
+            perm = priv.buildPermission(ClusterPermission.builder()).build();
+            assertTrue(perm.check("cluster:admin/xpack/security/profile/put/data", updateProfileDataRequest, mock(Authentication.class)));
+        }
+    }
+
+    public void testEqualsAndHashCode() {
+        final int applicationNameLength = randomIntBetween(4, 7);
+        final ConfigurableClusterPrivileges.WriteProfileDataPrivileges privileges = buildPrivileges(applicationNameLength);
+        final EqualsHashCodeTestUtils.MutateFunction<ConfigurableClusterPrivileges.WriteProfileDataPrivileges> mutate =
+            orig -> buildPrivileges(applicationNameLength + randomIntBetween(1, 3));
+        EqualsHashCodeTestUtils.checkEqualsAndHashCode(privileges, this::clone, mutate);
+    }
+
+    private UpdateProfileDataRequest newUpdateProfileDataRequest(Set<String> accessNames, Set<String> dataNames) {
+        Map<String, Object> access = new HashMap<>();
+        for (String accessName : accessNames) {
+            access.put(accessName, mock(Object.class));
+        }
+        Map<String, Object> data = new HashMap<>();
+        for (String dataName : dataNames) {
+            data.put(dataName, mock(Object.class));
+        }
+        return new UpdateProfileDataRequest(
+            randomAlphaOfLengthBetween(4, 8),
+            access,
+            data,
+            randomLong(),
+            randomLong(),
+            randomFrom(WriteRequest.RefreshPolicy.values())
+        );
+    }
+
+    private ConfigurableClusterPrivileges.WriteProfileDataPrivileges clone(
+        ConfigurableClusterPrivileges.WriteProfileDataPrivileges original
+    ) {
+        return new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(new LinkedHashSet<>(original.getApplicationNames()));
+    }
+
+    static ConfigurableClusterPrivileges.WriteProfileDataPrivileges buildPrivileges() {
+        return buildPrivileges(randomIntBetween(4, 7));
+    }
+
+    static ConfigurableClusterPrivileges.WriteProfileDataPrivileges buildPrivileges(int applicationNameLength) {
+        Set<String> applicationNames = Sets.newHashSet(Arrays.asList(generateRandomStringArray(5, applicationNameLength, false, false)));
+        return new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(applicationNames);
+    }
+}

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

@@ -55,6 +55,7 @@ import org.elasticsearch.action.ingest.SimulatePipelineAction;
 import org.elasticsearch.action.main.MainAction;
 import org.elasticsearch.action.search.MultiSearchAction;
 import org.elasticsearch.action.search.SearchAction;
+import org.elasticsearch.action.support.WriteRequest;
 import org.elasticsearch.action.update.UpdateAction;
 import org.elasticsearch.cluster.metadata.AliasMetadata;
 import org.elasticsearch.cluster.metadata.IndexAbstraction;
@@ -159,8 +160,12 @@ import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesReque
 import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesAction;
 import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesRequest;
 import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileAction;
+import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileRequest;
 import org.elasticsearch.xpack.core.security.action.profile.GetProfileAction;
+import org.elasticsearch.xpack.core.security.action.profile.SearchProfilesAction;
+import org.elasticsearch.xpack.core.security.action.profile.SearchProfilesRequest;
 import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataAction;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
 import org.elasticsearch.xpack.core.security.action.role.PutRoleAction;
 import org.elasticsearch.xpack.core.security.action.saml.SamlAuthenticateAction;
 import org.elasticsearch.xpack.core.security.action.saml.SamlPrepareAuthenticationAction;
@@ -209,6 +214,7 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.SortedMap;
 
 import static org.elasticsearch.xpack.core.security.test.TestRestrictedIndices.RESTRICTED_INDICES_AUTOMATON;
@@ -451,7 +457,79 @@ public class ReservedRolesStoreTests extends ESTestCase {
         // User profile
         assertThat(kibanaRole.cluster().check(GetProfileAction.NAME, request, authentication), is(true));
         assertThat(kibanaRole.cluster().check(ActivateProfileAction.NAME, request, authentication), is(true));
-        assertThat(kibanaRole.cluster().check(UpdateProfileDataAction.NAME, request, authentication), is(true));
+        UpdateProfileDataRequest updateProfileDataRequest = randomBoolean()
+            ? new UpdateProfileDataRequest(
+                randomAlphaOfLength(10),
+                Map.of("kibana-" + randomAlphaOfLengthBetween(0, 4), mock(Object.class)),
+                Map.of(),
+                randomFrom(-1L, randomLong()),
+                randomFrom(-1L, randomLong()),
+                randomFrom(WriteRequest.RefreshPolicy.values())
+            )
+            : new UpdateProfileDataRequest(
+                randomAlphaOfLength(10),
+                Map.of(),
+                Map.of("kibana-" + randomAlphaOfLengthBetween(0, 4), mock(Object.class)),
+                randomFrom(-1L, randomLong()),
+                randomFrom(-1L, randomLong()),
+                randomFrom(WriteRequest.RefreshPolicy.values())
+            );
+        assertThat(kibanaRole.cluster().check(UpdateProfileDataAction.NAME, updateProfileDataRequest, authentication), is(true));
+        updateProfileDataRequest = new UpdateProfileDataRequest(
+            randomAlphaOfLength(10),
+            Map.of("kibana-" + randomAlphaOfLengthBetween(0, 4), mock(Object.class)),
+            Map.of("kibana-" + randomAlphaOfLengthBetween(0, 4), mock(Object.class)),
+            randomFrom(-1L, randomLong()),
+            randomFrom(-1L, randomLong()),
+            randomFrom(WriteRequest.RefreshPolicy.values())
+        );
+        assertThat(kibanaRole.cluster().check(UpdateProfileDataAction.NAME, updateProfileDataRequest, authentication), is(true));
+        updateProfileDataRequest = randomBoolean()
+            ? new UpdateProfileDataRequest(
+                randomAlphaOfLength(10),
+                Map.of(randomAlphaOfLengthBetween(0, 6), mock(Object.class)),
+                Map.of(),
+                randomFrom(-1L, randomLong()),
+                randomFrom(-1L, randomLong()),
+                randomFrom(WriteRequest.RefreshPolicy.values())
+            )
+            : new UpdateProfileDataRequest(
+                randomAlphaOfLength(10),
+                Map.of(),
+                Map.of(randomAlphaOfLengthBetween(0, 6), mock(Object.class)),
+                randomFrom(-1L, randomLong()),
+                randomFrom(-1L, randomLong()),
+                randomFrom(WriteRequest.RefreshPolicy.values())
+            );
+        assertThat(kibanaRole.cluster().check(UpdateProfileDataAction.NAME, updateProfileDataRequest, authentication), is(false));
+        updateProfileDataRequest = randomBoolean()
+            ? new UpdateProfileDataRequest(
+                randomAlphaOfLength(10),
+                Map.of(
+                    "kibana-" + randomAlphaOfLengthBetween(0, 4),
+                    mock(Object.class),
+                    randomAlphaOfLengthBetween(0, 6),
+                    mock(Object.class)
+                ),
+                Map.of("kibana-" + randomAlphaOfLengthBetween(0, 4), mock(Object.class)),
+                randomFrom(-1L, randomLong()),
+                randomFrom(-1L, randomLong()),
+                randomFrom(WriteRequest.RefreshPolicy.values())
+            )
+            : new UpdateProfileDataRequest(
+                randomAlphaOfLength(10),
+                Map.of("kibana-" + randomAlphaOfLengthBetween(0, 4), mock(Object.class)),
+                Map.of(
+                    "kibana-" + randomAlphaOfLengthBetween(0, 4),
+                    mock(Object.class),
+                    randomAlphaOfLengthBetween(0, 6),
+                    mock(Object.class)
+                ),
+                randomFrom(-1L, randomLong()),
+                randomFrom(-1L, randomLong()),
+                randomFrom(WriteRequest.RefreshPolicy.values())
+            );
+        assertThat(kibanaRole.cluster().check(UpdateProfileDataAction.NAME, updateProfileDataRequest, authentication), is(false));
 
         // Everything else
         assertThat(kibanaRole.runAs().check(randomAlphaOfLengthBetween(1, 12)), is(false));
@@ -1535,6 +1613,13 @@ public class ReservedRolesStoreTests extends ESTestCase {
         assertThat(superuserRole.cluster().check(PutIndexTemplateAction.NAME, request, authentication), is(true));
         assertThat(superuserRole.cluster().check(DelegatePkiAuthenticationAction.NAME, request, authentication), is(true));
         assertThat(superuserRole.cluster().check("internal:admin/foo", request, authentication), is(false));
+        assertThat(
+            superuserRole.cluster().check(UpdateProfileDataAction.NAME, mock(UpdateProfileDataRequest.class), authentication),
+            is(true)
+        );
+        assertThat(superuserRole.cluster().check(GetProfileAction.NAME, mock(UpdateProfileDataRequest.class), authentication), is(true));
+        assertThat(superuserRole.cluster().check(SearchProfilesAction.NAME, mock(SearchProfilesRequest.class), authentication), is(true));
+        assertThat(superuserRole.cluster().check(ActivateProfileAction.NAME, mock(ActivateProfileRequest.class), authentication), is(true));
 
         final Settings indexSettings = Settings.builder().put("index.version.created", Version.CURRENT).build();
         final String internalSecurityIndex = randomFrom(

+ 5 - 3
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestGetUserPrivilegesAction.java

@@ -24,12 +24,10 @@ import org.elasticsearch.xpack.core.security.action.user.GetUserPrivilegesReques
 import org.elasticsearch.xpack.core.security.action.user.GetUserPrivilegesResponse;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivilege;
-import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivileges;
 import org.elasticsearch.xpack.core.security.user.User;
 import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
 
 import java.io.IOException;
-import java.util.Collections;
 import java.util.List;
 
 import static org.elasticsearch.rest.RestRequest.Method.GET;
@@ -82,7 +80,11 @@ public class RestGetUserPrivilegesAction extends SecurityBaseRestHandler {
             builder.field(RoleDescriptor.Fields.CLUSTER.getPreferredName(), response.getClusterPrivileges());
             builder.startArray(RoleDescriptor.Fields.GLOBAL.getPreferredName());
             for (ConfigurableClusterPrivilege ccp : response.getConditionalClusterPrivileges()) {
-                ConfigurableClusterPrivileges.toXContent(builder, ToXContent.EMPTY_PARAMS, Collections.singleton(ccp));
+                builder.startObject();
+                builder.startObject(ccp.getCategory().field.getPreferredName());
+                ccp.toXContent(builder, ToXContent.EMPTY_PARAMS);
+                builder.endObject();
+                builder.endObject();
             }
             builder.endArray();
 

+ 21 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecuritySystemIndices.java

@@ -295,6 +295,27 @@ public class SecuritySystemIndices {
                                 builder.endObject();
                             }
                             builder.endObject();
+                            builder.startObject("profile");
+                            {
+                                builder.field("type", "object");
+                                builder.startObject("properties");
+                                {
+                                    builder.startObject("write");
+                                    {
+                                        builder.field("type", "object");
+                                        builder.startObject("properties");
+                                        {
+                                            builder.startObject("applications");
+                                            builder.field("type", "keyword");
+                                            builder.endObject();
+                                        }
+                                        builder.endObject();
+                                    }
+                                    builder.endObject();
+                                }
+                                builder.endObject();
+                            }
+                            builder.endObject();
                         }
                         builder.endObject();
                     }

+ 15 - 1
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailTests.java

@@ -135,6 +135,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -538,6 +539,18 @@ public class LoggingAuditTrailTests extends ESTestCase {
             metaMap,
             Map.of("ignored", 2)
         );
+        RoleDescriptor roleDescriptor5 = new RoleDescriptor(
+            "role_descriptor5",
+            new String[] { "all" },
+            new RoleDescriptor.IndicesPrivileges[0],
+            randomFrom((RoleDescriptor.ApplicationResourcePrivileges[]) null, new RoleDescriptor.ApplicationResourcePrivileges[0]),
+            new ConfigurableClusterPrivilege[] {
+                new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(new LinkedHashSet<>(Arrays.asList("", "\""))),
+                new ConfigurableClusterPrivileges.ManageApplicationPrivileges(Set.of("\"")) },
+            new String[] { "\"[a]/" },
+            Map.of(),
+            Map.of()
+        );
         String keyName = randomAlphaOfLength(4);
         TimeValue expiration = randomFrom(new TimeValue(randomNonNegativeLong(), randomFrom(TimeUnit.values())), null);
         List<RoleDescriptor> allTestRoleDescriptors = List.of(
@@ -545,7 +558,8 @@ public class LoggingAuditTrailTests extends ESTestCase {
             roleDescriptor1,
             roleDescriptor2,
             roleDescriptor3,
-            roleDescriptor4
+            roleDescriptor4,
+            roleDescriptor5
         );
         List<RoleDescriptor> keyRoleDescriptors = randomSubsetOf(allTestRoleDescriptors);
         StringBuilder roleDescriptorsStringBuilder = new StringBuilder().append("\"role_descriptors\":[");

+ 157 - 10
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java

@@ -90,6 +90,7 @@ public class RoleDescriptorTests extends ESTestCase {
             ApplicationResourcePrivileges.builder().application("my_app").privileges("read", "write").resources("*").build() };
 
         final ConfigurableClusterPrivilege[] configurableClusterPrivileges = new ConfigurableClusterPrivilege[] {
+            new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(new LinkedHashSet<>(Arrays.asList("app*"))),
             new ConfigurableClusterPrivileges.ManageApplicationPrivileges(new LinkedHashSet<>(Arrays.asList("app01", "app02"))) };
 
         RoleDescriptor descriptor = new RoleDescriptor(
@@ -107,7 +108,7 @@ public class RoleDescriptorTests extends ESTestCase {
             descriptor.toString(),
             is(
                 "Role[name=test, cluster=[all,none]"
-                    + ", global=[{APPLICATION:manage:applications=app01,app02}]"
+                    + ", global=[{APPLICATION:manage:applications=app01,app02},{PROFILE:write:applications=app*}]"
                     + ", 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=[*]],]"
@@ -189,7 +190,11 @@ public class RoleDescriptorTests extends ESTestCase {
                   "privileges": [ "p1", "p2" ],
                   "allow_restricted_indices": true
                 }
-              ]
+              ],
+              "global": {
+                "profile": {
+                }
+              }
             }""";
         rd = RoleDescriptor.parse("test", new BytesArray(q), false, XContentType.JSON);
         assertEquals("test", rd.getName());
@@ -239,6 +244,8 @@ public class RoleDescriptorTests extends ESTestCase {
                   "manage": {
                     "applications": [ "kibana", "logstash" ]
                   }
+                },
+                "profile": {
                 }
               }
             }""";
@@ -259,7 +266,7 @@ public class RoleDescriptorTests extends ESTestCase {
         assertThat(rd.getApplicationPrivileges()[1].getApplication(), equalTo("app2"));
         assertThat(rd.getConditionalClusterPrivileges(), Matchers.arrayWithSize(1));
 
-        final ConfigurableClusterPrivilege conditionalPrivilege = rd.getConditionalClusterPrivileges()[0];
+        ConfigurableClusterPrivilege conditionalPrivilege = rd.getConditionalClusterPrivileges()[0];
         assertThat(conditionalPrivilege.getCategory(), equalTo(ConfigurableClusterPrivilege.Category.APPLICATION));
         assertThat(conditionalPrivilege, instanceOf(ConfigurableClusterPrivileges.ManageApplicationPrivileges.class));
         assertThat(
@@ -267,6 +274,45 @@ public class RoleDescriptorTests extends ESTestCase {
             containsInAnyOrder("kibana", "logstash")
         );
 
+        q = """
+            {
+              "cluster": [ "manage" ],
+              "global": {
+                "profile": {
+                  "write": {
+                    "applications": [ "", "kibana-*" ]
+                  }
+                },
+                "application": {
+                  "manage": {
+                    "applications": [ "apm*", "kibana-1" ]
+                  }
+                }
+              }
+            }""";
+        rd = RoleDescriptor.parse("testUpdateProfile", new BytesArray(q), false, XContentType.JSON);
+        assertThat(rd.getName(), is("testUpdateProfile"));
+        assertThat(rd.getClusterPrivileges(), arrayContaining("manage"));
+        assertThat(rd.getIndicesPrivileges(), Matchers.emptyArray());
+        assertThat(rd.getRunAs(), Matchers.emptyArray());
+        assertThat(rd.getApplicationPrivileges(), Matchers.emptyArray());
+        assertThat(rd.getConditionalClusterPrivileges(), Matchers.arrayWithSize(2));
+
+        conditionalPrivilege = rd.getConditionalClusterPrivileges()[0];
+        assertThat(conditionalPrivilege.getCategory(), equalTo(ConfigurableClusterPrivilege.Category.APPLICATION));
+        assertThat(conditionalPrivilege, instanceOf(ConfigurableClusterPrivileges.ManageApplicationPrivileges.class));
+        assertThat(
+            ((ConfigurableClusterPrivileges.ManageApplicationPrivileges) conditionalPrivilege).getApplicationNames(),
+            containsInAnyOrder("apm*", "kibana-1")
+        );
+        conditionalPrivilege = rd.getConditionalClusterPrivileges()[1];
+        assertThat(conditionalPrivilege.getCategory(), equalTo(ConfigurableClusterPrivilege.Category.PROFILE));
+        assertThat(conditionalPrivilege, instanceOf(ConfigurableClusterPrivileges.WriteProfileDataPrivileges.class));
+        assertThat(
+            ((ConfigurableClusterPrivileges.WriteProfileDataPrivileges) conditionalPrivilege).getApplicationNames(),
+            containsInAnyOrder("", "kibana-*")
+        );
+
         q = """
             {"applications": [{"application": "myapp", "resources": ["*"], "privileges": ["login" ]}] }""";
         rd = RoleDescriptor.parse("test", new BytesArray(q), false, XContentType.JSON);
@@ -444,6 +490,88 @@ public class RoleDescriptorTests extends ESTestCase {
         assertThat(epe, TestMatchers.throwableWithMessage(containsString("f3")));
     }
 
+    public void testGlobalPrivilegesOrdering() throws IOException {
+        final String roleName = randomAlphaOfLengthBetween(3, 30);
+        final String[] applicationNames = generateRandomStringArray(3, randomIntBetween(0, 3), false, true);
+        final String[] profileNames = generateRandomStringArray(3, randomIntBetween(0, 3), false, true);
+        ConfigurableClusterPrivilege[] configurableClusterPrivileges = new ConfigurableClusterPrivilege[] {
+            new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(Sets.newHashSet(profileNames)),
+            new ConfigurableClusterPrivileges.ManageApplicationPrivileges(Sets.newHashSet(applicationNames)) };
+        RoleDescriptor role1 = new RoleDescriptor(
+            roleName,
+            new String[0],
+            new RoleDescriptor.IndicesPrivileges[0],
+            new RoleDescriptor.ApplicationResourcePrivileges[0],
+            configurableClusterPrivileges,
+            new String[0],
+            Map.of(),
+            Map.of()
+        );
+        // swap
+        var temp = configurableClusterPrivileges[0];
+        configurableClusterPrivileges[0] = configurableClusterPrivileges[1];
+        configurableClusterPrivileges[1] = temp;
+        RoleDescriptor role2 = new RoleDescriptor(
+            roleName,
+            new String[0],
+            new RoleDescriptor.IndicesPrivileges[0],
+            new RoleDescriptor.ApplicationResourcePrivileges[0],
+            configurableClusterPrivileges,
+            new String[0],
+            Map.of(),
+            Map.of()
+        );
+        assertThat(role2, is(role1));
+        StringBuilder applicationNamesString = new StringBuilder();
+        for (int i = 0; i < applicationNames.length; i++) {
+            if (i > 0) {
+                applicationNamesString.append(", ");
+            }
+            applicationNamesString.append("\"" + applicationNames[i] + "\"");
+        }
+        StringBuilder profileNamesString = new StringBuilder();
+        for (int i = 0; i < profileNames.length; i++) {
+            if (i > 0) {
+                profileNamesString.append(", ");
+            }
+            profileNamesString.append("\"" + profileNames[i] + "\"");
+        }
+        String json = """
+            {
+              "global": {
+                "profile": {
+                  "write": {
+                    "applications": [ %s ]
+                  }
+                },
+                "application": {
+                  "manage": {
+                    "applications": [ %s ]
+                  }
+                }
+              }
+            }""".formatted(profileNamesString, applicationNamesString);
+        RoleDescriptor role3 = RoleDescriptor.parse(roleName, new BytesArray(json), false, XContentType.JSON);
+        assertThat(role3, is(role1));
+        json = """
+            {
+              "global": {
+                "application": {
+                  "manage": {
+                    "applications": [ %s ]
+                  }
+                },
+                "profile": {
+                  "write": {
+                    "applications": [ %s ]
+                  }
+                }
+              }
+            }""".formatted(applicationNamesString, profileNamesString);
+        RoleDescriptor role4 = RoleDescriptor.parse(roleName, new BytesArray(json), false, XContentType.JSON);
+        assertThat(role4, is(role1));
+    }
+
     public void testIsEmpty() {
         assertTrue(new RoleDescriptor(randomAlphaOfLengthBetween(1, 10), null, null, null, null, null, null, null).isEmpty());
 
@@ -483,7 +611,9 @@ public class RoleDescriptorTests extends ESTestCase {
             booleans.get(3)
                 ? new ConfigurableClusterPrivilege[0]
                 : new ConfigurableClusterPrivilege[] {
-                    new ConfigurableClusterPrivileges.ManageApplicationPrivileges(Collections.singleton("foo")) },
+                    randomBoolean()
+                        ? new ConfigurableClusterPrivileges.ManageApplicationPrivileges(Collections.singleton("foo"))
+                        : new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(Collections.singleton("bar")) },
             booleans.get(4) ? new String[0] : new String[] { "foo" },
             booleans.get(5) ? new HashMap<>() : Collections.singletonMap("foo", "bar"),
             Collections.singletonMap("foo", "bar")
@@ -536,15 +666,32 @@ public class RoleDescriptorTests extends ESTestCase {
             }
             applicationPrivileges[i] = builder.build();
         }
-        final ConfigurableClusterPrivilege[] configurableClusterPrivileges;
-        if (randomBoolean()) {
-            configurableClusterPrivileges = new ConfigurableClusterPrivilege[] {
+        final ConfigurableClusterPrivilege[] configurableClusterPrivileges = switch (randomIntBetween(0, 4)) {
+            case 0 -> new ConfigurableClusterPrivilege[0];
+            case 1 -> new ConfigurableClusterPrivilege[] {
                 new ConfigurableClusterPrivileges.ManageApplicationPrivileges(
                     Sets.newHashSet(generateRandomStringArray(3, randomIntBetween(4, 12), false, false))
                 ) };
-        } else {
-            configurableClusterPrivileges = new ConfigurableClusterPrivilege[0];
-        }
+            case 2 -> new ConfigurableClusterPrivilege[] {
+                new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(
+                    Sets.newHashSet(generateRandomStringArray(3, randomIntBetween(4, 12), false, false))
+                ) };
+            case 3 -> new ConfigurableClusterPrivilege[] {
+                new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(
+                    Sets.newHashSet(generateRandomStringArray(3, randomIntBetween(4, 12), false, false))
+                ),
+                new ConfigurableClusterPrivileges.ManageApplicationPrivileges(
+                    Sets.newHashSet(generateRandomStringArray(3, randomIntBetween(4, 12), false, false))
+                ) };
+            case 4 -> new ConfigurableClusterPrivilege[] {
+                new ConfigurableClusterPrivileges.ManageApplicationPrivileges(
+                    Sets.newHashSet(generateRandomStringArray(3, randomIntBetween(4, 12), false, false))
+                ),
+                new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(
+                    Sets.newHashSet(generateRandomStringArray(3, randomIntBetween(4, 12), false, false))
+                ) };
+            default -> throw new IllegalStateException("Unexpected value");
+        };
         final Map<String, Object> metadata = new HashMap<>();
         while (randomBoolean()) {
             String key = randomAlphaOfLengthBetween(4, 12);

+ 12 - 2
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/user/RestGetUserPrivilegesActionTests.java

@@ -68,8 +68,11 @@ public class RestGetUserPrivilegesActionTests extends ESTestCase {
     public void testBuildResponse() throws Exception {
         final RestGetUserPrivilegesAction.RestListener listener = new RestGetUserPrivilegesAction.RestListener(null);
         final Set<String> cluster = new LinkedHashSet<>(Arrays.asList("monitor", "manage_ml", "manage_watcher"));
-        final Set<ConfigurableClusterPrivilege> conditionalCluster = Collections.singleton(
-            new ConfigurableClusterPrivileges.ManageApplicationPrivileges(new LinkedHashSet<>(Arrays.asList("app01", "app02")))
+        final Set<ConfigurableClusterPrivilege> conditionalCluster = new LinkedHashSet<>(
+            Arrays.asList(
+                new ConfigurableClusterPrivileges.WriteProfileDataPrivileges(new LinkedHashSet<>(Arrays.asList("app*"))),
+                new ConfigurableClusterPrivileges.ManageApplicationPrivileges(new LinkedHashSet<>(Arrays.asList("app01", "app02")))
+            )
         );
         final Set<GetUserPrivilegesResponse.Indices> index = new LinkedHashSet<>(
             Arrays.asList(
@@ -114,6 +117,13 @@ public class RestGetUserPrivilegesActionTests extends ESTestCase {
             {
               "cluster": [ "monitor", "manage_ml", "manage_watcher" ],
               "global": [
+                {
+                  "profile": {
+                    "write": {
+                      "applications": [ "app*" ]
+                    }
+                  }
+                },
                 {
                   "application": {
                     "manage": {

+ 3 - 1
x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/audit/logfile/audited_roles.txt

@@ -7,4 +7,6 @@ role_descriptor2
 role_descriptor3
 {"cluster":[],"indices":[],"applications":[{"application":"maps","privileges":["{","}","\n","\\","\""],"resources":["raster:*"]},{"application":"maps","privileges":["*:*"],"resources":["noooooo!!\n\n\f\\\\r","{"]}],"run_as":["jack","nich*","//\""],"metadata":{"some meta":42}}
 role_descriptor4
-{"cluster":["manage_ml","grant_api_key","manage_rollup"],"global":{"application":{"manage":{"applications":["a+b+|b+a+"]}}},"indices":[{"names":["/. ? + * | { } [ ] ( ) \" \\/","*"],"privileges":["read","read_cross_cluster"],"field_security":{"grant":["almost","all*"],"except":["denied*"]}}],"applications":[],"run_as":["//+a+\"[a]/"],"metadata":{"?list":["e1","e2","*"],"some other meta":{"r":"t"}}}
+{"cluster":["manage_ml","grant_api_key","manage_rollup"],"global":{"application":{"manage":{"applications":["a+b+|b+a+"]}},"profile":{}},"indices":[{"names":["/. ? + * | { } [ ] ( ) \" \\/","*"],"privileges":["read","read_cross_cluster"],"field_security":{"grant":["almost","all*"],"except":["denied*"]}}],"applications":[],"run_as":["//+a+\"[a]/"],"metadata":{"?list":["e1","e2","*"],"some other meta":{"r":"t"}}}
+role_descriptor5
+{"cluster":["all"],"global":{"application":{"manage":{"applications":["\""]}},"profile":{"write":{"applications":["","\""]}}},"indices":[],"applications":[],"run_as":["\"[a]/"]}