Jelajahi Sumber

HLRest: model role and privileges (#35128)

The following "user privileges" objects have been created
on the client side:

* ApplicationResourcePrivileges
* IndicesPrivileges
* GlobalOperationPrivilege
* GlobalPrivileges

as well as the aggregating `Role` entity.
Albert Zaharovits 7 tahun lalu
induk
melakukan
6050deb6f0

+ 156 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/ApplicationResourcePrivileges.java

@@ -0,0 +1,156 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.client.security.user.privileges;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.common.xcontent.XContentType;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
+
+/**
+ * Represents privileges over resources that are scoped under an application.
+ * The application, resources and privileges are completely managed by the
+ * client and can be arbitrary string identifiers. Elasticsearch is not
+ * concerned by any resources under an application scope.
+ */
+public final class ApplicationResourcePrivileges implements ToXContentObject {
+
+    private static final ParseField APPLICATION = new ParseField("application");
+    private static final ParseField PRIVILEGES = new ParseField("privileges");
+    private static final ParseField RESOURCES = new ParseField("resources");
+
+    @SuppressWarnings("unchecked")
+    static final ConstructingObjectParser<ApplicationResourcePrivileges, Void> PARSER = new ConstructingObjectParser<>(
+            "application_privileges", false, constructorObjects -> {
+                // Don't ignore unknown fields. It is dangerous if the object we parse is also
+                // part of a request that we build later on, and the fields that we now ignore will
+                // end up being implicitly set to null in that request.
+                int i = 0;
+                final String application = (String) constructorObjects[i++];
+                final Collection<String> privileges = (Collection<String>) constructorObjects[i++];
+                final Collection<String> resources = (Collection<String>) constructorObjects[i];
+                return new ApplicationResourcePrivileges(application, privileges, resources);
+            });
+
+    static {
+        PARSER.declareString(constructorArg(), APPLICATION);
+        PARSER.declareStringArray(constructorArg(), PRIVILEGES);
+        PARSER.declareStringArray(constructorArg(), RESOURCES);
+    }
+
+    private final String application;
+    private final Set<String> privileges;
+    private final Set<String> resources;
+
+    /**
+     * Constructs privileges for resources under an application scope.
+     * 
+     * @param application
+     *            The application name. This identifier is completely under the
+     *            clients control.
+     * @param privileges
+     *            The privileges names. Cannot be null or empty. Privilege
+     *            identifiers are completely under the clients control.
+     * @param resources
+     *            The resources names. Cannot be null or empty. Resource identifiers
+     *            are completely under the clients control.
+     */
+    public ApplicationResourcePrivileges(String application, Collection<String> privileges, Collection<String> resources) {
+        if (Strings.isNullOrEmpty(application)) {
+            throw new IllegalArgumentException("application privileges must have an application name");
+        }
+        if (null == privileges || privileges.isEmpty()) {
+            throw new IllegalArgumentException("application privileges must define at least one privilege");
+        }
+        if (null == resources || resources.isEmpty()) {
+            throw new IllegalArgumentException("application privileges must refer to at least one resource");
+        }
+        this.application = application;
+        this.privileges = Collections.unmodifiableSet(new HashSet<>(privileges));
+        this.resources = Collections.unmodifiableSet(new HashSet<>(resources));
+    }
+
+    public String getApplication() {
+        return application;
+    }
+
+    public Set<String> getResources() {
+        return this.resources;
+    }
+
+    public Set<String> getPrivileges() {
+        return this.privileges;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || this.getClass() != o.getClass()) {
+            return false;
+        }
+        ApplicationResourcePrivileges that = (ApplicationResourcePrivileges) o;
+        return application.equals(that.application)
+                && privileges.equals(that.privileges)
+                && resources.equals(that.resources);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(application, privileges, resources);
+    }
+
+    @Override
+    public String toString() {
+        try {
+            return XContentHelper.toXContent(this, XContentType.JSON, true).utf8ToString();
+        } catch (IOException e) {
+            throw new RuntimeException("Unexpected", e);
+        }
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field(APPLICATION.getPreferredName(), application);
+        builder.field(PRIVILEGES.getPreferredName(), privileges);
+        builder.field(RESOURCES.getPreferredName(), resources);
+        return builder.endObject();
+    }
+
+    public static ApplicationResourcePrivileges fromXContent(XContentParser parser) {
+        return PARSER.apply(parser, null);
+    }
+
+}

+ 99 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/GlobalOperationPrivilege.java

