Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/changelog/114964.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pr: 114964
summary: Add a `monitor_stats` privilege and allow that privilege for remote cluster
privileges
area: Authorization
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ The result would then have the `errors` field set to `true` and hold the error f
"details": {
"my_admin_role": { <4>
"type": "action_request_validation_exception",
"reason": "Validation Failed: 1: unknown cluster privilege [bad_cluster_privilege]. a privilege must be either one of the predefined cluster privilege names [manage_own_api_key,manage_data_stream_global_retention,monitor_data_stream_global_retention,none,cancel_task,cross_cluster_replication,cross_cluster_search,delegate_pki,grant_api_key,manage_autoscaling,manage_index_templates,manage_logstash_pipelines,manage_oidc,manage_saml,manage_search_application,manage_search_query_rules,manage_search_synonyms,manage_service_account,manage_token,manage_user_profile,monitor_connector,monitor_enrich,monitor_inference,monitor_ml,monitor_rollup,monitor_snapshot,monitor_text_structure,monitor_watcher,post_behavioral_analytics_event,read_ccr,read_connector_secrets,read_fleet_secrets,read_ilm,read_pipeline,read_security,read_slm,transport_client,write_connector_secrets,write_fleet_secrets,create_snapshot,manage_behavioral_analytics,manage_ccr,manage_connector,manage_enrich,manage_ilm,manage_inference,manage_ml,manage_rollup,manage_slm,manage_watcher,monitor_data_frame_transforms,monitor_transform,manage_api_key,manage_ingest_pipelines,manage_pipeline,manage_data_frame_transforms,manage_transform,manage_security,monitor,manage,all] or a pattern over one of the available cluster actions;"
"reason": "Validation Failed: 1: unknown cluster privilege [bad_cluster_privilege]. a privilege must be either one of the predefined cluster privilege names [manage_own_api_key,manage_data_stream_global_retention,monitor_data_stream_global_retention,none,cancel_task,cross_cluster_replication,cross_cluster_search,delegate_pki,grant_api_key,manage_autoscaling,manage_index_templates,manage_logstash_pipelines,manage_oidc,manage_saml,manage_search_application,manage_search_query_rules,manage_search_synonyms,manage_service_account,manage_token,manage_user_profile,monitor_connector,monitor_enrich,monitor_inference,monitor_ml,monitor_rollup,monitor_snapshot,monitor_stats,monitor_text_structure,monitor_watcher,post_behavioral_analytics_event,read_ccr,read_connector_secrets,read_fleet_secrets,read_ilm,read_pipeline,read_security,read_slm,transport_client,write_connector_secrets,write_fleet_secrets,create_snapshot,manage_behavioral_analytics,manage_ccr,manage_connector,manage_enrich,manage_ilm,manage_inference,manage_ml,manage_rollup,manage_slm,manage_watcher,monitor_data_frame_transforms,monitor_transform,manage_api_key,manage_ingest_pipelines,manage_pipeline,manage_data_frame_transforms,manage_transform,manage_security,monitor,manage,all] or a pattern over one of the available cluster actions;"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ A successful call returns an object with "cluster", "index", and "remote_cluster
"monitor_ml",
"monitor_rollup",
"monitor_snapshot",
"monitor_stats",
"monitor_text_structure",
"monitor_transform",
"monitor_watcher",
Expand Down Expand Up @@ -152,7 +153,8 @@ A successful call returns an object with "cluster", "index", and "remote_cluster
"write"
],
"remote_cluster" : [
"monitor_enrich"
"monitor_enrich",
"monitor_stats"
]
}
--------------------------------------------------
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ static TransportVersion def(int id) {
public static final TransportVersion LOGSDB_TELEMETRY = def(8_784_00_0);
public static final TransportVersion LOGSDB_TELEMETRY_STATS = def(8_785_00_0);
public static final TransportVersion KQL_QUERY_ADDED = def(8_786_00_0);
public static final TransportVersion ROLE_MONITOR_STATS = def(8_787_00_0);

/*
* STOP! READ THIS FIRST! No, really,
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugin/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -207,5 +207,6 @@ tasks.named("yamlRestTestV7CompatTransform").configure({ task ->
task.skipTest("esql/60_usage/Basic ESQL usage output (telemetry) non-snapshot version", "The number of functions is constantly increasing")
task.skipTest("esql/80_text/reverse text", "The output type changed from TEXT to KEYWORD.")
task.skipTest("esql/80_text/values function", "The output type changed from TEXT to KEYWORD.")
task.skipTest("privileges/11_builtin/Test get builtin privileges" ,"unnecessary to test compatibility")
})

Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public boolean hasRemoteIndicesPrivileges() {
}

public boolean hasRemoteClusterPrivileges() {
return remoteClusterPermissions.hasPrivileges();
return remoteClusterPermissions.hasAnyPrivileges();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountSettings;
import org.elasticsearch.xpack.core.security.authc.support.AuthenticationContextSerializer;
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
import org.elasticsearch.xpack.core.security.authz.permission.RemoteClusterPermissions;
import org.elasticsearch.xpack.core.security.user.AnonymousUser;
import org.elasticsearch.xpack.core.security.user.InternalUser;
import org.elasticsearch.xpack.core.security.user.InternalUsers;
Expand Down Expand Up @@ -76,6 +77,7 @@
import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.FALLBACK_REALM_NAME;
import static org.elasticsearch.xpack.core.security.authc.AuthenticationField.FALLBACK_REALM_TYPE;
import static org.elasticsearch.xpack.core.security.authc.RealmDomain.REALM_DOMAIN_PARSER;
import static org.elasticsearch.xpack.core.security.authz.RoleDescriptor.Fields.REMOTE_CLUSTER;
import static org.elasticsearch.xpack.core.security.authz.permission.RemoteClusterPermissions.ROLE_REMOTE_CLUSTER_PRIVS;

/**
Expand Down Expand Up @@ -233,8 +235,8 @@ public Authentication maybeRewriteForOlderVersion(TransportVersion olderVersion)
+ "]"
);
}

final Map<String, Object> newMetadata = maybeRewriteMetadata(olderVersion, this);

final Authentication newAuthentication;
if (isRunAs()) {
// The lookup user for run-as currently doesn't have authentication metadata associated with them because
Expand Down Expand Up @@ -272,12 +274,23 @@ public Authentication maybeRewriteForOlderVersion(TransportVersion olderVersion)
}

private static Map<String, Object> maybeRewriteMetadata(TransportVersion olderVersion, Authentication authentication) {
if (authentication.isAuthenticatedAsApiKey()) {
return maybeRewriteMetadataForApiKeyRoleDescriptors(olderVersion, authentication);
} else if (authentication.isCrossClusterAccess()) {
return maybeRewriteMetadataForCrossClusterAccessAuthentication(olderVersion, authentication);
} else {
return authentication.getAuthenticatingSubject().getMetadata();
try {
if (authentication.isAuthenticatedAsApiKey()) {
return maybeRewriteMetadataForApiKeyRoleDescriptors(olderVersion, authentication);
} else if (authentication.isCrossClusterAccess()) {
return maybeRewriteMetadataForCrossClusterAccessAuthentication(olderVersion, authentication);
} else {
return authentication.getAuthenticatingSubject().getMetadata();
}
} catch (Exception e) {
// CCS workflows may swallow the exception message making this difficult to troubleshoot, so we explicitly log and re-throw
// here. It may result in duplicate logs, so we only log the message at warn level.
if (logger.isDebugEnabled()) {
logger.debug("Un-expected exception thrown while rewriting metadata. This is likely a bug.", e);
} else {
logger.warn("Un-expected exception thrown while rewriting metadata. This is likely a bug [" + e.getMessage() + "]");
}
throw e;
}
}

Expand Down Expand Up @@ -1323,6 +1336,7 @@ private static Map<String, Object> maybeRewriteMetadataForApiKeyRoleDescriptors(

if (authentication.getEffectiveSubject().getTransportVersion().onOrAfter(ROLE_REMOTE_CLUSTER_PRIVS)
&& streamVersion.before(ROLE_REMOTE_CLUSTER_PRIVS)) {
// the authentication understands the remote_cluster field but the stream does not
metadata = new HashMap<>(metadata);
metadata.put(
AuthenticationField.API_KEY_ROLE_DESCRIPTORS_KEY,
Expand All @@ -1336,7 +1350,26 @@ private static Map<String, Object> maybeRewriteMetadataForApiKeyRoleDescriptors(
(BytesReference) metadata.get(AuthenticationField.API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY)
)
);
}
} else if (authentication.getEffectiveSubject().getTransportVersion().onOrAfter(ROLE_REMOTE_CLUSTER_PRIVS)
&& streamVersion.onOrAfter(ROLE_REMOTE_CLUSTER_PRIVS)) {
// both the authentication object and the stream understand the remote_cluster field
// check each individual permission and remove as needed
metadata = new HashMap<>(metadata);
metadata.put(
AuthenticationField.API_KEY_ROLE_DESCRIPTORS_KEY,
maybeRemoveRemoteClusterPrivilegesFromRoleDescriptors(
(BytesReference) metadata.get(AuthenticationField.API_KEY_ROLE_DESCRIPTORS_KEY),
streamVersion
)
);
metadata.put(
AuthenticationField.API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY,
maybeRemoveRemoteClusterPrivilegesFromRoleDescriptors(
(BytesReference) metadata.get(AuthenticationField.API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY),
streamVersion
)
);
}

if (authentication.getEffectiveSubject().getTransportVersion().onOrAfter(VERSION_API_KEY_ROLES_AS_BYTES)
&& streamVersion.before(VERSION_API_KEY_ROLES_AS_BYTES)) {
Expand Down Expand Up @@ -1417,7 +1450,7 @@ private static BytesReference convertRoleDescriptorsMapToBytes(Map<String, Objec
}

static BytesReference maybeRemoveRemoteClusterFromRoleDescriptors(BytesReference roleDescriptorsBytes) {
return maybeRemoveTopLevelFromRoleDescriptors(roleDescriptorsBytes, RoleDescriptor.Fields.REMOTE_CLUSTER.getPreferredName());
return maybeRemoveTopLevelFromRoleDescriptors(roleDescriptorsBytes, REMOTE_CLUSTER.getPreferredName());
}

static BytesReference maybeRemoveRemoteIndicesFromRoleDescriptors(BytesReference roleDescriptorsBytes) {
Expand Down Expand Up @@ -1450,6 +1483,66 @@ static BytesReference maybeRemoveTopLevelFromRoleDescriptors(BytesReference role
}
}

/**
* Before we send the role descriptors to the remote cluster, we need to remove the remote cluster privileges that the other cluster
* will not understand. If all privileges are removed, then the entire "remote_cluster" is removed to avoid sending empty privileges.
* @param roleDescriptorsBytes The role descriptors to be sent to the remote cluster, represented as bytes.
* @return The role descriptors with the privileges that unsupported by version removed, represented as bytes.
*/
@SuppressWarnings("unchecked")
static BytesReference maybeRemoveRemoteClusterPrivilegesFromRoleDescriptors(
BytesReference roleDescriptorsBytes,
TransportVersion outboundVersion
) {
if (roleDescriptorsBytes == null || roleDescriptorsBytes.length() == 0) {
return roleDescriptorsBytes;
}
final Map<String, Object> roleDescriptorsMap = convertRoleDescriptorsBytesToMap(roleDescriptorsBytes);
final Map<String, Object> roleDescriptorsMapMutated = new HashMap<>(roleDescriptorsMap);
final AtomicBoolean modified = new AtomicBoolean(false);
roleDescriptorsMap.forEach((key, value) -> {
if (value instanceof Map) {
Map<String, Object> roleDescriptor = (Map<String, Object>) value;
roleDescriptor.forEach((innerKey, innerValue) -> {
// example: remote_cluster=[{privileges=[monitor_enrich, monitor_stats]
if (REMOTE_CLUSTER.getPreferredName().equals(innerKey)) {
assert innerValue instanceof List;
RemoteClusterPermissions discoveredRemoteClusterPermission = new RemoteClusterPermissions(
(List<Map<String, List<String>>>) innerValue
);
RemoteClusterPermissions mutated = discoveredRemoteClusterPermission.removeUnsupportedPrivileges(outboundVersion);
if (mutated.equals(discoveredRemoteClusterPermission) == false) {
// swap out the old value with the new value
modified.set(true);
Map<String, Object> remoteClusterMap = new HashMap<>((Map<String, Object>) roleDescriptorsMapMutated.get(key));
if (mutated.hasAnyPrivileges()) {
// has at least one group with privileges
remoteClusterMap.put(innerKey, mutated.toMap());
} else {
// has no groups with privileges
remoteClusterMap.remove(innerKey);
}
roleDescriptorsMapMutated.put(key, remoteClusterMap);
}
}
});
}
});
if (modified.get()) {
logger.debug(
"mutated role descriptors. Changed from {} to {} for outbound version {}",
roleDescriptorsMap,
roleDescriptorsMapMutated,
outboundVersion
);
return convertRoleDescriptorsMapToBytes(roleDescriptorsMapMutated);
} else {
// No need to serialize if we did not change anything.
logger.trace("no change to role descriptors {} for outbound version {}", roleDescriptorsMap, outboundVersion);
return roleDescriptorsBytes;
}
}

static boolean equivalentRealms(String name1, String type1, String name2, String type2) {
if (false == type1.equals(type2)) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
*/
package org.elasticsearch.xpack.core.security.authz;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.ElasticsearchParseException;
import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.TransportVersion;
Expand Down Expand Up @@ -62,6 +64,7 @@ public class RoleDescriptor implements ToXContentObject, Writeable {
public static final TransportVersion SECURITY_ROLE_DESCRIPTION = TransportVersions.V_8_15_0;

public static final String ROLE_TYPE = "role";
private static final Logger logger = LogManager.getLogger(RoleDescriptor.class);

private final String name;
private final String[] clusterPrivileges;
Expand Down Expand Up @@ -191,7 +194,7 @@ public RoleDescriptor(
? Collections.unmodifiableMap(transientMetadata)
: Collections.singletonMap("enabled", true);
this.remoteIndicesPrivileges = remoteIndicesPrivileges != null ? remoteIndicesPrivileges : RemoteIndicesPrivileges.NONE;
this.remoteClusterPermissions = remoteClusterPermissions != null && remoteClusterPermissions.hasPrivileges()
this.remoteClusterPermissions = remoteClusterPermissions != null && remoteClusterPermissions.hasAnyPrivileges()
? remoteClusterPermissions
: RemoteClusterPermissions.NONE;
this.restriction = restriction != null ? restriction : Restriction.NONE;
Expand Down Expand Up @@ -263,7 +266,7 @@ public boolean hasRemoteIndicesPrivileges() {
}

public boolean hasRemoteClusterPermissions() {
return remoteClusterPermissions.hasPrivileges();
return remoteClusterPermissions.hasAnyPrivileges();
}

public RemoteClusterPermissions getRemoteClusterPermissions() {
Expand Down Expand Up @@ -830,25 +833,32 @@ private static RemoteClusterPermissions parseRemoteCluster(final String roleName
currentFieldName = parser.currentName();
} else if (Fields.PRIVILEGES.match(currentFieldName, parser.getDeprecationHandler())) {
privileges = readStringArray(roleName, parser, false);
if (privileges.length != 1
|| RemoteClusterPermissions.getSupportedRemoteClusterPermissions()
.contains(privileges[0].trim().toLowerCase(Locale.ROOT)) == false) {
throw new ElasticsearchParseException(
"failed to parse remote_cluster for role [{}]. "
+ RemoteClusterPermissions.getSupportedRemoteClusterPermissions()
+ " is the only value allowed for [{}] within [remote_cluster]",
if (Arrays.stream(privileges)
.map(s -> s.toLowerCase(Locale.ROOT).trim())
.allMatch(RemoteClusterPermissions.getSupportedRemoteClusterPermissions()::contains) == false) {
final String message = String.format(
Locale.ROOT,
"failed to parse remote_cluster for role [%s]. "
+ "%s are the only values allowed for [%s] within [remote_cluster]. Found %s",
roleName,
currentFieldName
RemoteClusterPermissions.getSupportedRemoteClusterPermissions(),
currentFieldName,
Arrays.toString(privileges)
);
logger.info(message);
throw new ElasticsearchParseException(message);
}
} else if (Fields.CLUSTERS.match(currentFieldName, parser.getDeprecationHandler())) {
clusters = readStringArray(roleName, parser, false);
} else {
throw new ElasticsearchParseException(
"failed to parse remote_cluster for role [{}]. unexpected field [{}]",
final String message = String.format(
Locale.ROOT,
"failed to parse remote_cluster for role [%s]. unexpected field [%s]",
roleName,
currentFieldName
);
logger.info(message);
throw new ElasticsearchParseException(message);
}
}
if (privileges != null && clusters == null) {
Expand Down
Loading