diff --git a/docs/reference/rest-api/info.asciidoc b/docs/reference/rest-api/info.asciidoc index 5f4176b44069c..ff0abac56d0bb 100644 --- a/docs/reference/rest-api/info.asciidoc +++ b/docs/reference/rest-api/info.asciidoc @@ -103,6 +103,10 @@ Example response: "available" : true, "enabled" : true }, + "operator_privileges": { + "available": true, + "enabled": false + }, "rollup": { "available": true, "enabled": true diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java index d2c7754b59a67..0968105288a20 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java @@ -100,7 +100,9 @@ public enum Feature { ANALYTICS(OperationMode.MISSING, true), - SEARCHABLE_SNAPSHOTS(OperationMode.ENTERPRISE, true); + SEARCHABLE_SNAPSHOTS(OperationMode.ENTERPRISE, true), + + OPERATOR_PRIVILEGES(OperationMode.ENTERPRISE, true); final OperationMode minimumOperationMode; final boolean needsActive; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackField.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackField.java index 1d95209f7911a..698547f5843f7 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackField.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackField.java @@ -67,6 +67,8 @@ public final class XPackField { public static final String DATA_TIERS = "data_tiers"; /** Name constant for the aggregate_metric plugin. */ public static final String AGGREGATE_METRIC = "aggregate_metric"; + /** Name constant for the operator privileges feature. */ + public static final String OPERATOR_PRIVILEGES = "operator_privileges"; private XPackField() {} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/action/XPackInfoFeatureAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/action/XPackInfoFeatureAction.java index 08bfe678bb910..493d991428a7f 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/action/XPackInfoFeatureAction.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/action/XPackInfoFeatureAction.java @@ -47,6 +47,7 @@ public class XPackInfoFeatureAction extends ActionType public static final XPackInfoFeatureAction DATA_STREAMS = new XPackInfoFeatureAction(XPackField.DATA_STREAMS); public static final XPackInfoFeatureAction DATA_TIERS = new XPackInfoFeatureAction(XPackField.DATA_TIERS); public static final XPackInfoFeatureAction AGGREGATE_METRIC = new XPackInfoFeatureAction(XPackField.AGGREGATE_METRIC); + public static final XPackInfoFeatureAction OPERATOR_PRIVILEGES = new XPackInfoFeatureAction(XPackField.OPERATOR_PRIVILEGES); public static final List ALL; static { @@ -54,7 +55,7 @@ public class XPackInfoFeatureAction extends ActionType actions.addAll(Arrays.asList( SECURITY, MONITORING, WATCHER, GRAPH, MACHINE_LEARNING, LOGSTASH, EQL, SQL, ROLLUP, INDEX_LIFECYCLE, SNAPSHOT_LIFECYCLE, CCR, TRANSFORM, VECTORS, VOTING_ONLY, FROZEN_INDICES, SPATIAL, ANALYTICS, ENRICH, DATA_STREAMS, SEARCHABLE_SNAPSHOTS, DATA_TIERS, - AGGREGATE_METRIC + AGGREGATE_METRIC, OPERATOR_PRIVILEGES )); ALL = Collections.unmodifiableList(actions); } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/AuthenticationField.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/AuthenticationField.java index 12fab154b8ef7..e90bfa7b7c251 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/AuthenticationField.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/AuthenticationField.java @@ -10,6 +10,8 @@ public final class AuthenticationField { public static final String AUTHENTICATION_KEY = "_xpack_security_authentication"; public static final String API_KEY_ROLE_DESCRIPTORS_KEY = "_security_api_key_role_descriptors"; public static final String API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY = "_security_api_key_limited_by_role_descriptors"; + public static final String PRIVILEGE_CATEGORY_KEY = "_security_privilege_category"; + public static final String PRIVILEGE_CATEGORY_VALUE_OPERATOR = "operator"; private AuthenticationField() {} } diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/build.gradle b/x-pack/plugin/security/qa/operator-privileges-tests/build.gradle new file mode 100644 index 0000000000000..f4afcb9d30c58 --- /dev/null +++ b/x-pack/plugin/security/qa/operator-privileges-tests/build.gradle @@ -0,0 +1,33 @@ +apply plugin: 'elasticsearch.esplugin' +apply plugin: 'elasticsearch.java-rest-test' + +esplugin { + name 'operator-privileges-test' + description 'An test plugin for testing hard to get internals' + classname 'org.elasticsearch.xpack.security.operator.OperatorPrivilegesTestPlugin' +} + +dependencies { + compileOnly project(':x-pack:plugin:core') + javaRestTestImplementation project(':x-pack:plugin:core') + javaRestTestImplementation project(':client:rest-high-level') + javaRestTestImplementation project(':x-pack:plugin:security') + // let the javaRestTest see the classpath of main + javaRestTestImplementation project.sourceSets.main.runtimeClasspath +} + +testClusters.all { + testDistribution = 'DEFAULT' + numberOfNodes = 3 + + extraConfigFile 'operator_users.yml', file('src/javaRestTest/resources/operator_users.yml') + extraConfigFile 'roles.yml', file('src/javaRestTest/resources/roles.yml') + + setting 'xpack.license.self_generated.type', 'trial' + setting 'xpack.security.enabled', 'true' + setting 'xpack.security.http.ssl.enabled', 'false' + setting 'xpack.security.operator_privileges.enabled', "true" + + user username: "test_admin", password: 'x-pack-test-password', role: "superuser" + user username: "test_operator", password: 'x-pack-test-password', role: "limited_operator" +} diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java new file mode 100644 index 0000000000000..5f26944a1c921 --- /dev/null +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java @@ -0,0 +1,422 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import java.util.Set; + +public class Constants { + + public static final Set NON_OPERATOR_ACTIONS = Set.of( + // "cluster:admin/autoscaling/delete_autoscaling_policy", + // "cluster:admin/autoscaling/get_autoscaling_capacity", + // "cluster:admin/autoscaling/get_autoscaling_policy", + // "cluster:admin/autoscaling/put_autoscaling_policy", + "cluster:admin/component_template/delete", + "cluster:admin/component_template/get", + "cluster:admin/component_template/put", + "cluster:admin/data_frame/delete", + "cluster:admin/data_frame/preview", + "cluster:admin/data_frame/put", + "cluster:admin/data_frame/start", + "cluster:admin/data_frame/stop", + "cluster:admin/data_frame/update", + "cluster:admin/ilm/_move/post", + "cluster:admin/ilm/delete", + "cluster:admin/ilm/get", + "cluster:admin/ilm/operation_mode/get", + "cluster:admin/ilm/put", + "cluster:admin/ilm/start", + "cluster:admin/ilm/stop", + "cluster:admin/indices/dangling/delete", + "cluster:admin/indices/dangling/find", + "cluster:admin/indices/dangling/import", + "cluster:admin/indices/dangling/list", + "cluster:admin/ingest/pipeline/delete", + "cluster:admin/ingest/pipeline/get", + "cluster:admin/ingest/pipeline/put", + "cluster:admin/ingest/pipeline/simulate", + "cluster:admin/ingest/processor/grok/get", + "cluster:admin/logstash/pipeline/delete", + "cluster:admin/logstash/pipeline/get", + "cluster:admin/logstash/pipeline/put", + "cluster:admin/nodes/reload_secure_settings", + "cluster:admin/persistent/completion", + "cluster:admin/persistent/remove", + "cluster:admin/persistent/start", + "cluster:admin/persistent/update_status", + "cluster:admin/reindex/rethrottle", + "cluster:admin/repository/_cleanup", + "cluster:admin/repository/delete", + "cluster:admin/repository/get", + "cluster:admin/repository/put", + "cluster:admin/repository/verify", + "cluster:admin/reroute", + "cluster:admin/script/delete", + "cluster:admin/script/get", + "cluster:admin/script/put", + "cluster:admin/script_context/get", + "cluster:admin/script_language/get", + "cluster:admin/scripts/painless/context", + "cluster:admin/scripts/painless/execute", + "cluster:admin/settings/update", + "cluster:admin/slm/delete", + "cluster:admin/slm/execute", + "cluster:admin/slm/execute-retention", + "cluster:admin/slm/get", + "cluster:admin/slm/put", + "cluster:admin/slm/start", + "cluster:admin/slm/stats", + "cluster:admin/slm/status", + "cluster:admin/slm/stop", + "cluster:admin/snapshot/clone", + "cluster:admin/snapshot/create", + "cluster:admin/snapshot/delete", + "cluster:admin/snapshot/get", + "cluster:admin/snapshot/mount", + "cluster:admin/snapshot/restore", + "cluster:admin/snapshot/status", + "cluster:admin/snapshot/status[nodes]", + "cluster:admin/tasks/cancel", + "cluster:admin/transform/delete", + "cluster:admin/transform/preview", + "cluster:admin/transform/put", + "cluster:admin/transform/start", + "cluster:admin/transform/stop", + "cluster:admin/transform/update", + // "cluster:admin/voting_config/add_exclusions", + // "cluster:admin/voting_config/clear_exclusions", + "cluster:admin/xpack/ccr/auto_follow_pattern/activate", + "cluster:admin/xpack/ccr/auto_follow_pattern/delete", + "cluster:admin/xpack/ccr/auto_follow_pattern/get", + "cluster:admin/xpack/ccr/auto_follow_pattern/put", + "cluster:admin/xpack/ccr/pause_follow", + "cluster:admin/xpack/ccr/resume_follow", + "cluster:admin/xpack/deprecation/info", + "cluster:admin/xpack/deprecation/nodes/info", + "cluster:admin/xpack/enrich/delete", + "cluster:admin/xpack/enrich/execute", + "cluster:admin/xpack/enrich/get", + "cluster:admin/xpack/enrich/put", + "cluster:admin/xpack/license/basic_status", + // "cluster:admin/xpack/license/delete", + "cluster:admin/xpack/license/feature_usage", + // "cluster:admin/xpack/license/put", + "cluster:admin/xpack/license/start_basic", + "cluster:admin/xpack/license/start_trial", + "cluster:admin/xpack/license/trial_status", + "cluster:admin/xpack/ml/calendars/delete", + "cluster:admin/xpack/ml/calendars/events/delete", + "cluster:admin/xpack/ml/calendars/events/post", + "cluster:admin/xpack/ml/calendars/jobs/update", + "cluster:admin/xpack/ml/calendars/put", + "cluster:admin/xpack/ml/data_frame/analytics/delete", + "cluster:admin/xpack/ml/data_frame/analytics/explain", + "cluster:admin/xpack/ml/data_frame/analytics/put", + "cluster:admin/xpack/ml/data_frame/analytics/start", + "cluster:admin/xpack/ml/data_frame/analytics/stop", + "cluster:admin/xpack/ml/data_frame/analytics/update", + "cluster:admin/xpack/ml/datafeed/start", + "cluster:admin/xpack/ml/datafeed/stop", + "cluster:admin/xpack/ml/datafeeds/delete", + "cluster:admin/xpack/ml/datafeeds/preview", + "cluster:admin/xpack/ml/datafeeds/put", + "cluster:admin/xpack/ml/datafeeds/update", + "cluster:admin/xpack/ml/delete_expired_data", + "cluster:admin/xpack/ml/filters/delete", + "cluster:admin/xpack/ml/filters/get", + "cluster:admin/xpack/ml/filters/put", + "cluster:admin/xpack/ml/filters/update", + "cluster:admin/xpack/ml/inference/delete", + "cluster:admin/xpack/ml/inference/put", + "cluster:admin/xpack/ml/job/close", + "cluster:admin/xpack/ml/job/data/post", + "cluster:admin/xpack/ml/job/delete", + "cluster:admin/xpack/ml/job/estimate_model_memory", + "cluster:admin/xpack/ml/job/flush", + "cluster:admin/xpack/ml/job/forecast", + "cluster:admin/xpack/ml/job/forecast/delete", + "cluster:admin/xpack/ml/job/model_snapshots/delete", + "cluster:admin/xpack/ml/job/model_snapshots/revert", + "cluster:admin/xpack/ml/job/model_snapshots/update", + "cluster:admin/xpack/ml/job/model_snapshots/upgrade", + "cluster:admin/xpack/ml/job/open", + "cluster:admin/xpack/ml/job/persist", + "cluster:admin/xpack/ml/job/put", + "cluster:admin/xpack/ml/job/update", + "cluster:admin/xpack/ml/job/validate", + "cluster:admin/xpack/ml/job/validate/detector", + "cluster:admin/xpack/ml/upgrade_mode", + "cluster:admin/xpack/monitoring/bulk", + "cluster:admin/xpack/rollup/delete", + "cluster:admin/xpack/rollup/put", + "cluster:admin/xpack/rollup/start", + "cluster:admin/xpack/rollup/stop", + "cluster:admin/xpack/searchable_snapshots/cache/clear", + "cluster:admin/xpack/security/api_key/create", + "cluster:admin/xpack/security/api_key/get", + "cluster:admin/xpack/security/api_key/grant", + "cluster:admin/xpack/security/api_key/invalidate", + "cluster:admin/xpack/security/cache/clear", + "cluster:admin/xpack/security/delegate_pki", + "cluster:admin/xpack/security/oidc/authenticate", + "cluster:admin/xpack/security/oidc/logout", + "cluster:admin/xpack/security/oidc/prepare", + "cluster:admin/xpack/security/privilege/builtin/get", + "cluster:admin/xpack/security/privilege/cache/clear", + "cluster:admin/xpack/security/privilege/delete", + "cluster:admin/xpack/security/privilege/get", + "cluster:admin/xpack/security/privilege/put", + "cluster:admin/xpack/security/realm/cache/clear", + "cluster:admin/xpack/security/role/delete", + "cluster:admin/xpack/security/role/get", + "cluster:admin/xpack/security/role/put", + "cluster:admin/xpack/security/role_mapping/delete", + "cluster:admin/xpack/security/role_mapping/get", + "cluster:admin/xpack/security/role_mapping/put", + "cluster:admin/xpack/security/roles/cache/clear", + "cluster:admin/xpack/security/saml/authenticate", + "cluster:admin/xpack/security/saml/complete_logout", + "cluster:admin/xpack/security/saml/invalidate", + "cluster:admin/xpack/security/saml/logout", + "cluster:admin/xpack/security/saml/prepare", + "cluster:admin/xpack/security/token/create", + "cluster:admin/xpack/security/token/invalidate", + "cluster:admin/xpack/security/token/refresh", + "cluster:admin/xpack/security/user/authenticate", + "cluster:admin/xpack/security/user/change_password", + "cluster:admin/xpack/security/user/delete", + "cluster:admin/xpack/security/user/get", + "cluster:admin/xpack/security/user/has_privileges", + "cluster:admin/xpack/security/user/list_privileges", + "cluster:admin/xpack/security/user/put", + "cluster:admin/xpack/security/user/set_enabled", + "cluster:admin/xpack/watcher/service", + "cluster:admin/xpack/watcher/watch/ack", + "cluster:admin/xpack/watcher/watch/activate", + "cluster:admin/xpack/watcher/watch/delete", + "cluster:admin/xpack/watcher/watch/execute", + "cluster:admin/xpack/watcher/watch/put", + "cluster:internal/xpack/ml/datafeed/isolate", + "cluster:internal/xpack/ml/inference/infer", + "cluster:internal/xpack/ml/job/finalize_job_execution", + "cluster:internal/xpack/ml/job/kill/process", + "cluster:internal/xpack/ml/job/update/process", + "cluster:monitor/allocation/explain", + "cluster:monitor/async_search/status", + "cluster:monitor/ccr/follow_info", + "cluster:monitor/ccr/follow_stats", + "cluster:monitor/ccr/stats", + "cluster:monitor/data_frame/get", + "cluster:monitor/data_frame/stats/get", + "cluster:monitor/health", + "cluster:monitor/main", + "cluster:monitor/nodes/hot_threads", + "cluster:monitor/nodes/info", + "cluster:monitor/nodes/stats", + "cluster:monitor/nodes/usage", + "cluster:monitor/remote/info", + "cluster:monitor/state", + "cluster:monitor/stats", + "cluster:monitor/task", + "cluster:monitor/task/get", + "cluster:monitor/tasks/lists", + "cluster:monitor/transform/get", + "cluster:monitor/transform/stats/get", + "cluster:monitor/xpack/analytics/stats", + "cluster:monitor/xpack/enrich/coordinator_stats", + "cluster:monitor/xpack/enrich/stats", + "cluster:monitor/xpack/eql/stats/dist", + "cluster:monitor/xpack/info", + "cluster:monitor/xpack/info/aggregate_metric", + "cluster:monitor/xpack/info/analytics", + "cluster:monitor/xpack/info/ccr", + "cluster:monitor/xpack/info/data_streams", + "cluster:monitor/xpack/info/data_tiers", + "cluster:monitor/xpack/info/enrich", + "cluster:monitor/xpack/info/eql", + "cluster:monitor/xpack/info/frozen_indices", + "cluster:monitor/xpack/info/graph", + "cluster:monitor/xpack/info/ilm", + "cluster:monitor/xpack/info/logstash", + "cluster:monitor/xpack/info/ml", + "cluster:monitor/xpack/info/monitoring", + "cluster:monitor/xpack/info/operator_privileges", + "cluster:monitor/xpack/info/rollup", + "cluster:monitor/xpack/info/searchable_snapshots", + "cluster:monitor/xpack/info/security", + "cluster:monitor/xpack/info/slm", + "cluster:monitor/xpack/info/spatial", + "cluster:monitor/xpack/info/sql", + "cluster:monitor/xpack/info/transform", + "cluster:monitor/xpack/info/vectors", + "cluster:monitor/xpack/info/voting_only", + "cluster:monitor/xpack/info/watcher", + "cluster:monitor/xpack/license/get", + "cluster:monitor/xpack/ml/calendars/events/get", + "cluster:monitor/xpack/ml/calendars/get", + "cluster:monitor/xpack/ml/data_frame/analytics/get", + "cluster:monitor/xpack/ml/data_frame/analytics/stats/get", + "cluster:monitor/xpack/ml/data_frame/evaluate", + "cluster:monitor/xpack/ml/datafeeds/get", + "cluster:monitor/xpack/ml/datafeeds/stats/get", + "cluster:monitor/xpack/ml/findfilestructure", + "cluster:monitor/xpack/ml/inference/get", + "cluster:monitor/xpack/ml/inference/stats/get", + "cluster:monitor/xpack/ml/info/get", + "cluster:monitor/xpack/ml/job/get", + "cluster:monitor/xpack/ml/job/model_snapshots/get", + "cluster:monitor/xpack/ml/job/results/buckets/get", + "cluster:monitor/xpack/ml/job/results/categories/get", + "cluster:monitor/xpack/ml/job/results/influencers/get", + "cluster:monitor/xpack/ml/job/results/overall_buckets/get", + "cluster:monitor/xpack/ml/job/results/records/get", + "cluster:monitor/xpack/ml/job/stats/get", + "cluster:monitor/xpack/repositories_metering/clear_metering_archive", + "cluster:monitor/xpack/repositories_metering/get_metrics", + "cluster:monitor/xpack/rollup/get", + "cluster:monitor/xpack/rollup/get/caps", + "cluster:monitor/xpack/searchable_snapshots/stats", + "cluster:monitor/xpack/security/saml/metadata", + "cluster:monitor/xpack/spatial/stats", + "cluster:monitor/xpack/sql/stats/dist", + "cluster:monitor/xpack/ssl/certificates/get", + "cluster:monitor/xpack/usage", + "cluster:monitor/xpack/usage/aggregate_metric", + "cluster:monitor/xpack/usage/analytics", + "cluster:monitor/xpack/usage/ccr", + "cluster:monitor/xpack/usage/data_streams", + "cluster:monitor/xpack/usage/data_tiers", + "cluster:monitor/xpack/usage/enrich", + "cluster:monitor/xpack/usage/eql", + "cluster:monitor/xpack/usage/frozen_indices", + "cluster:monitor/xpack/usage/graph", + "cluster:monitor/xpack/usage/ilm", + "cluster:monitor/xpack/usage/logstash", + "cluster:monitor/xpack/usage/ml", + "cluster:monitor/xpack/usage/monitoring", + "cluster:monitor/xpack/usage/rollup", + "cluster:monitor/xpack/usage/searchable_snapshots", + "cluster:monitor/xpack/usage/security", + "cluster:monitor/xpack/usage/slm", + "cluster:monitor/xpack/usage/spatial", + "cluster:monitor/xpack/usage/sql", + "cluster:monitor/xpack/usage/transform", + "cluster:monitor/xpack/usage/vectors", + "cluster:monitor/xpack/usage/voting_only", + "cluster:monitor/xpack/usage/watcher", + "cluster:monitor/xpack/watcher/stats/dist", + "cluster:monitor/xpack/watcher/watch/get", + "indices:admin/aliases", + "indices:admin/aliases/get", + "indices:admin/analyze", + "indices:admin/auto_create", + "indices:admin/block/add", + "indices:admin/block/add[s]", + "indices:admin/cache/clear", + "indices:admin/close", + "indices:admin/close[s]", + "indices:admin/create", + "indices:admin/data_stream/create", + "indices:admin/data_stream/delete", + "indices:admin/data_stream/get", + "indices:admin/data_stream/migrate", + "indices:admin/delete", + "indices:admin/flush", + "indices:admin/flush[s]", + "indices:admin/forcemerge", + "indices:admin/freeze", + "indices:admin/get", + "indices:admin/ilm/explain", + "indices:admin/ilm/remove_policy", + "indices:admin/ilm/retry", + "indices:admin/index_template/delete", + "indices:admin/index_template/get", + "indices:admin/index_template/put", + "indices:admin/index_template/simulate", + "indices:admin/index_template/simulate_index", + "indices:admin/mapping/auto_put", + "indices:admin/mapping/put", + "indices:admin/mappings/fields/get", + "indices:admin/mappings/fields/get[index]", + "indices:admin/mappings/get", + "indices:admin/open", + "indices:admin/refresh", + "indices:admin/refresh[s]", + "indices:admin/reload_analyzers", + "indices:admin/resize", + "indices:admin/resolve/index", + "indices:admin/rollover", + "indices:admin/seq_no/add_retention_lease", + "indices:admin/seq_no/global_checkpoint_sync", + "indices:admin/seq_no/remove_retention_lease", + "indices:admin/seq_no/renew_retention_lease", + "indices:admin/settings/update", + "indices:admin/shards/search_shards", + "indices:admin/template/delete", + "indices:admin/template/get", + "indices:admin/template/put", + "indices:admin/validate/query", + "indices:admin/xpack/ccr/forget_follower", + "indices:admin/xpack/ccr/put_follow", + "indices:admin/xpack/ccr/unfollow", + "indices:data/read/async_search/delete", + "indices:data/read/async_search/get", + "indices:data/read/async_search/submit", + "indices:data/read/close_point_in_time", + "indices:data/read/eql", + "indices:data/read/eql/async/get", + "indices:data/read/explain", + "indices:data/read/field_caps", + "indices:data/read/field_caps[index]", + "indices:data/read/get", + "indices:data/read/mget", + "indices:data/read/mget[shard]", + "indices:data/read/msearch", + "indices:data/read/msearch/template", + "indices:data/read/mtv", + "indices:data/read/mtv[shard]", + "indices:data/read/open_point_in_time", + "indices:data/read/rank_eval", + "indices:data/read/scroll", + "indices:data/read/scroll/clear", + "indices:data/read/search", + "indices:data/read/search/template", + "indices:data/read/shard_multi_search", + "indices:data/read/sql", + "indices:data/read/sql/close_cursor", + "indices:data/read/sql/translate", + "indices:data/read/tv", + "indices:data/read/xpack/ccr/shard_changes", + "indices:data/read/xpack/enrich/coordinate_lookups", + "indices:data/read/xpack/graph/explore", + "indices:data/read/xpack/rollup/get/index/caps", + "indices:data/read/xpack/rollup/search", + "indices:data/write/bulk", + "indices:data/write/bulk[s]", + "indices:data/write/bulk_shard_operations[s]", + "indices:data/write/delete", + "indices:data/write/delete/byquery", + "indices:data/write/index", + "indices:data/write/reindex", + "indices:data/write/update", + "indices:data/write/update/byquery", + "indices:monitor/data_stream/stats", + "indices:monitor/recovery", + "indices:monitor/segments", + "indices:monitor/settings/get", + "indices:monitor/shard_stores", + "indices:monitor/stats", + "internal:admin/ccr/internal_repository/delete", + "internal:admin/ccr/internal_repository/put", + "internal:admin/ccr/restore/file_chunk/get", + "internal:admin/ccr/restore/session/clear", + "internal:admin/ccr/restore/session/put", + "internal:cluster/nodes/indices/shard/store", + "internal:gateway/local/meta_state", + "internal:gateway/local/started_shards" + ); +} diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesIT.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesIT.java new file mode 100644 index 0000000000000..e4389a4f60721 --- /dev/null +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesIT.java @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.security.operator; + +import org.elasticsearch.client.Request; +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.ResponseException; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.test.rest.ESRestTestCase; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +public class OperatorPrivilegesIT extends ESRestTestCase { + + @Override + protected Settings restClientSettings() { + String token = basicAuthHeaderValue("test_admin", new SecureString("x-pack-test-password".toCharArray())); + return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build(); + } + + public void testNonOperatorSuperuserWillFailToCallOperatorOnlyApiWhenOperatorPrivilegesIsEnabled() throws IOException { + final Request postVotingConfigExclusionsRequest = new Request("POST", "_cluster/voting_config_exclusions?node_names=foo"); + final ResponseException responseException = expectThrows( + ResponseException.class, + () -> client().performRequest(postVotingConfigExclusionsRequest) + ); + assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat(responseException.getMessage(), containsString("Operator privileges are required for action")); + } + + public void testOperatorUserWillSucceedToCallOperatorOnlyApi() throws IOException { + final Request postVotingConfigExclusionsRequest = new Request("POST", "_cluster/voting_config_exclusions?node_names=foo"); + final String authHeader = "Basic " + + Base64.getEncoder().encodeToString("test_operator:x-pack-test-password".getBytes(StandardCharsets.UTF_8)); + postVotingConfigExclusionsRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", authHeader)); + client().performRequest(postVotingConfigExclusionsRequest); + } + + public void testOperatorUserWillFailToCallOperatorOnlyApiIfRbacFails() throws IOException { + final Request deleteVotingConfigExclusionsRequest = new Request("DELETE", "_cluster/voting_config_exclusions"); + final String authHeader = "Basic " + + Base64.getEncoder().encodeToString("test_operator:x-pack-test-password".getBytes(StandardCharsets.UTF_8)); + deleteVotingConfigExclusionsRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", authHeader)); + final ResponseException responseException = expectThrows( + ResponseException.class, + () -> client().performRequest(deleteVotingConfigExclusionsRequest) + ); + assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat(responseException.getMessage(), containsString("is unauthorized for user")); + } + + public void testOperatorUserCanCallNonOperatorOnlyApi() throws IOException { + final Request mainRequest = new Request("GET", "/"); + final String authHeader = "Basic " + + Base64.getEncoder().encodeToString("test_operator:x-pack-test-password".getBytes(StandardCharsets.UTF_8)); + mainRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", authHeader)); + client().performRequest(mainRequest); + } + + @SuppressWarnings("unchecked") + public void testEveryActionIsEitherOperatorOnlyOrNonOperator() throws IOException { + Set doubleLabelled = Sets.intersection(Constants.NON_OPERATOR_ACTIONS, OperatorOnlyRegistry.SIMPLE_ACTIONS); + assertTrue("Actions are both operator-only and non-operator: " + doubleLabelled, doubleLabelled.isEmpty()); + + final Request request = new Request("GET", "/_test/get_actions"); + final Map response = responseAsMap(client().performRequest(request)); + Set allActions = Set.copyOf((List) response.get("actions")); + final HashSet labelledActions = new HashSet<>(OperatorOnlyRegistry.SIMPLE_ACTIONS); + labelledActions.addAll(Constants.NON_OPERATOR_ACTIONS); + + final Set unlabelled = Sets.difference(allActions, labelledActions); + assertTrue("Actions are neither operator-only nor non-operator: " + unlabelled, unlabelled.isEmpty()); + + final Set redundant = Sets.difference(labelledActions, allActions); + assertTrue("Actions may no longer be valid: " + redundant, redundant.isEmpty()); + } + + @SuppressWarnings("unchecked") + public void testOperatorPrivilegesXpackInfo() throws IOException { + final Request xpackRequest = new Request("GET", "/_xpack"); + final Map response = entityAsMap(client().performRequest(xpackRequest)); + final Map features = (Map) response.get("features"); + final Map operatorPrivileges = (Map) features.get("operator_privileges"); + assertTrue((boolean) operatorPrivileges.get("available")); + assertTrue((boolean) operatorPrivileges.get("enabled")); + } +} diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/operator_users.yml b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/operator_users.yml new file mode 100644 index 0000000000000..1535ef271dd00 --- /dev/null +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/operator_users.yml @@ -0,0 +1,2 @@ +operator: + - usernames: ["test_operator"] diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/roles.yml b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/roles.yml new file mode 100644 index 0000000000000..ac6d3a00dacad --- /dev/null +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/roles.yml @@ -0,0 +1,4 @@ +limited_operator: + cluster: + - "cluster:admin/voting_config/add_exclusions" + - "monitor" diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTestPlugin.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTestPlugin.java new file mode 100644 index 0000000000000..49e273fd33031 --- /dev/null +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTestPlugin.java @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; +import org.elasticsearch.cluster.node.DiscoveryNodes; +import org.elasticsearch.common.settings.ClusterSettings; +import org.elasticsearch.common.settings.IndexScopedSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsFilter; +import org.elasticsearch.xpack.security.operator.actions.RestGetActionsAction; +import org.elasticsearch.plugins.ActionPlugin; +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.rest.RestController; +import org.elasticsearch.rest.RestHandler; + +import java.util.List; +import java.util.function.Supplier; + +public class OperatorPrivilegesTestPlugin extends Plugin implements ActionPlugin { + + @Override + public List getRestHandlers( + Settings settings, + RestController restController, + ClusterSettings clusterSettings, + IndexScopedSettings indexScopedSettings, + SettingsFilter settingsFilter, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier nodesInCluster + ) { + return List.of(new RestGetActionsAction()); + } +} diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/main/java/org/elasticsearch/xpack/security/operator/actions/RestGetActionsAction.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/main/java/org/elasticsearch/xpack/security/operator/actions/RestGetActionsAction.java new file mode 100644 index 0000000000000..ed0c9ca0d6e8c --- /dev/null +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/main/java/org/elasticsearch/xpack/security/operator/actions/RestGetActionsAction.java @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator.actions; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.action.ActionType; +import org.elasticsearch.action.support.TransportAction; +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.rest.BaseRestHandler; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.action.RestToXContentListener; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.elasticsearch.rest.RestRequest.Method.GET; + +public class RestGetActionsAction extends BaseRestHandler { + @Override + public List routes() { + return List.of(new Route(GET, "/_test/get_actions")); + } + + @Override + public String getName() { + return "test_get_actions"; + } + + @SuppressForbidden(reason = "Use reflection for testing only") + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + final Map actions = AccessController.doPrivileged( + (PrivilegedAction>) () -> { + try { + final Field actionsField = client.getClass().getDeclaredField("actions"); + actionsField.setAccessible(true); + return (Map) actionsField.get(client); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new ElasticsearchException(e); + } + } + ); + + final List actionNames = actions.keySet().stream().map(ActionType::name).collect(Collectors.toList()); + return channel -> new RestToXContentListener<>(channel).onResponse( + (builder, params) -> builder.startObject().field("actions", actionNames).endObject() + ); + } +} diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/main/plugin-metadata/plugin-security.policy b/x-pack/plugin/security/qa/operator-privileges-tests/src/main/plugin-metadata/plugin-security.policy new file mode 100644 index 0000000000000..eb1558fb8e381 --- /dev/null +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/main/plugin-metadata/plugin-security.policy @@ -0,0 +1,4 @@ +grant { + permission java.lang.reflect.ReflectPermission "suppressAccessChecks"; + permission java.lang.RuntimePermission "accessDeclaredMembers"; +}; diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTestPluginTests.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTestPluginTests.java new file mode 100644 index 0000000000000..509dd4757f659 --- /dev/null +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTestPluginTests.java @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.elasticsearch.test.ESTestCase; + +// This test class is really to pass the testingConventions test +public class OperatorPrivilegesTestPluginTests extends ESTestCase { + + public void testPluginWillInstantiate() { + final OperatorPrivilegesTestPlugin operatorPrivilegesTestPlugin = new OperatorPrivilegesTestPlugin(); + } + +} diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java index 168a24e8a3c90..7a386cf42772e 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java @@ -208,6 +208,10 @@ protected String configRoles() { return SECURITY_DEFAULT_SETTINGS.configRoles(); } + protected String configOperatorUsers() { + return SECURITY_DEFAULT_SETTINGS.configOperatorUsers(); + } + /** * Allows to override the node client username */ @@ -250,6 +254,11 @@ protected String configRoles() { return SecuritySingleNodeTestCase.this.configRoles(); } + @Override + protected String configOperatorUsers() { + return SecuritySingleNodeTestCase.this.configOperatorUsers(); + } + @Override protected String nodeClientUsername() { return SecuritySingleNodeTestCase.this.nodeClientUsername(); diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesSingleNodeTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesSingleNodeTests.java new file mode 100644 index 0000000000000..5aada3954e96a --- /dev/null +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesSingleNodeTests.java @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.admin.cluster.configuration.ClearVotingConfigExclusionsAction; +import org.elasticsearch.action.admin.cluster.configuration.ClearVotingConfigExclusionsRequest; +import org.elasticsearch.client.Client; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.test.SecuritySingleNodeTestCase; +import org.elasticsearch.xpack.core.security.action.user.GetUsersAction; +import org.elasticsearch.xpack.core.security.action.user.GetUsersRequest; + +import java.util.Map; + +import static org.elasticsearch.test.SecuritySettingsSource.TEST_PASSWORD_HASHED; +import static org.elasticsearch.test.SecuritySettingsSourceField.TEST_PASSWORD; +import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; +import static org.hamcrest.Matchers.containsString; + +public class OperatorPrivilegesSingleNodeTests extends SecuritySingleNodeTestCase { + + private static final String OPERATOR_USER_NAME = "test_operator"; + + @Override + protected String configUsers() { + return super.configUsers() + + OPERATOR_USER_NAME + ":" + TEST_PASSWORD_HASHED + "\n"; + } + + @Override + protected String configRoles() { + return super.configRoles() + + "limited_operator:\n" + + " cluster:\n" + + " - 'cluster:admin/voting_config/clear_exclusions'\n" + + " - 'monitor'\n"; + } + + @Override + protected String configUsersRoles() { + return super.configUsersRoles() + + "limited_operator:" + OPERATOR_USER_NAME + "\n"; + } + + @Override + protected String configOperatorUsers() { + return super.configOperatorUsers() + + "operator:\n" + + " - usernames: ['" + OPERATOR_USER_NAME + "']\n"; + } + + @Override + protected Settings nodeSettings() { + Settings.Builder builder = Settings.builder().put(super.nodeSettings()); + // Ensure the new settings can be configured + builder.put("xpack.security.operator_privileges.enabled", "true"); + return builder.build(); + } + + public void testOutcomeOfSuperuserPerformingOperatorOnlyActionWillDependOnWhetherFeatureIsEnabled() { + final Client client = client(); + final ClearVotingConfigExclusionsRequest clearVotingConfigExclusionsRequest = new ClearVotingConfigExclusionsRequest(); + final ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, + () -> client.execute(ClearVotingConfigExclusionsAction.INSTANCE, clearVotingConfigExclusionsRequest).actionGet()); + assertThat(e.getCause().getMessage(), containsString("Operator privileges are required for action")); + } + + public void testOperatorUserWillSucceedToCallOperatorOnlyAction() { + final Client client = client().filterWithHeader(Map.of( + "Authorization", + basicAuthHeaderValue(OPERATOR_USER_NAME, new SecureString(TEST_PASSWORD.toCharArray())))); + final ClearVotingConfigExclusionsRequest clearVotingConfigExclusionsRequest = new ClearVotingConfigExclusionsRequest(); + client.execute(ClearVotingConfigExclusionsAction.INSTANCE, clearVotingConfigExclusionsRequest).actionGet(); + } + + public void testOperatorUserIsStillSubjectToRoleLimits() { + final Client client = client().filterWithHeader(Map.of( + "Authorization", + basicAuthHeaderValue(OPERATOR_USER_NAME, new SecureString(TEST_PASSWORD.toCharArray())))); + final GetUsersRequest getUsersRequest = new GetUsersRequest(); + final ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, + () -> client.execute(GetUsersAction.INSTANCE, getUsersRequest).actionGet()); + assertThat(e.getMessage(), containsString("is unauthorized for user")); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 8cd10494e0c11..8a6fbdfc58b08 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -214,6 +214,11 @@ import org.elasticsearch.xpack.security.authz.store.NativePrivilegeStore; import org.elasticsearch.xpack.security.authz.store.NativeRolesStore; import org.elasticsearch.xpack.security.ingest.SetSecurityUserProcessor; +import org.elasticsearch.xpack.security.operator.OperatorOnlyRegistry; +import org.elasticsearch.xpack.security.operator.OperatorPrivileges; +import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService; +import org.elasticsearch.xpack.security.operator.FileOperatorUsersStore; +import org.elasticsearch.xpack.security.operator.OperatorPrivilegesInfoTransportAction; import org.elasticsearch.xpack.security.rest.SecurityRestFilter; import org.elasticsearch.xpack.security.rest.action.RestAuthenticateAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestClearApiKeyCacheAction; @@ -290,6 +295,7 @@ import static org.elasticsearch.xpack.core.XPackSettings.API_KEY_SERVICE_ENABLED_SETTING; import static org.elasticsearch.xpack.core.XPackSettings.HTTP_SSL_ENABLED; import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.SECURITY_MAIN_ALIAS; +import static org.elasticsearch.xpack.security.operator.OperatorPrivileges.OPERATOR_PRIVILEGES_ENABLED; import static org.elasticsearch.xpack.security.support.SecurityIndexManager.SECURITY_MAIN_TEMPLATE_7; public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin, NetworkPlugin, ClusterPlugin, @@ -473,8 +479,15 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste getLicenseState().addListener(new SecurityStatusChangeListener(getLicenseState())); final AuthenticationFailureHandler failureHandler = createAuthenticationFailureHandler(realms, extensionComponents); + final OperatorPrivilegesService operatorPrivilegesService; + if (OPERATOR_PRIVILEGES_ENABLED.get(settings)) { + operatorPrivilegesService = new OperatorPrivileges.DefaultOperatorPrivilegesService(getLicenseState(), + new FileOperatorUsersStore(environment, resourceWatcherService), new OperatorOnlyRegistry()); + } else { + operatorPrivilegesService = OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE; + } authcService.set(new AuthenticationService(settings, realms, auditTrailService, failureHandler, threadPool, - anonymousUser, tokenService, apiKeyService)); + anonymousUser, tokenService, apiKeyService, operatorPrivilegesService)); components.add(authcService.get()); securityIndex.get().addIndexStateListener(authcService.get()::onSecurityIndexStateChange); @@ -492,7 +505,7 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste final AuthorizationService authzService = new AuthorizationService(settings, allRolesStore, clusterService, auditTrailService, failureHandler, threadPool, anonymousUser, getAuthorizationEngine(), requestInterceptors, - getLicenseState(), expressionResolver); + getLicenseState(), expressionResolver, operatorPrivilegesService); components.add(nativeRolesStore); // used by roles actions components.add(reservedRolesStore); // used by roles actions @@ -673,6 +686,7 @@ public static List> getSettings(List securityExten settingsList.add(ApiKeyService.DOC_CACHE_TTL_SETTING); settingsList.add(NativePrivilegeStore.CACHE_MAX_APPLICATIONS_SETTING); settingsList.add(NativePrivilegeStore.CACHE_TTL_SETTING); + settingsList.add(OPERATOR_PRIVILEGES_ENABLED); // hide settings settingsList.add(Setting.listSetting(SecurityField.setting("hide_settings"), Collections.emptyList(), Function.identity(), @@ -757,8 +771,9 @@ public void onIndexModule(IndexModule module) { public List> getActions() { var usageAction = new ActionHandler<>(XPackUsageFeatureAction.SECURITY, SecurityUsageTransportAction.class); var infoAction = new ActionHandler<>(XPackInfoFeatureAction.SECURITY, SecurityInfoTransportAction.class); + var opInfoAction = new ActionHandler<>(XPackInfoFeatureAction.OPERATOR_PRIVILEGES, OperatorPrivilegesInfoTransportAction.class); if (enabled == false) { - return Arrays.asList(usageAction, infoAction); + return Arrays.asList(usageAction, infoAction, opInfoAction); } return Arrays.asList( new ActionHandler<>(ClearRealmCacheAction.INSTANCE, TransportClearRealmCacheAction.class), @@ -803,7 +818,8 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class), new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class), usageAction, - infoAction); + infoAction, + opInfoAction); } @Override diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java index 42cab54b0ca4a..c125c35f728c4 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java @@ -46,6 +46,7 @@ import org.elasticsearch.xpack.security.audit.AuditTrailService; import org.elasticsearch.xpack.security.audit.AuditUtil; import org.elasticsearch.xpack.security.authc.support.RealmUserLookup; +import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService; import org.elasticsearch.xpack.security.support.SecurityIndexManager; import java.util.ArrayList; @@ -86,13 +87,15 @@ public class AuthenticationService { private final Cache lastSuccessfulAuthCache; private final AtomicLong numInvalidation = new AtomicLong(); private final ApiKeyService apiKeyService; + private final OperatorPrivilegesService operatorPrivilegesService; private final boolean runAsEnabled; private final boolean isAnonymousUserEnabled; private final AuthenticationContextSerializer authenticationSerializer; public AuthenticationService(Settings settings, Realms realms, AuditTrailService auditTrailService, AuthenticationFailureHandler failureHandler, ThreadPool threadPool, - AnonymousUser anonymousUser, TokenService tokenService, ApiKeyService apiKeyService) { + AnonymousUser anonymousUser, TokenService tokenService, ApiKeyService apiKeyService, + OperatorPrivilegesService operatorPrivilegesService) { this.nodeName = Node.NODE_NAME_SETTING.get(settings); this.realms = realms; this.auditTrailService = auditTrailService; @@ -111,6 +114,7 @@ public AuthenticationService(Settings settings, Realms realms, AuditTrailService this.lastSuccessfulAuthCache = null; } this.apiKeyService = apiKeyService; + this.operatorPrivilegesService = operatorPrivilegesService; this.authenticationSerializer = new AuthenticationContextSerializer(); } @@ -689,6 +693,9 @@ void writeAuthToContext(Authentication authentication) { try { authenticationSerializer.writeToContext(authentication, threadContext); request.authenticationSuccess(authentication); + // Header for operator privileges will only be written if authentication actually happens, + // i.e. not read from either header or transient header + operatorPrivilegesService.maybeMarkOperatorUser(authentication, threadContext); } catch (Exception e) { action = () -> { logger.debug( diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java index ecb9fd43cc7f1..472d74744980b 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java @@ -73,6 +73,7 @@ import org.elasticsearch.xpack.security.authc.ApiKeyService; import org.elasticsearch.xpack.security.authz.interceptor.RequestInterceptor; import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; +import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService; import java.util.ArrayList; import java.util.Collection; @@ -118,6 +119,7 @@ public class AuthorizationService { private final AuthorizationEngine authorizationEngine; private final Set requestInterceptors; private final XPackLicenseState licenseState; + private final OperatorPrivilegesService operatorPrivilegesService; private final boolean isAnonymousEnabled; private final boolean anonymousAuthzExceptionEnabled; @@ -125,7 +127,7 @@ public AuthorizationService(Settings settings, CompositeRolesStore rolesStore, C AuditTrailService auditTrailService, AuthenticationFailureHandler authcFailureHandler, ThreadPool threadPool, AnonymousUser anonymousUser, @Nullable AuthorizationEngine authorizationEngine, Set requestInterceptors, XPackLicenseState licenseState, - IndexNameExpressionResolver resolver) { + IndexNameExpressionResolver resolver, OperatorPrivilegesService operatorPrivilegesService) { this.clusterService = clusterService; this.auditTrailService = auditTrailService; this.indicesAndAliasesResolver = new IndicesAndAliasesResolver(settings, clusterService, resolver); @@ -139,6 +141,7 @@ public AuthorizationService(Settings settings, CompositeRolesStore rolesStore, C this.requestInterceptors = requestInterceptors; this.settings = settings; this.licenseState = licenseState; + this.operatorPrivilegesService = operatorPrivilegesService; } public void checkPrivileges(Authentication authentication, HasPrivilegesRequest request, @@ -202,6 +205,16 @@ public void authorize(final Authentication authentication, final String action, // sometimes a request might be wrapped within another, which is the case for proxied // requests and concrete shard requests final TransportRequest unwrappedRequest = maybeUnwrapRequest(authentication, originalRequest, action, auditId); + + // Check operator privileges + // TODO: audit? + final ElasticsearchSecurityException operatorException = + operatorPrivilegesService.check(action, originalRequest, threadContext); + if (operatorException != null) { + listener.onFailure(denialException(authentication, action, operatorException)); + return; + } + if (SystemUser.is(authentication.getUser())) { // this never goes async so no need to wrap the listener authorizeSystemUser(authentication, action, auditId, unwrappedRequest, listener); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/FileOperatorUsersStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/FileOperatorUsersStore.java new file mode 100644 index 0000000000000..2b4ae50b106fe --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/FileOperatorUsersStore.java @@ -0,0 +1,294 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.DeprecationHandler; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.env.Environment; +import org.elasticsearch.watcher.FileChangesListener; +import org.elasticsearch.watcher.FileWatcher; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.elasticsearch.xpack.core.XPackPlugin; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings; +import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg; +import static org.elasticsearch.xpack.security.operator.OperatorPrivileges.OPERATOR_PRIVILEGES_ENABLED; + +public class FileOperatorUsersStore { + private static final Logger logger = LogManager.getLogger(FileOperatorUsersStore.class); + + private final Path file; + private volatile OperatorUsersDescriptor operatorUsersDescriptor; + + public FileOperatorUsersStore(Environment env, ResourceWatcherService watcherService) { + this.file = XPackPlugin.resolveConfigFile(env, "operator_users.yml"); + this.operatorUsersDescriptor = parseFile(this.file, logger); + FileWatcher watcher = new FileWatcher(file.getParent()); + watcher.addListener(new FileOperatorUsersStore.FileListener()); + try { + watcherService.add(watcher, ResourceWatcherService.Frequency.HIGH); + } catch (IOException e) { + throw new ElasticsearchException("Failed to start watching the operator users file [" + file.toAbsolutePath() + "]", e); + } + } + + public boolean isOperatorUser(Authentication authentication) { + if (authentication.getUser().isRunAs()) { + return false; + } else if (User.isInternal(authentication.getUser())) { + // Internal user are considered operator users + return true; + } + + // Other than realm name, other criteria must always be an exact match for the user to be an operator. + // Realm name of a descriptor can be null. When it is null, it is ignored for comparison. + // If not null, it will be compared exactly as well. + // The special handling for realm name is because there can only be one file or native realm and it does + // not matter what the name is. + return operatorUsersDescriptor.groups.stream().anyMatch(group -> { + final Authentication.RealmRef realm = authentication.getSourceRealm(); + return group.usernames.contains(authentication.getUser().principal()) + && group.authenticationType == authentication.getAuthenticationType() + && realm.getType().equals(group.realmType) + && (group.realmName == null || group.realmName.equals(realm.getName())); + }); + } + + // Package private for tests + public OperatorUsersDescriptor getOperatorUsersDescriptor() { + return operatorUsersDescriptor; + } + + static final class OperatorUsersDescriptor { + private final List groups; + + private OperatorUsersDescriptor(List groups) { + this.groups = groups; + } + + // Package private for tests + List getGroups() { + return groups; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + OperatorUsersDescriptor that = (OperatorUsersDescriptor) o; + return groups.equals(that.groups); + } + + @Override + public int hashCode() { + return Objects.hash(groups); + } + + @Override + public String toString() { + return "OperatorUsersDescriptor{" + "groups=" + groups + '}'; + } + } + + private static final OperatorUsersDescriptor EMPTY_OPERATOR_USERS_DESCRIPTOR = new OperatorUsersDescriptor(List.of()); + + static final class Group { + private static final Set SINGLETON_REALM_TYPES = Set.of( + FileRealmSettings.TYPE, NativeRealmSettings.TYPE, ReservedRealm.TYPE); + + private final Set usernames; + private final String realmName; + private final String realmType; + private final Authentication.AuthenticationType authenticationType; + + Group(Set usernames) { + this(usernames, null); + } + + Group(Set usernames, @Nullable String realmName) { + this(usernames, realmName, null, null); + } + + Group(Set usernames, @Nullable String realmName, @Nullable String realmType, + @Nullable String authenticationType) { + this.usernames = usernames; + this.realmName = realmName; + this.realmType = realmType == null ? FileRealmSettings.TYPE : realmType; + this.authenticationType = authenticationType == null ? Authentication.AuthenticationType.REALM : + Authentication.AuthenticationType.valueOf(authenticationType.toUpperCase(Locale.ROOT)); + validate(); + } + + private void validate() { + final ValidationException validationException = new ValidationException(); + if (false == FileRealmSettings.TYPE.equals(realmType)) { + validationException.addValidationError("[realm_type] only supports [file]"); + } + if (Authentication.AuthenticationType.REALM != authenticationType) { + validationException.addValidationError("[auth_type] only supports [realm]"); + } + if (realmName == null) { + if (false == SINGLETON_REALM_TYPES.contains(realmType)) { + validationException.addValidationError( + "[realm_name] must be specified for realm types other than [" + + Strings.collectionToCommaDelimitedString(SINGLETON_REALM_TYPES) + "]"); + } + } + if (false == validationException.validationErrors().isEmpty()) { + throw validationException; + } + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("Group["); + sb.append("usernames=").append(usernames); + if (realmName != null) { + sb.append(", realm_name=").append(realmName); + } + if (realmType != null) { + sb.append(", realm_type=").append(realmType); + } + if (authenticationType != null) { + sb.append(", auth_type=").append(authenticationType.name().toLowerCase(Locale.ROOT)); + } + sb.append("]"); + return sb.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Group group = (Group) o; + return usernames.equals(group.usernames) + && Objects.equals(realmName, group.realmName) + && realmType.equals(group.realmType) + && authenticationType == group.authenticationType; + } + + @Override + public int hashCode() { + return Objects.hash(usernames, realmName, realmType, authenticationType); + } + } + + public static OperatorUsersDescriptor parseFile(Path file, Logger logger) { + if (false == Files.exists(file)) { + logger.warn("Operator privileges [{}] is enabled, but operator user file does not exist. " + + "No user will be able to perform operator-only actions.", OPERATOR_PRIVILEGES_ENABLED.getKey()); + return EMPTY_OPERATOR_USERS_DESCRIPTOR; + } else { + logger.debug("Reading operator users file [{}]", file.toAbsolutePath()); + try (InputStream in = Files.newInputStream(file, StandardOpenOption.READ)) { + return parseConfig(in); + } catch (IOException | RuntimeException e) { + logger.error(new ParameterizedMessage("Failed to parse operator users file [{}].", file), e); + throw new ElasticsearchParseException("Error parsing operator users file [{}]", e, file.toAbsolutePath()); + } + } + } + + public static OperatorUsersDescriptor parseConfig(InputStream in) throws IOException { + try (XContentParser parser = yamlParser(in)) { + final OperatorUsersDescriptor operatorUsersDescriptor = OPERATOR_USER_PARSER.parse(parser, null); + logger.trace("Parsed: [{}]", operatorUsersDescriptor); + return operatorUsersDescriptor; + } + } + + @SuppressWarnings("unchecked") + private static final ConstructingObjectParser GROUP_PARSER = new ConstructingObjectParser<>( + "operator_privileges.operator.group", false, + (Object[] arr) -> new Group( + Set.copyOf((List)arr[0]), + (String) arr[1], + (String) arr[2], + (String) arr[3] + ) + ); + + @SuppressWarnings("unchecked") + private static final ConstructingObjectParser OPERATOR_USER_PARSER = new ConstructingObjectParser<>( + "operator_privileges.operator", false, + (Object[] arr) -> new OperatorUsersDescriptor((List) arr[0]) + ); + + static { + GROUP_PARSER.declareStringArray(constructorArg(), Fields.USERNAMES); + GROUP_PARSER.declareString(optionalConstructorArg(), Fields.REALM_NAME); + GROUP_PARSER.declareString(optionalConstructorArg(), Fields.REALM_TYPE); + GROUP_PARSER.declareString(optionalConstructorArg(), Fields.AUTH_TYPE); + OPERATOR_USER_PARSER.declareObjectArray(constructorArg(), (parser, ignore) -> GROUP_PARSER.parse(parser, null), Fields.OPERATOR); + } + + private static XContentParser yamlParser(InputStream in) throws IOException { + return XContentType.YAML.xContent().createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, in); + } + + public interface Fields { + ParseField OPERATOR = new ParseField("operator"); + ParseField USERNAMES = new ParseField("usernames"); + ParseField REALM_NAME = new ParseField("realm_name"); + ParseField REALM_TYPE = new ParseField("realm_type"); + ParseField AUTH_TYPE = new ParseField("auth_type"); + } + + private class FileListener implements FileChangesListener { + @Override + public void onFileCreated(Path file) { + onFileChanged(file); + } + + @Override + public void onFileDeleted(Path file) { + onFileChanged(file); + } + + @Override + public void onFileChanged(Path file) { + if (file.equals(FileOperatorUsersStore.this.file)) { + final OperatorUsersDescriptor newDescriptor = parseFile(file, logger); + if (operatorUsersDescriptor.equals(newDescriptor) == false) { + logger.info("operator users file [{}] changed. updating operator users...", file.toAbsolutePath()); + operatorUsersDescriptor = newDescriptor; + } + } + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistry.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistry.java new file mode 100644 index 0000000000000..b3b633199d8f8 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistry.java @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.elasticsearch.action.admin.cluster.configuration.AddVotingConfigExclusionsAction; +import org.elasticsearch.action.admin.cluster.configuration.ClearVotingConfigExclusionsAction; +import org.elasticsearch.license.DeleteLicenseAction; +import org.elasticsearch.license.PutLicenseAction; +import org.elasticsearch.transport.TransportRequest; + +import java.util.Set; + +public class OperatorOnlyRegistry { + + public static final Set SIMPLE_ACTIONS = Set.of(AddVotingConfigExclusionsAction.NAME, + ClearVotingConfigExclusionsAction.NAME, + PutLicenseAction.NAME, + DeleteLicenseAction.NAME, + // Autoscaling does not publish its actions to core, literal strings are needed. + "cluster:admin/autoscaling/put_autoscaling_policy", + "cluster:admin/autoscaling/delete_autoscaling_policy", + "cluster:admin/autoscaling/get_autoscaling_policy", + "cluster:admin/autoscaling/get_autoscaling_capacity"); + + /** + * Check whether the given action and request qualify as operator-only. The method returns + * null if the action+request is NOT operator-only. Other it returns a violation object + * that contains the message for details. + */ + public OperatorPrivilegesViolation check(String action, TransportRequest request) { + if (SIMPLE_ACTIONS.contains(action)) { + return () -> "action [" + action + "]"; + } else { + return null; + } + } + + @FunctionalInterface + public interface OperatorPrivilegesViolation { + String message(); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivileges.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivileges.java new file mode 100644 index 0000000000000..98083b20ad54e --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivileges.java @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.transport.TransportRequest; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationField; + +public class OperatorPrivileges { + + private static final Logger logger = LogManager.getLogger(OperatorPrivileges.class); + + public static final Setting OPERATOR_PRIVILEGES_ENABLED = + Setting.boolSetting("xpack.security.operator_privileges.enabled", false, Setting.Property.NodeScope); + + public interface OperatorPrivilegesService { + /** + * Set a ThreadContext Header {@link AuthenticationField#PRIVILEGE_CATEGORY_KEY} if authentication + * is an operator user. + */ + void maybeMarkOperatorUser(Authentication authentication, ThreadContext threadContext); + + /** + * Check whether the user is an operator and whether the request is an operator-only. + * @return An exception if user is an non-operator and the request is operator-only. Otherwise returns null. + */ + ElasticsearchSecurityException check(String action, TransportRequest request, ThreadContext threadContext); + } + + public static final class DefaultOperatorPrivilegesService implements OperatorPrivilegesService { + + private final FileOperatorUsersStore fileOperatorUsersStore; + private final OperatorOnlyRegistry operatorOnlyRegistry; + private final XPackLicenseState licenseState; + + public DefaultOperatorPrivilegesService( + XPackLicenseState licenseState, + FileOperatorUsersStore fileOperatorUsersStore, + OperatorOnlyRegistry operatorOnlyRegistry) { + this.fileOperatorUsersStore = fileOperatorUsersStore; + this.operatorOnlyRegistry = operatorOnlyRegistry; + this.licenseState = licenseState; + } + + public void maybeMarkOperatorUser(Authentication authentication, ThreadContext threadContext) { + if (false == shouldProcess()) { + return; + } + if (fileOperatorUsersStore.isOperatorUser(authentication)) { + logger.trace("User [{}] is an operator", authentication.getUser().principal()); + threadContext.putHeader(AuthenticationField.PRIVILEGE_CATEGORY_KEY, AuthenticationField.PRIVILEGE_CATEGORY_VALUE_OPERATOR); + } + } + + public ElasticsearchSecurityException check(String action, TransportRequest request, ThreadContext threadContext) { + if (false == shouldProcess()) { + return null; + } + if (false == AuthenticationField.PRIVILEGE_CATEGORY_VALUE_OPERATOR.equals( + threadContext.getHeader(AuthenticationField.PRIVILEGE_CATEGORY_KEY))) { + // Only check whether request is operator-only when user is NOT an operator + logger.trace("Checking operator-only violation for: action [{}]", action); + final OperatorOnlyRegistry.OperatorPrivilegesViolation violation = operatorOnlyRegistry.check(action, request); + if (violation != null) { + return new ElasticsearchSecurityException("Operator privileges are required for " + violation.message()); + } + } + return null; + } + + private boolean shouldProcess() { + return licenseState.checkFeature(XPackLicenseState.Feature.OPERATOR_PRIVILEGES); + } + } + + public static final OperatorPrivilegesService NOOP_OPERATOR_PRIVILEGES_SERVICE = new OperatorPrivilegesService() { + @Override + public void maybeMarkOperatorUser(Authentication authentication, ThreadContext threadContext) { + } + + @Override + public ElasticsearchSecurityException check(String action, TransportRequest request, ThreadContext threadContext) { + return null; + } + }; +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesInfoTransportAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesInfoTransportAction.java new file mode 100644 index 0000000000000..013787c4d8066 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesInfoTransportAction.java @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.XPackField; +import org.elasticsearch.xpack.core.action.XPackInfoFeatureAction; +import org.elasticsearch.xpack.core.action.XPackInfoFeatureTransportAction; + +import static org.elasticsearch.xpack.security.operator.OperatorPrivileges.OPERATOR_PRIVILEGES_ENABLED; + +public class OperatorPrivilegesInfoTransportAction extends XPackInfoFeatureTransportAction { + + private final XPackLicenseState licenseState; + private final boolean enabled; + + @Inject + public OperatorPrivilegesInfoTransportAction(TransportService transportService, ActionFilters actionFilters, + Settings settings, XPackLicenseState licenseState) { + super(XPackInfoFeatureAction.OPERATOR_PRIVILEGES.name(), transportService, actionFilters); + this.licenseState = licenseState; + enabled = OPERATOR_PRIVILEGES_ENABLED.get(settings); + } + + @Override + protected String name() { + return XPackField.OPERATOR_PRIVILEGES; + } + + @Override + protected boolean available() { + return licenseState.isAllowed(XPackLicenseState.Feature.OPERATOR_PRIVILEGES); + } + + @Override + protected boolean enabled() { + return enabled; + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java index e2bda4522f322..7d31eb8e703b3 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java @@ -129,6 +129,7 @@ public Settings nodeSettings(int nodeOrdinal) { writeFile(xpackConf, "roles.yml", configRoles()); writeFile(xpackConf, "users", configUsers()); writeFile(xpackConf, "users_roles", configUsersRoles()); + writeFile(xpackConf, "operator_users.yml", configOperatorUsers()); Settings.Builder builder = Settings.builder() .put(Environment.PATH_HOME_SETTING.getKey(), home) @@ -179,6 +180,11 @@ protected String configRoles() { return CONFIG_ROLE_ALLOW_ALL; } + protected String configOperatorUsers() { + // By default, no operator user is configured + return ""; + } + protected String nodeClientUsername() { return TEST_USER_NAME; } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index badfd859e80d1..b75c26014ae06 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -85,6 +85,7 @@ import org.elasticsearch.xpack.security.audit.AuditUtil; import org.elasticsearch.xpack.security.authc.AuthenticationService.Authenticator; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; +import org.elasticsearch.xpack.security.operator.OperatorPrivileges; import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; import org.elasticsearch.xpack.security.support.SecurityIndexManager; import org.junit.After; @@ -167,7 +168,7 @@ public class AuthenticationServiceTests extends ESTestCase { private SecurityIndexManager securityIndex; private Client client; private InetSocketAddress remoteAddress; - + private OperatorPrivileges.OperatorPrivilegesService operatorPrivilegesService; private String concreteSecurityIndexName; @Before @@ -257,9 +258,12 @@ public void init() throws Exception { mock(CacheInvalidatorRegistry.class), threadPool); tokenService = new TokenService(settings, Clock.systemUTC(), client, licenseState, securityContext, securityIndex, securityIndex, clusterService); + + operatorPrivilegesService = mock(OperatorPrivileges.OperatorPrivilegesService.class); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, new AnonymousUser(settings), tokenService, apiKeyService); + threadPool, new AnonymousUser(settings), tokenService, apiKeyService, + operatorPrivilegesService); } @After @@ -338,6 +342,7 @@ public void testAuthenticateBothSupportSecondSucceeds() throws Exception { assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); assertThreadContextContainsAuthentication(result); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); }, this::logAndFail)); assertTrue(completed.get()); verify(auditTrail).authenticationFailed(reqId, firstRealm.name(), token, "_action", transportRequest); @@ -366,6 +371,7 @@ public void testAuthenticateSmartRealmOrdering() { assertThreadContextContainsAuthentication(result); verify(auditTrail).authenticationSuccess(reqId, result, "_action", transportRequest); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); }, this::logAndFail)); assertTrue(completed.get()); @@ -373,6 +379,7 @@ public void testAuthenticateSmartRealmOrdering() { // Authenticate against the smart chain. // "SecondRealm" will be at the top of the list and will successfully authc. // "FirstRealm" will not be used + Mockito.reset(operatorPrivilegesService); service.authenticate("_action", transportRequest, true, ActionListener.wrap(result -> { assertThat(result, notNullValue()); assertThat(result.getUser(), is(user)); @@ -383,6 +390,7 @@ public void testAuthenticateSmartRealmOrdering() { assertThreadContextContainsAuthentication(result); verify(auditTrail, times(2)).authenticationSuccess(reqId, result, "_action", transportRequest); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); }, this::logAndFail)); verify(auditTrail).authenticationFailed(reqId, firstRealm.name(), token, "_action", transportRequest); @@ -415,6 +423,7 @@ public void testAuthenticateSmartRealmOrdering() { assertThreadContextContainsAuthentication(result); verify(auditTrail).authenticationSuccess(reqId, result, "_action", transportRequest); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); }, this::logAndFail)); verify(auditTrail).authenticationFailed(reqId, SECOND_REALM_NAME, token, "_action", transportRequest); @@ -464,7 +473,7 @@ public void testAuthenticateSmartRealmOrderingDisabled() { .build(); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(Settings.EMPTY), - tokenService, apiKeyService); + tokenService, apiKeyService, operatorPrivilegesService); User user = new User("_username", "r1"); when(firstRealm.supports(token)).thenReturn(true); mockAuthenticate(firstRealm, token, null); @@ -482,10 +491,12 @@ public void testAuthenticateSmartRealmOrderingDisabled() { assertThreadContextContainsAuthentication(result); verify(auditTrail).authenticationSuccess(reqId, result, "_action", transportRequest); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); }, this::logAndFail)); assertTrue(completed.get()); completed.set(false); + Mockito.reset(operatorPrivilegesService); service.authenticate("_action", transportRequest, true, ActionListener.wrap(result -> { assertThat(result, notNullValue()); assertThat(result.getUser(), is(user)); @@ -494,6 +505,7 @@ public void testAuthenticateSmartRealmOrderingDisabled() { assertThreadContextContainsAuthentication(result); verify(auditTrail, times(2)).authenticationSuccess(reqId, result, "_action", transportRequest); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); }, this::logAndFail)); verify(auditTrail, times(2)).authenticationFailed(reqId, firstRealm.name(), token, "_action", transportRequest); verify(firstRealm, times(3)).name(); // used above one time @@ -526,6 +538,7 @@ public void testAuthenticateFirstNotSupportingSecondSucceeds() throws Exception assertThreadContextContainsAuthentication(result); verify(auditTrail).authenticationSuccess(reqId, result, "_action", transportRequest); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); }, this::logAndFail)); verifyNoMoreInteractions(auditTrail); verify(firstRealm, never()).authenticate(eq(token), any(ActionListener.class)); @@ -544,6 +557,7 @@ public void testAuthenticateCached() throws Exception { verifyZeroInteractions(auditTrail); verifyZeroInteractions(firstRealm); verifyZeroInteractions(secondRealm); + verifyZeroInteractions(operatorPrivilegesService); } public void testAuthenticateNonExistentRestRequestUserThrowsAuthenticationException() throws Exception { @@ -554,6 +568,7 @@ public void testAuthenticateNonExistentRestRequestUserThrowsAuthenticationExcept fail("Authentication was successful but should not"); } catch (ElasticsearchSecurityException e) { assertAuthenticationException(e, containsString("unable to authenticate user [idonotexist] for REST request [/]")); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -567,24 +582,23 @@ public void testTokenRestMissing() throws Exception { }); } - public void authenticationInContextAndHeader() throws Exception { + public void testAuthenticationInContextAndHeader() throws Exception { User user = new User("_username", "r1"); when(firstRealm.token(threadContext)).thenReturn(token); when(firstRealm.supports(token)).thenReturn(true); mockAuthenticate(firstRealm, token, user); - Authentication result = authenticateBlocking("_action", transportRequest, null); - - assertThat(result, notNullValue()); - assertThat(result.getUser(), is(user)); - assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); - - String userStr = threadContext.getHeader(AuthenticationField.AUTHENTICATION_KEY); - assertThat(userStr, notNullValue()); - assertThat(userStr, equalTo("_signed_auth")); + service.authenticate("_action", transportRequest, true, ActionListener.wrap(result -> { + assertThat(result, notNullValue()); + assertThat(result.getUser(), is(user)); + assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); - Authentication ctxAuth = threadContext.getTransient(AuthenticationField.AUTHENTICATION_KEY); - assertThat(ctxAuth, is(result)); + String userStr = threadContext.getHeader(AuthenticationField.AUTHENTICATION_KEY); + assertThat(userStr, notNullValue()); + Authentication ctxAuth = threadContext.getTransient(AuthenticationField.AUTHENTICATION_KEY); + assertThat(ctxAuth, is(result)); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); + }, this::logAndFail)); } public void testAuthenticateTransportAnonymous() throws Exception { @@ -597,6 +611,7 @@ public void testAuthenticateTransportAnonymous() throws Exception { } catch (ElasticsearchSecurityException e) { // expected assertAuthenticationException(e); + verifyZeroInteractions(operatorPrivilegesService); } verify(auditTrail).anonymousAccessDenied(reqId, "_action", transportRequest); } @@ -610,6 +625,7 @@ public void testAuthenticateRestAnonymous() throws Exception { } catch (ElasticsearchSecurityException e) { // expected assertAuthenticationException(e); + verifyZeroInteractions(operatorPrivilegesService); } String reqId = expectAuditRequestId(); verify(auditTrail).anonymousAccessDenied(reqId, restRequest); @@ -625,6 +641,7 @@ public void testAuthenticateTransportFallback() throws Exception { assertThat(result.getUser(), sameInstance(user1)); assertThat(result.getAuthenticationType(), is(AuthenticationType.INTERNAL)); assertThreadContextContainsAuthentication(result); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); } public void testAuthenticateTransportDisabledUser() throws Exception { @@ -640,6 +657,7 @@ public void testAuthenticateTransportDisabledUser() throws Exception { verify(auditTrail).authenticationFailed(reqId, token, "_action", transportRequest); verifyNoMoreInteractions(auditTrail); assertAuthenticationException(e); + verifyZeroInteractions(operatorPrivilegesService); } public void testAuthenticateRestDisabledUser() throws Exception { @@ -654,6 +672,7 @@ public void testAuthenticateRestDisabledUser() throws Exception { verify(auditTrail).authenticationFailed(reqId, token, restRequest); verifyNoMoreInteractions(auditTrail); assertAuthenticationException(e); + verifyZeroInteractions(operatorPrivilegesService); } public void testAuthenticateTransportSuccess() throws Exception { @@ -678,6 +697,7 @@ public void testAuthenticateTransportSuccess() throws Exception { assertThreadContextContainsAuthentication(result); verify(auditTrail).authenticationSuccess(reqId, result, "_action", transportRequest); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); }, this::logAndFail)); verifyNoMoreInteractions(auditTrail); @@ -700,6 +720,7 @@ public void testAuthenticateRestSuccess() throws Exception { String reqId = expectAuditRequestId(); verify(auditTrail).authenticationSuccess(reqId, authentication, restRequest); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(authentication), eq(threadContext)); }, this::logAndFail)); verifyNoMoreInteractions(auditTrail); assertTrue(completed.get()); @@ -722,6 +743,7 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { authRef.set(authentication); authHeaderRef.set(threadContext.getHeader(AuthenticationField.AUTHENTICATION_KEY)); setCompletedToTrue(completed); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(authentication), eq(threadContext)); }, this::logAndFail)); } assertTrue(completed.compareAndSet(true, false)); @@ -730,11 +752,12 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { // checking authentication from the context InternalRequest message1 = new InternalRequest(); ThreadPool threadPool1 = new TestThreadPool("testAutheticateTransportContextAndHeader1"); + Mockito.reset(operatorPrivilegesService); try { ThreadContext threadContext1 = threadPool1.getThreadContext(); service = new AuthenticationService(Settings.EMPTY, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool1, new AnonymousUser(Settings.EMPTY), - tokenService, apiKeyService); + tokenService, apiKeyService, operatorPrivilegesService); threadContext1.putTransient(AuthenticationField.AUTHENTICATION_KEY, authRef.get()); threadContext1.putHeader(AuthenticationField.AUTHENTICATION_KEY, authHeaderRef.get()); @@ -742,6 +765,7 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { assertThat(ctxAuth, sameInstance(authRef.get())); assertThat(threadContext1.getHeader(AuthenticationField.AUTHENTICATION_KEY), sameInstance(authHeaderRef.get())); setCompletedToTrue(completed); + verifyZeroInteractions(operatorPrivilegesService); }, this::logAndFail)); assertTrue(completed.compareAndSet(true, false)); verifyZeroInteractions(firstRealm); @@ -752,13 +776,14 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { // checking authentication from the user header ThreadPool threadPool2 = new TestThreadPool("testAutheticateTransportContextAndHeader2"); + Mockito.reset(operatorPrivilegesService); try { ThreadContext threadContext2 = threadPool2.getThreadContext(); final String header; try (ThreadContext.StoredContext ignore = threadContext2.stashContext()) { service = new AuthenticationService(Settings.EMPTY, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool2, new AnonymousUser(Settings.EMPTY), - tokenService, apiKeyService); + tokenService, apiKeyService, operatorPrivilegesService); threadContext2.putHeader(AuthenticationField.AUTHENTICATION_KEY, authHeaderRef.get()); BytesStreamOutput output = new BytesStreamOutput(); @@ -772,12 +797,13 @@ public void testAuthenticateTransportContextAndHeader() throws Exception { threadPool2.getThreadContext().putHeader(AuthenticationField.AUTHENTICATION_KEY, header); service = new AuthenticationService(Settings.EMPTY, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool2, new AnonymousUser(Settings.EMPTY), - tokenService, apiKeyService); + tokenService, apiKeyService, operatorPrivilegesService); service.authenticate("_action", new InternalRequest(), SystemUser.INSTANCE, ActionListener.wrap(result -> { assertThat(result, notNullValue()); assertThat(result.getUser(), equalTo(user1)); assertThat(result.getAuthenticationType(), is(AuthenticationType.REALM)); setCompletedToTrue(completed); + verifyZeroInteractions(operatorPrivilegesService); }, this::logAndFail)); assertTrue(completed.get()); verifyZeroInteractions(firstRealm); @@ -797,6 +823,7 @@ public void testAuthenticateTamperedUser() throws Exception { //expected verify(auditTrail).tamperedRequest(reqId, "_action", message); verifyNoMoreInteractions(auditTrail); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -810,7 +837,8 @@ public void testWrongTokenDoesNotFallbackToAnonymous() { Settings anonymousEnabledSettings = builder.build(); final AnonymousUser anonymousUser = new AnonymousUser(anonymousEnabledSettings); service = new AuthenticationService(anonymousEnabledSettings, realms, auditTrailService, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, tokenService, apiKeyService); + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, + tokenService, apiKeyService, operatorPrivilegesService); try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { final String reqId = AuditUtil.getOrGenerateRequestId(threadContext); @@ -820,6 +848,7 @@ public void testWrongTokenDoesNotFallbackToAnonymous() { verify(auditTrail).anonymousAccessDenied(reqId, "_action", transportRequest); verifyNoMoreInteractions(auditTrail); assertAuthenticationException(e); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -833,7 +862,8 @@ public void testWrongApiKeyDoesNotFallbackToAnonymous() { Settings anonymousEnabledSettings = builder.build(); final AnonymousUser anonymousUser = new AnonymousUser(anonymousEnabledSettings); service = new AuthenticationService(anonymousEnabledSettings, realms, auditTrailService, - new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, tokenService, apiKeyService); + new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, + tokenService, apiKeyService, operatorPrivilegesService); doAnswer(invocationOnMock -> { final GetRequest request = (GetRequest) invocationOnMock.getArguments()[0]; final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; @@ -850,6 +880,7 @@ public void testWrongApiKeyDoesNotFallbackToAnonymous() { verify(auditTrail).anonymousAccessDenied(reqId, "_action", transportRequest); verifyNoMoreInteractions(auditTrail); assertAuthenticationException(e); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -864,7 +895,7 @@ public void testAnonymousUserRest() throws Exception { final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService, apiKeyService); + threadPool, anonymousUser, tokenService, apiKeyService, operatorPrivilegesService); RestRequest request = new FakeRestRequest(); Authentication result = authenticateBlocking(request); @@ -876,6 +907,7 @@ public void testAnonymousUserRest() throws Exception { String reqId = expectAuditRequestId(); verify(auditTrail).authenticationSuccess(reqId, result, request); verifyNoMoreInteractions(auditTrail); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); } public void testAuthenticateRestRequestDisallowAnonymous() throws Exception { @@ -890,7 +922,7 @@ public void testAuthenticateRestRequestDisallowAnonymous() throws Exception { final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService, apiKeyService); + threadPool, anonymousUser, tokenService, apiKeyService, operatorPrivilegesService); RestRequest request = new FakeRestRequest(); PlainActionFuture future = new PlainActionFuture<>(); @@ -904,6 +936,7 @@ public void testAuthenticateRestRequestDisallowAnonymous() throws Exception { String reqId = expectAuditRequestId(); verify(auditTrail).anonymousAccessDenied(reqId, request); verifyNoMoreInteractions(auditTrail); + verifyZeroInteractions(operatorPrivilegesService); } public void testAnonymousUserTransportNoDefaultUser() throws Exception { @@ -913,7 +946,7 @@ public void testAnonymousUserTransportNoDefaultUser() throws Exception { final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService, apiKeyService); + threadPool, anonymousUser, tokenService, apiKeyService, operatorPrivilegesService); InternalRequest message = new InternalRequest(); Authentication result = authenticateBlocking("_action", message, null); @@ -921,6 +954,7 @@ public void testAnonymousUserTransportNoDefaultUser() throws Exception { assertThat(result.getUser(), sameInstance(anonymousUser)); assertThat(result.getAuthenticationType(), is(AuthenticationType.ANONYMOUS)); assertThreadContextContainsAuthentication(result); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); } public void testAnonymousUserTransportWithDefaultUser() throws Exception { @@ -930,7 +964,7 @@ public void testAnonymousUserTransportWithDefaultUser() throws Exception { final AnonymousUser anonymousUser = new AnonymousUser(settings); service = new AuthenticationService(settings, realms, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), - threadPool, anonymousUser, tokenService, apiKeyService); + threadPool, anonymousUser, tokenService, apiKeyService, operatorPrivilegesService); InternalRequest message = new InternalRequest(); @@ -939,6 +973,7 @@ public void testAnonymousUserTransportWithDefaultUser() throws Exception { assertThat(result.getUser(), sameInstance(SystemUser.INSTANCE)); assertThat(result.getAuthenticationType(), is(AuthenticationType.INTERNAL)); assertThreadContextContainsAuthentication(result); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); } public void testRealmTokenThrowingException() throws Exception { @@ -950,6 +985,7 @@ public void testRealmTokenThrowingException() throws Exception { } catch (ElasticsearchException e) { assertThat(e.getMessage(), is("realm doesn't like tokens")); verify(auditTrail).authenticationFailed(reqId, "_action", transportRequest); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -962,6 +998,7 @@ public void testRealmTokenThrowingExceptionRest() throws Exception { assertThat(e.getMessage(), is("realm doesn't like tokens")); String reqId = expectAuditRequestId(); verify(auditTrail).authenticationFailed(reqId, restRequest); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -977,6 +1014,7 @@ public void testRealmSupportsMethodThrowingException() throws Exception { } catch (ElasticsearchException e) { assertThat(e.getMessage(), is("realm doesn't like supports")); verify(auditTrail).authenticationFailed(reqId, token, "_action", transportRequest); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -992,6 +1030,7 @@ public void testRealmSupportsMethodThrowingExceptionRest() throws Exception { assertThat(e.getMessage(), is("realm doesn't like supports")); String reqId = expectAuditRequestId(); verify(auditTrail).authenticationFailed(reqId, token, restRequest); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -1031,6 +1070,7 @@ public void testRealmAuthenticateTerminateAuthenticationProcessWithException() { verify(auditTrail).authenticationFailed(reqId, secondRealm.name(), token, "_action", transportRequest); verify(auditTrail).authenticationFailed(reqId, token, "_action", transportRequest); verifyNoMoreInteractions(auditTrail); + verifyZeroInteractions(operatorPrivilegesService); } public void testRealmAuthenticateGracefulTerminateAuthenticationProcess() { @@ -1050,6 +1090,7 @@ public void testRealmAuthenticateGracefulTerminateAuthenticationProcess() { verify(auditTrail).authenticationFailed(reqId, firstRealm.name(), token, "_action", transportRequest); verify(auditTrail).authenticationFailed(reqId, token, "_action", transportRequest); verifyNoMoreInteractions(auditTrail); + verifyZeroInteractions(operatorPrivilegesService); } public void testRealmAuthenticateThrowingException() throws Exception { @@ -1066,6 +1107,7 @@ public void testRealmAuthenticateThrowingException() throws Exception { } catch (ElasticsearchException e) { assertThat(e.getMessage(), is("realm doesn't like authenticate")); verify(auditTrail).authenticationFailed(reqId, token, "_action", transportRequest); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -1083,6 +1125,7 @@ public void testRealmAuthenticateThrowingExceptionRest() throws Exception { assertThat(e.getMessage(), is("realm doesn't like authenticate")); String reqId = expectAuditRequestId(); verify(auditTrail).authenticationFailed(reqId, token, restRequest); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -1104,6 +1147,7 @@ public void testRealmLookupThrowingException() throws Exception { } catch (ElasticsearchException e) { assertThat(e.getMessage(), is("realm doesn't want to lookup")); verify(auditTrail).authenticationFailed(reqId, token, "_action", transportRequest); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -1124,6 +1168,7 @@ public void testRealmLookupThrowingExceptionRest() throws Exception { assertThat(e.getMessage(), is("realm doesn't want to lookup")); String reqId = expectAuditRequestId(); verify(auditTrail).authenticationFailed(reqId, token, restRequest); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -1162,8 +1207,7 @@ public void testRunAsLookupSameRealm() throws Exception { assertEquals(user.email(), authUser.email()); assertEquals(user.enabled(), authUser.enabled()); assertEquals(user.fullName(), authUser.fullName()); - - + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); setCompletedToTrue(completed); }, this::logAndFail); @@ -1203,6 +1247,7 @@ public void testRunAsLookupDifferentRealm() throws Exception { assertThat(authenticated.principal(), is("looked up user")); assertThat(authenticated.roles(), arrayContaining("some role")); assertThreadContextContainsAuthentication(result); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); setCompletedToTrue(completed); }, this::logAndFail); @@ -1231,6 +1276,7 @@ public void testRunAsWithEmptyRunAsUsernameRest() throws Exception { String reqId = expectAuditRequestId(); verify(auditTrail).runAsDenied(eq(reqId), any(Authentication.class), eq(restRequest), eq(EmptyAuthorizationInfo.INSTANCE)); verifyNoMoreInteractions(auditTrail); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -1251,6 +1297,7 @@ public void testRunAsWithEmptyRunAsUsername() throws Exception { verify(auditTrail).runAsDenied(eq(reqId), any(Authentication.class), eq("_action"), eq(transportRequest), eq(EmptyAuthorizationInfo.INSTANCE)); verifyNoMoreInteractions(auditTrail); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -1275,6 +1322,7 @@ public void testAuthenticateTransportDisabledRunAsUser() throws Exception { verify(auditTrail).authenticationFailed(reqId, token, "_action", transportRequest); verifyNoMoreInteractions(auditTrail); assertAuthenticationException(e); + verifyZeroInteractions(operatorPrivilegesService); } public void testAuthenticateRestDisabledRunAsUser() throws Exception { @@ -1298,6 +1346,7 @@ public void testAuthenticateRestDisabledRunAsUser() throws Exception { verify(auditTrail).authenticationFailed(reqId, token, restRequest); verifyNoMoreInteractions(auditTrail); assertAuthenticationException(e); + verifyZeroInteractions(operatorPrivilegesService); } public void testAuthenticateWithToken() throws Exception { @@ -1326,6 +1375,7 @@ public void testAuthenticateWithToken() throws Exception { assertThat(result.getAuthenticatedBy(), is(notNullValue())); assertThat(result.getAuthenticatedBy().getName(), is("realm")); // TODO implement equals assertThat(result.getAuthenticationType(), is(AuthenticationType.TOKEN)); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); setCompletedToTrue(completed); verify(auditTrail).authenticationSuccess(anyString(), eq(result), eq("_action"), same(transportRequest)); }, this::logAndFail)); @@ -1354,9 +1404,11 @@ public void testInvalidToken() throws Exception { assertThat(result.getAuthenticatedBy(), is(notNullValue())); assertThreadContextContainsAuthentication(result); assertEquals(expected, result); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(result), eq(threadContext)); success.set(true); latch.countDown(); }, e -> { + verifyZeroInteractions(operatorPrivilegesService); if (e instanceof IllegalStateException) { assertThat(e.getMessage(), containsString("array length must be <= to " + ArrayUtil.MAX_ARRAY_LENGTH + " but was: ")); latch.countDown(); @@ -1412,6 +1464,7 @@ public void testExpiredToken() throws Exception { expectThrows(ElasticsearchSecurityException.class, () -> authenticateBlocking("_action", transportRequest, null)); assertEquals(RestStatus.UNAUTHORIZED, e.status()); assertEquals("token expired", e.getMessage()); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -1423,6 +1476,7 @@ public void testApiKeyAuthInvalidHeader() { () -> authenticateBlocking("_action", transportRequest, null)); assertEquals(RestStatus.UNAUTHORIZED, e.status()); assertThat(e.getMessage(), containsString("missing authentication credentials")); + verifyZeroInteractions(operatorPrivilegesService); } } @@ -1471,6 +1525,7 @@ public void testApiKeyAuth() { assertThat(authentication.getUser().fullName(), is("john doe")); assertThat(authentication.getUser().email(), is("john@doe.com")); assertThat(authentication.getAuthenticationType(), is(AuthenticationType.API_KEY)); + verify(operatorPrivilegesService).maybeMarkOperatorUser(eq(authentication), eq(threadContext)); } } @@ -1511,6 +1566,7 @@ public void testExpiredApiKey() { ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, () -> authenticateBlocking("_action", transportRequest, null)); assertEquals(RestStatus.UNAUTHORIZED, e.status()); + verifyZeroInteractions(operatorPrivilegesService); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java index 0f254a908441e..8f1425c4b9782 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java @@ -46,6 +46,7 @@ import org.elasticsearch.xpack.security.authc.AuthenticationService; import org.elasticsearch.xpack.security.authc.Realms; import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.operator.OperatorPrivileges; import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; import org.elasticsearch.xpack.security.support.SecurityIndexManager; import org.elasticsearch.xpack.security.test.SecurityMocks; @@ -79,6 +80,7 @@ public class SecondaryAuthenticatorTests extends ESTestCase { private SecurityContext securityContext; private TokenService tokenService; private Client client; + private OperatorPrivileges operatorPrivileges; @Before public void setupMocks() throws Exception { @@ -124,7 +126,7 @@ public void setupMocks() throws Exception { securityIndex, clusterService, mock(CacheInvalidatorRegistry.class),threadPool); authenticationService = new AuthenticationService(settings, realms, auditTrail, failureHandler, threadPool, anonymous, - tokenService, apiKeyService); + tokenService, apiKeyService, OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE); authenticator = new SecondaryAuthenticator(securityContext, authenticationService); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java index fa1edf32e7d85..d1c19bd7a7e66 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java @@ -147,11 +147,13 @@ import org.elasticsearch.xpack.security.audit.AuditUtil; import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; import org.elasticsearch.xpack.security.authz.store.NativePrivilegeStore; +import org.elasticsearch.xpack.security.operator.OperatorPrivileges; import org.elasticsearch.xpack.sql.action.SqlQueryAction; import org.elasticsearch.xpack.sql.action.SqlQueryRequest; import org.junit.Before; import org.mockito.ArgumentMatcher; import org.mockito.Matchers; +import org.mockito.Mockito; import java.io.IOException; import java.io.UncheckedIOException; @@ -166,6 +168,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Predicate; @@ -200,6 +203,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; public class AuthorizationServiceTests extends ESTestCase { @@ -211,6 +215,8 @@ public class AuthorizationServiceTests extends ESTestCase { private ThreadPool threadPool; private Map roleMap = new HashMap<>(); private CompositeRolesStore rolesStore; + private OperatorPrivileges.OperatorPrivilegesService operatorPrivilegesService; + private boolean shouldFailOperatorPrivilegesCheck = false; @SuppressWarnings("unchecked") @Before @@ -267,9 +273,11 @@ public void setup() { return Void.TYPE; }).when(rolesStore).getRoles(any(User.class), any(Authentication.class), any(ActionListener.class)); roleMap.put(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName(), ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR); + operatorPrivilegesService = mock(OperatorPrivileges.OperatorPrivilegesService.class); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(settings), - null, Collections.emptySet(), licenseState, new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY))); + null, Collections.emptySet(), licenseState, new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), + operatorPrivilegesService); } private void authorize(Authentication authentication, String action, TransportRequest request) { @@ -296,6 +304,16 @@ private void authorize(Authentication authentication, String action, TransportRe authorizationInfoHeader = mock(AuthorizationInfo.class); threadContext.putTransient(AUTHORIZATION_INFO_KEY, authorizationInfoHeader); } + Mockito.reset(operatorPrivilegesService); + final AtomicBoolean operatorPrivilegesChecked = new AtomicBoolean(false); + final ElasticsearchSecurityException operatorPrivilegesException = + new ElasticsearchSecurityException("Operator privileges check failed"); + if (shouldFailOperatorPrivilegesCheck) { + when(operatorPrivilegesService.check(action, request, threadContext)).thenAnswer(invocationOnMock -> { + operatorPrivilegesChecked.set(true); + return operatorPrivilegesException; + }); + } ActionListener listener = ActionListener.wrap(response -> { // extract the authorization transient headers from the thread context of the action // that has been authorized @@ -303,12 +321,16 @@ private void authorize(Authentication authentication, String action, TransportRe authorizationInfo.onResponse(threadContext.getTransient(AUTHORIZATION_INFO_KEY)); indicesPermissions.onResponse(threadContext.getTransient(INDICES_PERMISSIONS_KEY)); done.onResponse(threadContext.getTransient(someRandomHeader)); + assertNull(verify(operatorPrivilegesService).check(action, request, threadContext)); }, e -> { + if (shouldFailOperatorPrivilegesCheck && operatorPrivilegesChecked.get()) { + assertSame(operatorPrivilegesException, e.getCause()); + } done.onFailure(e); }); authorizationService.authorize(authentication, action, request, listener); - Object someRandonHeaderValueInListener = done.actionGet(); - assertThat(someRandonHeaderValueInListener, sameInstance(someRandomHeaderValue)); + Object someRandomHeaderValueInListener = done.actionGet(); + assertThat(someRandomHeaderValueInListener, sameInstance(someRandomHeaderValue)); assertThat(threadContext.getTransient(someRandomHeader), sameInstance(someRandomHeaderValue)); // authorization restores any previously existing transient headers if (mockAccessControlHeader != null) { @@ -913,7 +935,8 @@ public void testDenialForAnonymousUser() throws IOException { final AnonymousUser anonymousUser = new AnonymousUser(settings); authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, anonymousUser, null, Collections.emptySet(), - new XPackLicenseState(settings, () -> 0), new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY))); + new XPackLicenseState(settings, () -> 0), + new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), operatorPrivilegesService); RoleDescriptor role = new RoleDescriptor("a_all", null, new IndicesPrivileges[] { IndicesPrivileges.builder().indices("a").privileges("all").build() }, null); @@ -942,7 +965,7 @@ public void testDenialForAnonymousUserAuthorizationExceptionDisabled() throws IO authorizationService = new AuthorizationService(settings, rolesStore, clusterService, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(settings), null, Collections.emptySet(), new XPackLicenseState(settings, () -> 0), - new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY))); + new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), operatorPrivilegesService); RoleDescriptor role = new RoleDescriptor("a_all", null, new IndicesPrivileges[]{IndicesPrivileges.builder().indices("a").privileges("all").build()}, null); @@ -1684,7 +1707,7 @@ public void getUserPrivileges(Authentication authentication, AuthorizationInfo a authorizationService = new AuthorizationService(Settings.EMPTY, rolesStore, clusterService, auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap()), threadPool, new AnonymousUser(Settings.EMPTY), engine, Collections.emptySet(), licenseState, - new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY))); + new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), operatorPrivilegesService); Authentication authentication; try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { authentication = createAuthentication(new User("test user", "a_all")); @@ -1749,6 +1772,17 @@ auditTrailService, new DefaultAuthenticationFailureHandler(Collections.emptyMap( } } + public void testOperatorPrivileges() { + shouldFailOperatorPrivilegesCheck = true; + AuditUtil.getOrGenerateRequestId(threadContext); + final Authentication authentication = createAuthentication(new User("user1", "role1")); + assertThrowsAuthorizationException( + () -> authorize(authentication, "cluster:admin/whatever", mock(TransportRequest.class)), + "cluster:admin/whatever", "user1"); + // The operator related exception is verified in the authorize(...) call + verifyZeroInteractions(auditTrail); + } + static AuthorizationInfo authzInfoRoles(String[] expectedRoles) { return Matchers.argThat(new RBACAuthorizationInfoRoleMatcher(expectedRoles)); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/FileOperatorUsersStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/FileOperatorUsersStoreTests.java new file mode 100644 index 0000000000000..b6989d47c5305 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/FileOperatorUsersStoreTests.java @@ -0,0 +1,287 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.Version; +import org.elasticsearch.common.logging.Loggers; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentParseException; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.MockLogAppender; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.elasticsearch.xpack.core.security.audit.logfile.CapturingLogger; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.user.AsyncSearchUser; +import org.elasticsearch.xpack.core.security.user.SystemUser; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.core.security.user.XPackSecurityUser; +import org.elasticsearch.xpack.core.security.user.XPackUser; +import org.junit.After; +import org.junit.Before; + +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.mock; + +public class FileOperatorUsersStoreTests extends ESTestCase { + + private Settings settings; + private Environment env; + private ThreadPool threadPool; + + @Before + public void init() { + settings = Settings.builder() + .put("resource.reload.interval.high", "100ms") + .put("path.home", createTempDir()) + .build(); + env = TestEnvironment.newEnvironment(settings); + threadPool = new TestThreadPool("test"); + } + + @After + public void shutdown() { + terminate(threadPool); + } + + public void testIsOperator() throws IOException { + Path sampleFile = getDataPath("operator_users.yml"); + Path inUseFile = getOperatorUsersPath(); + Files.copy(sampleFile, inUseFile, StandardCopyOption.REPLACE_EXISTING); + final ResourceWatcherService resourceWatcherService = mock(ResourceWatcherService.class); + final FileOperatorUsersStore fileOperatorUsersStore = new FileOperatorUsersStore(env, resourceWatcherService); + + // user operator_1 from file realm is an operator + final Authentication.RealmRef fileRealm = new Authentication.RealmRef("file", "file", randomAlphaOfLength(8)); + final User operator_1 = new User("operator_1", randomRoles()); + assertTrue(fileOperatorUsersStore.isOperatorUser(new Authentication(operator_1, fileRealm, fileRealm))); + + // user operator_3 is an operator and its file realm can have any name + final Authentication.RealmRef anotherFileRealm = new Authentication.RealmRef( + randomAlphaOfLengthBetween(3, 8), "file", randomAlphaOfLength(8)); + assertTrue(fileOperatorUsersStore.isOperatorUser( + new Authentication(new User("operator_3", randomRoles()), anotherFileRealm, anotherFileRealm))); + + // user operator_1 from a different realm is not an operator + final Authentication.RealmRef differentRealm = randomFrom( + new Authentication.RealmRef("file", randomAlphaOfLengthBetween(5, 8), randomAlphaOfLength(8)), + new Authentication.RealmRef(randomAlphaOfLengthBetween(5, 8), "file", randomAlphaOfLength(8)), + new Authentication.RealmRef(randomAlphaOfLengthBetween(5, 8), randomAlphaOfLengthBetween(5, 8), randomAlphaOfLength(8)) + ); + assertFalse(fileOperatorUsersStore.isOperatorUser(new Authentication(operator_1, differentRealm, differentRealm))); + + // user operator_1 with non realm auth type is not an operator + assertFalse(fileOperatorUsersStore.isOperatorUser( + new Authentication(operator_1, fileRealm, fileRealm, Version.CURRENT, Authentication.AuthenticationType.TOKEN, Map.of()))); + + // Run as user operator_1 is not an operator + final User runAsOperator_1 = new User(operator_1, new User(randomAlphaOfLengthBetween(5, 8), randomRoles())); + assertFalse(fileOperatorUsersStore.isOperatorUser(new Authentication(runAsOperator_1, fileRealm, fileRealm))); + + // Internal users are operator + final Authentication.RealmRef realm = + new Authentication.RealmRef(randomAlphaOfLength(8), randomAlphaOfLength(8), randomAlphaOfLength(8)); + final Authentication authentication = new Authentication( + randomFrom(SystemUser.INSTANCE, XPackUser.INSTANCE, XPackSecurityUser.INSTANCE, AsyncSearchUser.INSTANCE), + realm, realm); + assertTrue(fileOperatorUsersStore.isOperatorUser(authentication)); + } + + public void testFileAutoReload() throws Exception { + Path sampleFile = getDataPath("operator_users.yml"); + Path inUseFile = getOperatorUsersPath(); + Files.copy(sampleFile, inUseFile, StandardCopyOption.REPLACE_EXISTING); + + final Logger logger = LogManager.getLogger(FileOperatorUsersStore.class); + final MockLogAppender appender = new MockLogAppender(); + appender.start(); + appender.addExpectation( + new MockLogAppender.ExceptionSeenEventExpectation( + getTestName(), + logger.getName(), + Level.ERROR, + "Failed to parse operator users file", + XContentParseException.class, + "[10:1] [operator_privileges.operator] failed to parse field [operator]" + ) + ); + Loggers.addAppender(logger, appender); + + try (ResourceWatcherService watcherService = new ResourceWatcherService(settings, threadPool)) { + final FileOperatorUsersStore fileOperatorUsersStore = new FileOperatorUsersStore(env, watcherService); + final List groups = fileOperatorUsersStore.getOperatorUsersDescriptor().getGroups(); + + assertEquals(2, groups.size()); + assertEquals(new FileOperatorUsersStore.Group(Set.of("operator_1", "operator_2"), + "file"), groups.get(0)); + assertEquals(new FileOperatorUsersStore.Group(Set.of("operator_3"), null), groups.get(1)); + + // Content does not change, the groups should not be updated + try (BufferedWriter writer = Files.newBufferedWriter(inUseFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) { + writer.append("\n"); + } + watcherService.notifyNow(ResourceWatcherService.Frequency.HIGH); + assertSame(groups, fileOperatorUsersStore.getOperatorUsersDescriptor().getGroups()); + + // Add one more entry + try (BufferedWriter writer = Files.newBufferedWriter(inUseFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) { + writer.append(" - usernames: [ 'operator_4' ]\n"); + } + assertBusy(() -> { + final List newGroups = fileOperatorUsersStore.getOperatorUsersDescriptor().getGroups(); + assertEquals(3, newGroups.size()); + assertEquals(new FileOperatorUsersStore.Group(Set.of("operator_4")), newGroups.get(2)); + }); + + // Add mal-formatted entry + try (BufferedWriter writer = Files.newBufferedWriter(inUseFile, StandardCharsets.UTF_8, StandardOpenOption.APPEND)) { + writer.append(" - blah\n"); + } + watcherService.notifyNow(ResourceWatcherService.Frequency.HIGH); + assertEquals(3, fileOperatorUsersStore.getOperatorUsersDescriptor().getGroups().size()); + appender.assertAllExpectationsMatched(); + + // Delete the file will remove all the operator users + Files.delete(inUseFile); + assertBusy(() -> assertEquals(0, fileOperatorUsersStore.getOperatorUsersDescriptor().getGroups().size())); + + // Back to original content + Files.copy(sampleFile, inUseFile, StandardCopyOption.REPLACE_EXISTING); + assertBusy(() -> assertEquals(2, fileOperatorUsersStore.getOperatorUsersDescriptor().getGroups().size())); + } finally { + Loggers.removeAppender(logger, appender); + appender.stop(); + } + } + + public void testMalFormattedOrEmptyFile() throws IOException { + // Mal-formatted file is functionally equivalent to an empty file + writeOperatorUsers(randomBoolean() ? "foobar" : ""); + try (ResourceWatcherService watcherService = new ResourceWatcherService(settings, threadPool)) { + final ElasticsearchParseException e = + expectThrows(ElasticsearchParseException.class, () -> new FileOperatorUsersStore(env, watcherService)); + assertThat(e.getMessage(), containsString("Error parsing operator users file")); + } + } + + public void testParseFileWhenFileDoesNotExist() throws Exception { + Path file = createTempDir().resolve(randomAlphaOfLength(10)); + Logger logger = CapturingLogger.newCapturingLogger(Level.DEBUG, null); + final List groups = FileOperatorUsersStore.parseFile(file, logger).getGroups(); + assertEquals(0, groups.size()); + List events = CapturingLogger.output(logger.getName(), Level.WARN); + assertEquals(1, events.size()); + assertThat(events.get(0), containsString("operator user file does not exist")); + } + + public void testParseConfig() throws IOException { + String config = "" + + "operator:\n" + + " - usernames: [\"operator_1\"]\n"; + try (ByteArrayInputStream in = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8))) { + final List groups = FileOperatorUsersStore.parseConfig(in).getGroups(); + assertEquals(1, groups.size()); + assertEquals(new FileOperatorUsersStore.Group(Set.of("operator_1")), groups.get(0)); + } + + config = "" + + "operator:\n" + + " - usernames: [\"operator_1\",\"operator_2\"]\n" + + " realm_name: \"file1\"\n" + + " realm_type: \"file\"\n" + + " auth_type: \"realm\"\n" + + " - usernames: [\"internal_system\"]\n"; + + try (ByteArrayInputStream in = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8))) { + final List groups = FileOperatorUsersStore.parseConfig(in).getGroups(); + assertEquals(2, groups.size()); + assertEquals(new FileOperatorUsersStore.Group(Set.of("operator_1", "operator_2"), "file1"), groups.get(0)); + assertEquals(new FileOperatorUsersStore.Group(Set.of("internal_system")), groups.get(1)); + } + + config = "" + + "operator:\n" + + " - realm_name: \"file1\"\n" + + " usernames: [\"internal_system\"]\n" + + " - auth_type: \"realm\"\n" + + " usernames: [\"operator_1\",\"operator_2\"]\n"; + + try (ByteArrayInputStream in = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8))) { + final List groups = FileOperatorUsersStore.parseConfig(in).getGroups(); + assertEquals(2, groups.size()); + assertEquals(new FileOperatorUsersStore.Group(Set.of("internal_system"), "file1"), groups.get(0)); + assertEquals(new FileOperatorUsersStore.Group(Set.of("operator_1", "operator_2")), groups.get(1)); + } + } + + public void testParseInvalidConfig() throws IOException { + String config = "" + + "operator:\n" + + " - usernames: [\"operator_1\"]\n" + + " realm_type: \"native\"\n"; + try (ByteArrayInputStream in = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8))) { + final XContentParseException e = expectThrows(XContentParseException.class, + () -> FileOperatorUsersStore.parseConfig(in)); + assertThat(e.getCause().getCause().getMessage(), containsString("[realm_type] only supports [file]")); + } + + config = "" + + "operator:\n" + + " - usernames: [\"operator_1\"]\n" + + " auth_type: \"token\"\n"; + + try (ByteArrayInputStream in = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8))) { + final XContentParseException e = expectThrows(XContentParseException.class, + () -> FileOperatorUsersStore.parseConfig(in)); + assertThat(e.getCause().getCause().getMessage(), containsString("[auth_type] only supports [realm]")); + } + + config = "" + + "operator:\n" + + " auth_type: \"realm\"\n"; + try (ByteArrayInputStream in = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8))) { + final XContentParseException e = expectThrows(XContentParseException.class, + () -> FileOperatorUsersStore.parseConfig(in)); + assertThat(e.getCause().getMessage(), containsString("Required [usernames]")); + } + } + + private Path getOperatorUsersPath() throws IOException { + Path xpackConf = env.configFile(); + Files.createDirectories(xpackConf); + return xpackConf.resolve("operator_users.yml"); + } + + private Path writeOperatorUsers(String input) throws IOException { + Path file = getOperatorUsersPath(); + Files.write(file, input.getBytes(StandardCharsets.UTF_8)); + return file; + } + + private String[] randomRoles() { + return randomArray(0, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistryTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistryTests.java new file mode 100644 index 0000000000000..71ce0aa488c2a --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistryTests.java @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.elasticsearch.test.ESTestCase; +import org.junit.Before; + +import static org.hamcrest.Matchers.containsString; + +public class OperatorOnlyRegistryTests extends ESTestCase { + + private OperatorOnlyRegistry operatorOnlyRegistry; + + @Before + public void init() { + operatorOnlyRegistry = new OperatorOnlyRegistry(); + } + + public void testSimpleOperatorOnlyApi() { + for (final String actionName : OperatorOnlyRegistry.SIMPLE_ACTIONS) { + final OperatorOnlyRegistry.OperatorPrivilegesViolation violation = operatorOnlyRegistry.check(actionName, null); + assertNotNull(violation); + assertThat(violation.message(), containsString("action [" + actionName + "]")); + } + } + + public void testNonOperatorOnlyApi() { + final String actionName = randomValueOtherThanMany( + OperatorOnlyRegistry.SIMPLE_ACTIONS::contains, () -> randomAlphaOfLengthBetween(10, 40)); + assertNull(operatorOnlyRegistry.check(actionName, null)); + } + + // TODO: not tests for settings yet since it's not settled whether it will be part of phase 1 + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTests.java new file mode 100644 index 0000000000000..d51f18377cd7b --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTests.java @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.transport.TransportRequest; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationField; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.operator.OperatorPrivileges.DefaultOperatorPrivilegesService; +import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService; +import org.junit.Before; + +import static org.elasticsearch.xpack.security.operator.OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +public class OperatorPrivilegesTests extends ESTestCase { + + private XPackLicenseState xPackLicenseState; + private FileOperatorUsersStore fileOperatorUsersStore; + private OperatorOnlyRegistry operatorOnlyRegistry; + + @Before + public void init() { + xPackLicenseState = mock(XPackLicenseState.class); + fileOperatorUsersStore = mock(FileOperatorUsersStore.class); + operatorOnlyRegistry = mock(OperatorOnlyRegistry.class); + } + + public void testWillNotProcessWhenFeatureIsDisabledOrLicenseDoesNotSupport() { + final Settings settings = Settings.builder() + .put("xpack.security.operator_privileges.enabled", randomBoolean()) + .build(); + when(xPackLicenseState.checkFeature(XPackLicenseState.Feature.OPERATOR_PRIVILEGES)).thenReturn(false); + + final OperatorPrivilegesService operatorPrivilegesService = + new DefaultOperatorPrivilegesService(xPackLicenseState, fileOperatorUsersStore, operatorOnlyRegistry); + final ThreadContext threadContext = new ThreadContext(settings); + + operatorPrivilegesService.maybeMarkOperatorUser(mock(Authentication.class), threadContext); + verifyZeroInteractions(fileOperatorUsersStore); + + final ElasticsearchSecurityException e = + operatorPrivilegesService.check("cluster:action", mock(TransportRequest.class), threadContext); + assertNull(e); + verifyZeroInteractions(operatorOnlyRegistry); + } + + public void testMarkOperatorUser() { + final Settings settings = Settings.builder() + .put("xpack.security.operator_privileges.enabled", true) + .build(); + when(xPackLicenseState.checkFeature(XPackLicenseState.Feature.OPERATOR_PRIVILEGES)).thenReturn(true); + final Authentication operatorAuth = mock(Authentication.class); + final Authentication nonOperatorAuth = mock(Authentication.class); + when(operatorAuth.getUser()).thenReturn(new User("operator_user")); + when(fileOperatorUsersStore.isOperatorUser(operatorAuth)).thenReturn(true); + when(fileOperatorUsersStore.isOperatorUser(nonOperatorAuth)).thenReturn(false); + + final OperatorPrivilegesService operatorPrivilegesService = + new DefaultOperatorPrivilegesService(xPackLicenseState, fileOperatorUsersStore, operatorOnlyRegistry); + ThreadContext threadContext = new ThreadContext(settings); + + operatorPrivilegesService.maybeMarkOperatorUser(operatorAuth, threadContext); + assertEquals(AuthenticationField.PRIVILEGE_CATEGORY_VALUE_OPERATOR, + threadContext.getHeader(AuthenticationField.PRIVILEGE_CATEGORY_KEY)); + + threadContext = new ThreadContext(settings); + operatorPrivilegesService.maybeMarkOperatorUser(nonOperatorAuth, threadContext); + assertNull(threadContext.getHeader(AuthenticationField.PRIVILEGE_CATEGORY_KEY)); + } + + public void testCheck() { + final Settings settings = Settings.builder() + .put("xpack.security.operator_privileges.enabled", true) + .build(); + when(xPackLicenseState.checkFeature(XPackLicenseState.Feature.OPERATOR_PRIVILEGES)).thenReturn(true); + + final String operatorAction = "cluster:operator_only/action"; + final String nonOperatorAction = "cluster:non_operator/action"; + final String message = "[" + operatorAction + "]"; + when(operatorOnlyRegistry.check(eq(operatorAction), any())).thenReturn(() -> message); + when(operatorOnlyRegistry.check(eq(nonOperatorAction), any())).thenReturn(null); + + final OperatorPrivilegesService operatorPrivilegesService = + new DefaultOperatorPrivilegesService(xPackLicenseState, fileOperatorUsersStore, operatorOnlyRegistry); + + ThreadContext threadContext = new ThreadContext(settings); + if (randomBoolean()) { + threadContext.putHeader(AuthenticationField.PRIVILEGE_CATEGORY_KEY, AuthenticationField.PRIVILEGE_CATEGORY_VALUE_OPERATOR); + assertNull(operatorPrivilegesService.check(operatorAction, mock(TransportRequest.class), threadContext)); + } else { + final ElasticsearchSecurityException e = operatorPrivilegesService.check( + operatorAction, mock(TransportRequest.class), threadContext); + assertNotNull(e); + assertThat(e.getMessage(), containsString("Operator privileges are required for " + message)); + } + + assertNull(operatorPrivilegesService.check(nonOperatorAction, mock(TransportRequest.class), threadContext)); + } + + public void testNoOpService() { + final Authentication authentication = mock(Authentication.class); + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + NOOP_OPERATOR_PRIVILEGES_SERVICE.maybeMarkOperatorUser(authentication, threadContext); + verifyZeroInteractions(authentication); + assertNull(threadContext.getHeader(AuthenticationField.PRIVILEGE_CATEGORY_KEY)); + + final TransportRequest request = mock(TransportRequest.class); + assertNull(NOOP_OPERATOR_PRIVILEGES_SERVICE.check( + randomAlphaOfLengthBetween(10, 20), request, threadContext)); + verifyZeroInteractions(request); + } + +} diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/operator/operator_users.yml b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/operator/operator_users.yml new file mode 100644 index 0000000000000..507f4d550e446 --- /dev/null +++ b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/operator/operator_users.yml @@ -0,0 +1,6 @@ +operator: + - usernames: ['operator_1', 'operator_2'] + realm_name: file + realm_type: file + auth_type: realm + - usernames: ['operator_3']