@@ -0,0 +1,99 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.client.security.user.privileges;
+
+import org.elasticsearch.common.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Represents generic global cluster privileges that can be scoped by categories
+ * and then further by operations. The privilege's syntactic and semantic
+ * meaning is specific to each category and operation; there is no general
+ * definition template. It is not permitted to define different privileges under
+ * the same category and operation.
+ */
+public class GlobalOperationPrivilege {
+
+    private final String category;
+    private final String operation;
+    private final Map<String, Object> privilege;
+
+    /**
+     * Constructs privileges under a specific {@code category} and for some
+     * {@code operation}. The privilege definition is flexible, it is a {@code Map},
+     * and the semantics is bound to the {@code category} and {@code operation}.
+     * 
+     * @param category
+     *            The category of the privilege.
+     * @param operation
+     *            The operation of the privilege.
+     * @param privilege
+     *            The privilege definition.
+     */
+    public GlobalOperationPrivilege(String category, String operation, Map<String, Object> privilege) {
+        this.category = Objects.requireNonNull(category);
+        this.operation = Objects.requireNonNull(operation);
+        if (privilege == null || privilege.isEmpty()) {
+            throw new IllegalArgumentException("Privileges cannot be empty or null");
+        }
+        this.privilege = Collections.unmodifiableMap(privilege);
+    }
+
+    public String getCategory() {
+        return category;
+    }
+
+    public String getOperation() {
+        return operation;
+    }
+
+    public Map<String, Object> getRaw() {
+        return privilege;
+    }
+
+    public static GlobalOperationPrivilege fromXContent(String category, String operation, XContentParser parser) throws IOException {
+        // parser is still placed on the field name, advance to next token (field value)
+        assert parser.currentToken().equals(XContentParser.Token.FIELD_NAME);
+        parser.nextToken();
+        return new GlobalOperationPrivilege(category, operation, parser.map());
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || (false == this instanceof GlobalOperationPrivilege)) {
+            return false;
+        }
+        final GlobalOperationPrivilege that = (GlobalOperationPrivilege) o;
+        return category.equals(that.category) && operation.equals(that.operation) && privilege.equals(that.privilege);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(category, operation, privilege);
+    }
+
+}

+ 137 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/GlobalPrivileges.java

@@ -0,0 +1,137 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.client.security.user.privileges;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+/**
+ * Represents global privileges. "Global Privilege" is a mantra for granular
+ * generic cluster privileges. These privileges are organized into categories.
+ * Elasticsearch defines the set of categories. Under each category there are
+ * operations that are under the clients jurisdiction. The privilege is hence
+ * defined under an operation under a category.
+ */
+public final class GlobalPrivileges implements ToXContentObject {
+
+    // When categories change, adapting this field should suffice. Categories are NOT
+    // opaque "named_objects", we wish to maintain control over these namespaces
+    static final List<String> CATEGORIES = Collections.unmodifiableList(Arrays.asList("application"));
+
+    @SuppressWarnings("unchecked")
+    static final ConstructingObjectParser<GlobalPrivileges, Void> PARSER = new ConstructingObjectParser<>("global_category_privileges",
+            false, constructorObjects -> {
+                // ignore_unknown_fields is irrelevant here anyway, but let's keep it to false
+                // because this conveys strictness (woop woop)
+                return new GlobalPrivileges((Collection<GlobalOperationPrivilege>) constructorObjects[0]);
+            });
+
+    static {
+        for (final String category : CATEGORIES) {
+            PARSER.declareNamedObjects(optionalConstructorArg(),
+                    (parser, context, operation) -> GlobalOperationPrivilege.fromXContent(category, operation, parser),
+                    new ParseField(category));
+        }
+    }
+
+    private final Set<? extends GlobalOperationPrivilege> privileges;
+    // same data as in privileges but broken down by categories; internally, it is
+    // easier to work with this structure
+    private final Map<String, List<GlobalOperationPrivilege>> privilegesByCategoryMap;
+
+    /**
+     * Constructs global privileges by bundling the set of privileges.
+     * 
+     * @param privileges
+     *            The privileges under a category and for an operation in that category.
+     */
+    public GlobalPrivileges(Collection<? extends GlobalOperationPrivilege> privileges) {
+        if (privileges == null || privileges.isEmpty()) {
+            throw new IllegalArgumentException("Privileges cannot be empty or null");
+        }
+        // duplicates are just ignored
+        this.privileges = Collections.unmodifiableSet(new HashSet<>(Objects.requireNonNull(privileges)));
+        this.privilegesByCategoryMap = Collections
+                .unmodifiableMap(this.privileges.stream().collect(Collectors.groupingBy(GlobalOperationPrivilege::getCategory)));
+        for (final Map.Entry<String, List<GlobalOperationPrivilege>> privilegesByCategory : privilegesByCategoryMap.entrySet()) {
+            // all operations for a specific category
+            final Set<String> allOperations = privilegesByCategory.getValue().stream().map(p -> p.getOperation())
+                    .collect(Collectors.toSet());
+            if (allOperations.size() != privilegesByCategory.getValue().size()) {
+                throw new IllegalArgumentException("Different privileges for the same category and operation are not permitted");
+            }
+        }
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        for (final Map.Entry<String, List<GlobalOperationPrivilege>> privilegesByCategory : this.privilegesByCategoryMap.entrySet()) {
+            builder.startObject(privilegesByCategory.getKey());
+            for (final GlobalOperationPrivilege privilege : privilegesByCategory.getValue()) {
+                builder.field(privilege.getOperation(), privilege.getRaw());
+            }
+            builder.endObject();
+        }
+        return builder.endObject();
+    }
+
+    public static GlobalPrivileges fromXContent(XContentParser parser) {
+        return PARSER.apply(parser, null);
+    }
+
+    public Set<? extends GlobalOperationPrivilege> getPrivileges() {
+        return privileges;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || this.getClass() != o.getClass()) {
+            return false;
+        }
+        final GlobalPrivileges that = (GlobalPrivileges) o;
+        return privileges.equals(that.privileges);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(privileges);
+    }
+
+}

+ 309 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/IndicesPrivileges.java

@@ -0,0 +1,309 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.client.security.user.privileges;
+
+import org.elasticsearch.common.Nullable;
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.collect.Tuple;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+/**
+ * Represents privileges over indices. There is a canonical set of privilege
+ * names (eg. {@code IndicesPrivileges#READ_PRIVILEGE_NAME}) but there is
+ * flexibility in the definition of finer grained, more specialized, privileges.
+ * This also encapsulates field and document level security privileges. These
+ * allow to control what fields or documents are readable or queryable.
+ */
+public final class IndicesPrivileges implements ToXContentObject {
+
+    public static final ParseField NAMES = new ParseField("names");
+    public static final ParseField PRIVILEGES = new ParseField("privileges");
+    public static final ParseField FIELD_PERMISSIONS = new ParseField("field_security");
+    public static final ParseField GRANT_FIELDS = new ParseField("grant");
+    public static final ParseField EXCEPT_FIELDS = new ParseField("except");
+    public static final ParseField QUERY = new ParseField("query");
+
+    @SuppressWarnings("unchecked")
+    static final ConstructingObjectParser<IndicesPrivileges, Void> PARSER =
+        new ConstructingObjectParser<>("indices_privileges", false, constructorObjects -> {
+                int i = 0;
+                final Collection<String> indices = (Collection<String>) constructorObjects[i++];
+                final Collection<String> privileges = (Collection<String>) constructorObjects[i++];
+                final Tuple<Collection<String>, Collection<String>> fields =
+                        (Tuple<Collection<String>, Collection<String>>) constructorObjects[i++];
+                final Collection<String> grantFields = fields != null ? fields.v1() : null;
+                final Collection<String> exceptFields = fields != null ? fields.v2() : null;
+                final String query = (String) constructorObjects[i];
+                return new IndicesPrivileges(indices, privileges, grantFields, exceptFields, query);
+            });
+
+    static {
+        @SuppressWarnings("unchecked")
+        final ConstructingObjectParser<Tuple<Collection<String>, Collection<String>>, Void> fls_parser =
+                new ConstructingObjectParser<>( "field_level_parser", false, constructorObjects -> {
+                        int i = 0;
+                        final Collection<String> grantFields = (Collection<String>) constructorObjects[i++];
+                        final Collection<String> exceptFields = (Collection<String>) constructorObjects[i];
+                        return new Tuple<>(grantFields, exceptFields);
+                    });
+        fls_parser.declareStringArray(optionalConstructorArg(), GRANT_FIELDS);
+        fls_parser.declareStringArray(optionalConstructorArg(), EXCEPT_FIELDS);
+
+        PARSER.declareStringArray(constructorArg(), NAMES);
+        PARSER.declareStringArray(constructorArg(), PRIVILEGES);
+        PARSER.declareObject(optionalConstructorArg(), fls_parser, FIELD_PERMISSIONS);
+        PARSER.declareStringOrNull(optionalConstructorArg(), QUERY);
+    }
+
+    private final Set<String> indices;
+    private final Set<String> privileges;
+    // null or singleton '*' means all fields are granted, empty means no fields are granted
+    private final @Nullable Set<String> grantedFields;
+    // null or empty means no fields are denied
+    private final @Nullable Set<String> deniedFields;
+    // missing query means all documents, i.e. no restrictions
+    private final @Nullable String query;
+
+    private IndicesPrivileges(Collection<String> indices, Collection<String> privileges, @Nullable Collection<String> grantedFields,
+            @Nullable Collection<String> deniedFields, @Nullable String query) {
+        if (null == indices || indices.isEmpty()) {
+            throw new IllegalArgumentException("indices privileges must refer to at least one index name or index name pattern");
+        }
+        if (null == privileges || privileges.isEmpty()) {
+            throw new IllegalArgumentException("indices privileges must define at least one privilege");
+        }
+        this.indices = Collections.unmodifiableSet(new HashSet<>(indices));
+        this.privileges = Collections.unmodifiableSet(new HashSet<>(privileges));
+        // unspecified granted fields means no restriction
+        this.grantedFields = grantedFields == null ? null : Collections.unmodifiableSet(new HashSet<>(grantedFields));
+        // unspecified denied fields means no restriction
+        this.deniedFields = deniedFields == null ? null : Collections.unmodifiableSet(new HashSet<>(deniedFields));
+        this.query = query;
+    }
+
+    /**
+     * The indices names covered by the privileges.
+     */
+    public Set<String> getIndices() {
+        return this.indices;
+    }
+
+    /**
+     * The privileges acting over indices. There is a canonical predefined set of
+     * such privileges, but the {@code String} datatype allows for flexibility in defining
+     * finer grained privileges.
+     */
+    public Set<String> getPrivileges() {
+        return this.privileges;
+    }
+
+    /**
+     * The document fields that can be read or queried. Can be null, in this case
+     * all the document's fields are granted access to. Can also be empty, in which
+     * case no fields are granted access to.
+     */
+    public @Nullable Set<String> getGrantedFields() {
+        return this.grantedFields;
+    }
+
+    /**
+     * The document fields that cannot be accessed or queried. Can be null or empty,
+     * in which case no fields are denied.
+     */
+    public @Nullable Set<String> getDeniedFields() {
+        return this.deniedFields;
+    }
+
+    /**
+     * A query limiting the visible documents in the indices. Can be null, in which
+     * case all documents are visible.
+     */
+    public @Nullable String getQuery() {
+        return this.query;
+    }
+
+    /**
+     * If {@code true} some documents might not be visible. Only the documents
+     * matching {@code query} will be readable.
+     */
+    public boolean isUsingDocumentLevelSecurity() {
+        return query != null;
+    }
+
+    /**
+     * If {@code true} some document fields might not be visible.
+     */
+    public boolean isUsingFieldLevelSecurity() {
+        return limitsGrantedFields() || hasDeniedFields();
+    }
+
+    private boolean hasDeniedFields() {
+        return deniedFields != null && false == deniedFields.isEmpty();
+    }
+
+    private boolean limitsGrantedFields() {
+        // we treat just '*' as no FLS since that's what the UI defaults to
+        if (grantedFields == null || (grantedFields.size() == 1 && grantedFields.iterator().next().equals("*"))) {
+            return false;
+        }
+        return true;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        IndicesPrivileges that = (IndicesPrivileges) o;
+        return indices.equals(that.indices)
+                && privileges.equals(that.privileges)
+                && Objects.equals(grantedFields, that.grantedFields)
+                && Objects.equals(deniedFields, that.deniedFields)
+                && Objects.equals(query, that.query);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(indices, privileges, grantedFields, deniedFields, query);
+    }
+
+    @Override
+    public String toString() {
+        try {
+            return XContentHelper.toXContent(this, XContentType.JSON, true).utf8ToString();
+        } catch (IOException e) {
+            throw new RuntimeException("Unexpected", e);
+        }
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field(NAMES.getPreferredName(), indices);
+        builder.field(PRIVILEGES.getPreferredName(), privileges);
+        if (isUsingFieldLevelSecurity()) {
+            builder.startObject(FIELD_PERMISSIONS.getPreferredName());
+            if (grantedFields != null) {
+                builder.field(GRANT_FIELDS.getPreferredName(), grantedFields);
+            }
+            if (hasDeniedFields()) {
+                builder.field(EXCEPT_FIELDS.getPreferredName(), deniedFields);
+            }
+            builder.endObject();
+        }
+        if (isUsingDocumentLevelSecurity()) {
+            builder.field("query", query);
+        }
+        return builder.endObject();
+    }
+
+    public static IndicesPrivileges fromXContent(XContentParser parser) {
+        return PARSER.apply(parser, null);
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    public static final class Builder {
+
+        private @Nullable Collection<String> indices = null;
+        private @Nullable Collection<String> privileges = null;
+        private @Nullable Collection<String> grantedFields = null;
+        private @Nullable Collection<String> deniedFields = null;
+        private @Nullable String query = null;
+
+        private Builder() {
+        }
+
+        public Builder indices(String... indices) {
+            return indices(Arrays.asList(Objects.requireNonNull(indices, "indices required")));
+        }
+        
+        public Builder indices(Collection<String> indices) {
+            this.indices = Objects.requireNonNull(indices, "indices required");
+            return this;
+        }
+
+        public Builder privileges(String... privileges) {
+            return privileges(Arrays.asList(Objects.requireNonNull(privileges, "privileges required")));
+        }
+
+        public Builder privileges(Collection<String> privileges) {
+            this.privileges = Objects.requireNonNull(privileges, "privileges required");
+            return this;
+        }
+
+        public Builder grantedFields(@Nullable String... grantedFields) {
+            if (grantedFields == null) {
+                this.grantedFields = null;
+                return this;
+            }
+            return grantedFields(Arrays.asList(grantedFields));
+        }
+
+        public Builder grantedFields(@Nullable Collection<String> grantedFields) {
+            this.grantedFields = grantedFields;
+            return this;
+        }
+
+        public Builder deniedFields(@Nullable String... deniedFields) {
+            if (deniedFields == null) {
+                this.deniedFields = null;
+                return this;
+            }
+            return deniedFields(Arrays.asList(deniedFields));
+        }
+
+        public Builder deniedFields(@Nullable Collection<String> deniedFields) {
+            this.deniedFields = deniedFields;
+            return this;
+        }
+
+        public Builder query(@Nullable String query) {
+            this.query = query;
+            return this;
+        }
+
+        public IndicesPrivileges build() {
+            return new IndicesPrivileges(indices, privileges, grantedFields, deniedFields, query);
+        }
+    }
+
+}

+ 56 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/ManageApplicationPrivilege.java

@@ -0,0 +1,56 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.client.security.user.privileges;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Represents the privilege to "manage" certain applications. The "manage"
+ * privilege is actually defined outside of Elasticsearch.
+ */
+public class ManageApplicationPrivilege extends GlobalOperationPrivilege {
+
+    private static final String CATEGORY = "application";
+    private static final String OPERATION = "manage";
+    private static final String KEY = "applications";
+
+    public ManageApplicationPrivilege(Collection<String> applications) {
+        super(CATEGORY, OPERATION, Collections.singletonMap(KEY, new HashSet<String>(Objects.requireNonNull(applications))));
+    }
+
+    @SuppressWarnings("unchecked")
+    public Set<String> getManagedApplications() {
+        return (Set<String>)getRaw().get(KEY);
+    }
+    
+    @Override
+    public boolean equals(Object o) {
+        return super.equals(o);
+    }
+
+    @Override
+    public int hashCode() {
+        return super.hashCode();
+    }
+}

+ 310 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/Role.java

@@ -0,0 +1,310 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.client.security.user.privileges;
+
+import org.elasticsearch.common.Nullable;
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.common.xcontent.ObjectParser.ValueType;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+/**
+ * Represents an aggregation of privileges. This does not have a name
+ * identifier.
+ */
+public final class Role implements ToXContentObject {
+
+    public static final ParseField CLUSTER = new ParseField("cluster");
+    public static final ParseField GLOBAL = new ParseField("global");
+    public static final ParseField INDICES = new ParseField("indices");
+    public static final ParseField APPLICATIONS = new ParseField("applications");
+    public static final ParseField RUN_AS = new ParseField("run_as");
+    public static final ParseField METADATA = new ParseField("metadata");
+
+    @SuppressWarnings("unchecked")
+    private static final ConstructingObjectParser<Role, Void> PARSER = new ConstructingObjectParser<>("role_descriptor", false,
+            constructorObjects -> {
+                // Don't ignore unknown fields. It is dangerous if the object we parse is also
+                // part of a request that we build later on, and the fields that we now ignore
+                // will end up being implicitly set to null in that request.
+                int i = 0;
+                final Collection<String> clusterPrivileges = (Collection<String>) constructorObjects[i++];
+                final GlobalPrivileges globalApplicationPrivileges = (GlobalPrivileges) constructorObjects[i++];
+                final Collection<IndicesPrivileges> indicesPrivileges = (Collection<IndicesPrivileges>) constructorObjects[i++];
+                final Collection<ApplicationResourcePrivileges> applicationResourcePrivileges =
+                        (Collection<ApplicationResourcePrivileges>) constructorObjects[i++];
+                final Collection<String> runAsPrivilege = (Collection<String>) constructorObjects[i++];
+                final Map<String, Object> metadata = (Map<String, Object>) constructorObjects[i];
+                return new Role(clusterPrivileges, globalApplicationPrivileges, indicesPrivileges, applicationResourcePrivileges,
+                        runAsPrivilege, metadata);
+            });
+
+    static {
+        PARSER.declareStringArray(optionalConstructorArg(), CLUSTER);
+        PARSER.declareObject(optionalConstructorArg(), GlobalPrivileges.PARSER, GLOBAL);
+        PARSER.declareFieldArray(optionalConstructorArg(), IndicesPrivileges.PARSER, INDICES, ValueType.OBJECT_ARRAY);
+        PARSER.declareFieldArray(optionalConstructorArg(), ApplicationResourcePrivileges.PARSER, APPLICATIONS, ValueType.OBJECT_ARRAY);
+        PARSER.declareStringArray(optionalConstructorArg(), RUN_AS);
+        PARSER.declareObject(constructorArg(), (parser, c) -> parser.map(), METADATA);
+    }
+
+    private final Set<String> clusterPrivileges;
+    private final @Nullable GlobalPrivileges globalApplicationPrivileges;
+    private final Set<IndicesPrivileges> indicesPrivileges;
+    private final Set<ApplicationResourcePrivileges> applicationResourcePrivileges;
+    private final Set<String> runAsPrivilege;
+    private final Map<String, Object> metadata;
+
+    private Role(@Nullable Collection<String> clusterPrivileges, @Nullable GlobalPrivileges globalApplicationPrivileges,
+            @Nullable Collection<IndicesPrivileges> indicesPrivileges,
+            @Nullable Collection<ApplicationResourcePrivileges> applicationResourcePrivileges, @Nullable Collection<String> runAsPrivilege,
+            @Nullable Map<String, Object> metadata) {
+        // no cluster privileges are granted unless otherwise specified
+        this.clusterPrivileges = Collections
+                .unmodifiableSet(clusterPrivileges != null ? new HashSet<>(clusterPrivileges) : Collections.emptySet());
+        this.globalApplicationPrivileges = globalApplicationPrivileges;
+        // no indices privileges are granted unless otherwise specified
+        this.indicesPrivileges = Collections
+                .unmodifiableSet(indicesPrivileges != null ? new HashSet<>(indicesPrivileges) : Collections.emptySet());
+        // no application resource privileges are granted unless otherwise specified
+        this.applicationResourcePrivileges = Collections.unmodifiableSet(
+                applicationResourcePrivileges != null ? new HashSet<>(applicationResourcePrivileges) : Collections.emptySet());
+        // no run as privileges are granted unless otherwise specified
+        this.runAsPrivilege = Collections.unmodifiableSet(runAsPrivilege != null ? new HashSet<>(runAsPrivilege) : Collections.emptySet());
+        this.metadata = metadata != null ? Collections.unmodifiableMap(metadata) : Collections.emptyMap();
+    }
+
+    public Set<String> getClusterPrivileges() {
+        return clusterPrivileges;
+    }
+
+    public GlobalPrivileges getGlobalApplicationPrivileges() {
+        return globalApplicationPrivileges;
+    }
+
+    public Set<IndicesPrivileges> getIndicesPrivileges() {
+        return indicesPrivileges;
+    }
+
+    public Set<ApplicationResourcePrivileges> getApplicationResourcePrivileges() {
+        return applicationResourcePrivileges;
+    }
+
+    public Set<String> getRunAsPrivilege() {
+        return runAsPrivilege;
+    }
+
+    public Map<String, Object> getMetadata() {
+        return metadata;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Role that = (Role) o;
+        return clusterPrivileges.equals(that.clusterPrivileges)
+                && Objects.equals(globalApplicationPrivileges, that.globalApplicationPrivileges)
+                && indicesPrivileges.equals(that.indicesPrivileges)
+                && applicationResourcePrivileges.equals(that.applicationResourcePrivileges)
+                && runAsPrivilege.equals(that.runAsPrivilege)
+                && metadata.equals(that.metadata);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(clusterPrivileges, globalApplicationPrivileges, indicesPrivileges, applicationResourcePrivileges,
+                runAsPrivilege, metadata);
+    }
+
+    @Override
+    public String toString() {
+        try {
+            return XContentHelper.toXContent(this, XContentType.JSON, true).utf8ToString();
+        } catch (IOException e) {
+            throw new RuntimeException("Unexpected", e);
+        }
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        if (false == clusterPrivileges.isEmpty()) {
+            builder.field(CLUSTER.getPreferredName(), clusterPrivileges);
+        }
+        if (null != globalApplicationPrivileges) {
+            builder.field(GLOBAL.getPreferredName(), globalApplicationPrivileges);
+        }
+        if (false == indicesPrivileges.isEmpty()) {
+            builder.field(INDICES.getPreferredName(), indicesPrivileges);
+        }
+        if (false == applicationResourcePrivileges.isEmpty()) {
+            builder.field(APPLICATIONS.getPreferredName(), applicationResourcePrivileges);
+        }
+        if (false == runAsPrivilege.isEmpty()) {
+            builder.field(RUN_AS.getPreferredName(), runAsPrivilege);
+        }
+        if (false == metadata.isEmpty()) {
+            builder.field(METADATA.getPreferredName(), metadata);
+        }
+        return builder.endObject();
+    }
+
+    public static Role fromXContent(XContentParser parser) {
+        return PARSER.apply(parser, null);
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    public static final class Builder {
+
+        private @Nullable Collection<String> clusterPrivileges = null;
+        private @Nullable GlobalPrivileges globalApplicationPrivileges = null;
+        private @Nullable Collection<IndicesPrivileges> indicesPrivileges = null;
+        private @Nullable Collection<ApplicationResourcePrivileges> applicationResourcePrivileges = null;
+        private @Nullable Collection<String> runAsPrivilege = null;
+        private @Nullable Map<String, Object> metadata = null;
+
+        private Builder() {
+        }
+
+        public Builder clusterPrivileges(String... clusterPrivileges) {
+            return clusterPrivileges(Arrays
+                    .asList(Objects.requireNonNull(clusterPrivileges, "Cluster privileges cannot be null. Pass an empty array instead.")));
+        }
+
+        public Builder clusterPrivileges(Collection<String> clusterPrivileges) {
+            this.clusterPrivileges = Objects.requireNonNull(clusterPrivileges,
+                    "Cluster privileges cannot be null. Pass an empty collection instead.");
+            return this;
+        }
+
+        public Builder glabalApplicationPrivileges(GlobalPrivileges globalApplicationPrivileges) {
+            this.globalApplicationPrivileges = globalApplicationPrivileges;
+            return this;
+        }
+
+        public Builder indicesPrivileges(IndicesPrivileges... indicesPrivileges) {
+            return indicesPrivileges(Arrays
+                    .asList(Objects.requireNonNull(indicesPrivileges, "Indices privileges cannot be null. Pass an empty array instead.")));
+        }
+
+        public Builder indicesPrivileges(Collection<IndicesPrivileges> indicesPrivileges) {
+            this.indicesPrivileges = Objects.requireNonNull(indicesPrivileges,
+                    "Indices privileges cannot be null. Pass an empty collection instead.");
+            return this;
+        }
+
+        public Builder applicationResourcePrivileges(ApplicationResourcePrivileges... applicationResourcePrivileges) {
+            return applicationResourcePrivileges(Arrays.asList(Objects.requireNonNull(applicationResourcePrivileges,
+                    "Application resource privileges cannot be null. Pass an empty array instead.")));
+        }
+
+        public Builder applicationResourcePrivileges(Collection<ApplicationResourcePrivileges> applicationResourcePrivileges) {
+            this.applicationResourcePrivileges = Objects.requireNonNull(applicationResourcePrivileges,
+                    "Application resource privileges cannot be null. Pass an empty collection instead.");
+            return this;
+        }
+
+        public Builder runAsPrivilege(String... runAsPrivilege) {
+            return runAsPrivilege(Arrays
+                    .asList(Objects.requireNonNull(runAsPrivilege, "Run as privilege cannot be null. Pass an empty array instead.")));
+        }
+
+        public Builder runAsPrivilege(Collection<String> runAsPrivilege) {
+            this.runAsPrivilege = Objects.requireNonNull(runAsPrivilege,
+                    "Run as privilege cannot be null. Pass an empty collection instead.");
+            return this;
+        }
+
+        public Builder metadata(Map<String, Object> metadata) {
+            this.metadata = Objects.requireNonNull(metadata, "Metadata cannot be null. Pass an empty map instead.");
+            return this;
+        }
+
+        public Role build() {
+            return new Role(clusterPrivileges, globalApplicationPrivileges, indicesPrivileges, applicationResourcePrivileges,
+                    runAsPrivilege, metadata);
+        }
+    }
+
+    /**
+     * Canonical cluster privilege names. There is no enforcement to only use these.
+     */
+    public static class ClusterPrivilegeName {
+        public static final String NONE = "none";
+        public static final String ALL = "all";
+        public static final String MONITOR = "monitor";
+        public static final String MONITOR_ML = "monitor_ml";
+        public static final String MONITOR_WATCHER = "monitor_watcher";
+        public static final String MONITOR_ROLLUP = "monitor_rollup";
+        public static final String MANAGE = "manage";
+        public static final String MANAGE_ML = "manage_ml";
+        public static final String MANAGE_WATCHER = "manage_watcher";
+        public static final String MANAGE_ROLLUP = "manage_rollup";
+        public static final String MANAGE_INDEX_TEMPLATES = "manage_index_templates";
+        public static final String MANAGE_INGEST_PIPELINES = "manage_ingest_pipelines";
+        public static final String TRANSPORT_CLIENT = "transport_client";
+        public static final String MANAGE_SECURITY = "manage_security";
+        public static final String MANAGE_SAML = "manage_saml";
+        public static final String MANAGE_PIPELINE = "manage_pipeline";
+        public static final String MANAGE_CCR = "manage_ccr";
+        public static final String READ_CCR = "read_ccr";
+    }
+
+    /**
+     * Canonical index privilege names. There is no enforcement to only use these.
+     */
+    public static class IndexPrivilegeName {
+        public static final String NONE = "none";
+        public static final String ALL = "all";
+        public static final String READ = "read";
+        public static final String READ_CROSS = "read_cross_cluster";
+        public static final String CREATE = "create";
+        public static final String INDEX = "index";
+        public static final String DELETE = "delete";
+        public static final String WRITE = "write";
+        public static final String MONITOR = "monitor";
+        public static final String MANAGE = "manage";
+        public static final String DELETE_INDEX = "delete_index";
+        public static final String CREATE_INDEX = "create_index";
+        public static final String VIEW_INDEX_METADATA = "view_index_metadata";
+        public static final String MANAGE_FOLLOW_INDEX = "manage_follow_index";
+    }
+
+}

+ 77 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/security/user/privileges/ApplicationResourcePrivilegesTests.java

@@ -0,0 +1,77 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.client.security.user.privileges;
+
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.test.AbstractXContentTestCase;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+
+import static org.hamcrest.Matchers.is;
+
+public class ApplicationResourcePrivilegesTests extends AbstractXContentTestCase<ApplicationResourcePrivileges> {
+
+    @Override
+    protected ApplicationResourcePrivileges createTestInstance() {
+        return new ApplicationResourcePrivileges(randomAlphaOfLengthBetween(1, 8),
+                Arrays.asList(randomArray(1, 8, size -> new String[size], () -> randomAlphaOfLengthBetween(1, 8))),
+                Arrays.asList(randomArray(1, 8, size -> new String[size], () -> randomAlphaOfLengthBetween(1, 8))));
+    }
+
+    @Override
+    protected ApplicationResourcePrivileges doParseInstance(XContentParser parser) throws IOException {
+        return ApplicationResourcePrivileges.fromXContent(parser);
+    }
+
+    @Override
+    protected boolean supportsUnknownFields() {
+        return false;
+    }
+
+    public void testEmptyApplicationName() {
+        final String emptyApplicationName = randomBoolean() ? "" : null;
+        final IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
+                () -> new ApplicationResourcePrivileges(emptyApplicationName,
+                        Arrays.asList(randomArray(1, 8, size -> new String[size], () -> randomAlphaOfLengthBetween(1, 8))),
+                        Arrays.asList(randomArray(1, 8, size -> new String[size], () -> randomAlphaOfLengthBetween(1, 8)))));
+        assertThat(e.getMessage(), is("application privileges must have an application name"));
+    }
+
+    public void testEmptyPrivileges() {
+        final Collection<String> emptyPrivileges = randomBoolean() ? Collections.emptyList() : null;
+        final IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
+                () -> new ApplicationResourcePrivileges(randomAlphaOfLengthBetween(1, 8),
+                        emptyPrivileges,
+                        Arrays.asList(randomArray(1, 8, size -> new String[size], () -> randomAlphaOfLengthBetween(1, 8)))));
+        assertThat(e.getMessage(), is("application privileges must define at least one privilege"));
+    }
+
+    public void testEmptyResources() {
+        final Collection<String> emptyResources = randomBoolean() ? Collections.emptyList() : null;
+        final IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
+                () -> new ApplicationResourcePrivileges(randomAlphaOfLengthBetween(1, 8),
+                        Arrays.asList(randomArray(1, 8, size -> new String[size], () -> randomAlphaOfLengthBetween(1, 8))),
+                        emptyResources));
+        assertThat(e.getMessage(), is("application privileges must refer to at least one resource"));
+    }
+}

+ 94 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/security/user/privileges/GlobalPrivilegesTests.java

@@ -0,0 +1,94 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.client.security.user.privileges;
+
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.test.AbstractXContentTestCase;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.is;
+
+public class GlobalPrivilegesTests extends AbstractXContentTestCase<GlobalPrivileges> {
+
+    private static long idCounter = 0;
+
+    @Override
+    protected GlobalPrivileges createTestInstance() {
+        final List<GlobalOperationPrivilege> privilegeList = Arrays
+                .asList(randomArray(1, 4, size -> new GlobalOperationPrivilege[size], () -> buildRandomGlobalScopedPrivilege()));
+        return new GlobalPrivileges(privilegeList);
+    }
+
+    @Override
+    protected GlobalPrivileges doParseInstance(XContentParser parser) throws IOException {
+        return GlobalPrivileges.fromXContent(parser);
+    }
+
+    @Override
+    protected boolean supportsUnknownFields() {
+        return false; // true really means inserting bogus privileges
+    }
+    
+    public void testEmptyOrNullGlobalOperationPrivilege() {
+        final Map<String, Object> privilege = randomBoolean() ? null : Collections.emptyMap();
+        final IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
+                () -> new GlobalOperationPrivilege(randomAlphaOfLength(2), randomAlphaOfLength(2), privilege));
+        assertThat(e.getMessage(), is("Privileges cannot be empty or null"));
+    }
+
+    public void testEmptyOrNullGlobalPrivileges() {
+        final List<GlobalOperationPrivilege> privileges = randomBoolean() ? null : Collections.emptyList();
+        final IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> new GlobalPrivileges(privileges));
+        assertThat(e.getMessage(), is("Privileges cannot be empty or null"));
+    }
+
+    public void testDuplicateGlobalOperationPrivilege() {
+        final GlobalOperationPrivilege privilege = buildRandomGlobalScopedPrivilege();
+        // duplicate
+        final GlobalOperationPrivilege privilege2 = new GlobalOperationPrivilege(privilege.getCategory(), privilege.getOperation(),
+                new HashMap<>(privilege.getRaw()));
+        final GlobalPrivileges globalPrivilege = new GlobalPrivileges(Arrays.asList(privilege, privilege2));
+        assertThat(globalPrivilege.getPrivileges().size(), is(1));
+        assertThat(globalPrivilege.getPrivileges().iterator().next(), is(privilege));
+    }
+
+    public void testSameScopeGlobalOperationPrivilege() {
+        final GlobalOperationPrivilege privilege = buildRandomGlobalScopedPrivilege();
+        final GlobalOperationPrivilege sameOperationPrivilege = new GlobalOperationPrivilege(privilege.getCategory(),
+                privilege.getOperation(), buildRandomGlobalScopedPrivilege().getRaw());
+        IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
+                () -> new GlobalPrivileges(Arrays.asList(privilege, sameOperationPrivilege)));
+        assertThat(e.getMessage(), is("Different privileges for the same category and operation are not permitted"));
+    }
+
+    private static GlobalOperationPrivilege buildRandomGlobalScopedPrivilege() {
+        final Map<String, Object> privilege = new HashMap<>();
+        for (int i = 0; i < randomIntBetween(1, 4); i++) {
+            privilege.put(randomAlphaOfLength(2) + idCounter++, randomAlphaOfLengthBetween(1, 4));
+        }
+        return new GlobalOperationPrivilege("application", randomAlphaOfLength(2) + idCounter++, privilege);
+    }
+}