فهرست منبع

Allow configuring SAML private attributes (#133154)

This PR is twofold:
- adds a new `private_attributes` setting to the SAML realm, and
- introduces extension point that allows providing a custom `SamlAuthenticateResponseHandler`

The `private_attributes` setting can be used to define which SAML 
attributes should be treated as private. This implies that these 
attributes will not be logged or returned as part of user's metadata 
when `populate_user_metadata` is set to `true`.
Slobodan Adamović 1 ماه پیش
والد
کامیت
0c6100d175
13فایلهای تغییر یافته به همراه499 افزوده شده و 61 حذف شده
  1. 5 0
      docs/changelog/133154.yaml
  2. 53 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/saml/SamlRealmSettings.java
  3. 12 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
  4. 7 26
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/saml/TransportSamlAuthenticateAction.java
  5. 46 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/DefaultSamlAuthenticateResponseHandler.java
  6. 93 8
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAttributes.java
  7. 51 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticateResponseHandler.java
  8. 31 8
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticator.java
  9. 35 2
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlRealm.java
  10. 11 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/saml/SamlAttributesTests.java
  11. 30 1
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticatorTests.java
  12. 115 15
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/saml/SamlRealmTests.java
  13. 10 0
      x-pack/qa/saml-idp-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticationIT.java

+ 5 - 0
docs/changelog/133154.yaml

@@ -0,0 +1,5 @@
+pr: 133154
+summary: Allow configuring SAML private attributes
+area: Authentication
+type: enhancement
+issues: []

+ 53 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/saml/SamlRealmSettings.java

@@ -6,6 +6,7 @@
  */
 package org.elasticsearch.xpack.core.security.authc.saml;
 
+import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.settings.Setting;
 import org.elasticsearch.common.settings.SettingsException;
 import org.elasticsearch.common.util.set.Sets;
@@ -139,6 +140,56 @@ public class SamlRealmSettings {
         key -> Setting.positiveTimeSetting(key, TimeValue.timeValueMinutes(3), Setting.Property.NodeScope)
     );
 
+    /**
+     * The names of attributes that should be treated as private and never populated as part of the user's metadata
+     * (even when {@code #POPULATE_USER_METADATA} is configured).
+     */
+    public static final Function<String, Setting.AffixSetting<List<String>>> PRIVATE_ATTRIBUTES = (type) -> Setting.affixKeySetting(
+        RealmSettings.realmSettingPrefix(type),
+        "private_attributes",
+        (namespace, key) -> Setting.stringListSetting(key, new Setting.Validator<>() {
+
+            @Override
+            public Iterator<Setting<?>> settings() {
+                final List<Setting<?>> settings = List.of(
+                    PRINCIPAL_ATTRIBUTE.apply(type).getAttribute().getConcreteSettingForNamespace(namespace),
+                    GROUPS_ATTRIBUTE.apply(type).getAttributeSetting().getAttribute().getConcreteSettingForNamespace(namespace),
+                    DN_ATTRIBUTE.apply(type).getAttribute().getConcreteSettingForNamespace(namespace),
+                    NAME_ATTRIBUTE.apply(type).getAttribute().getConcreteSettingForNamespace(namespace),
+                    MAIL_ATTRIBUTE.apply(type).getAttribute().getConcreteSettingForNamespace(namespace)
+                );
+                return settings.iterator();
+            }
+
+            @Override
+            public void validate(List<String> attributes) {
+                verifyNonNullNotEmpty(key, attributes);
+            }
+
+            @Override
+            public void validate(List<String> privateAttributes, Map<Setting<?>, Object> settings) {
+                if (false == privateAttributes.isEmpty()) {
+                    final Set<String> privateAttributesSet = Set.copyOf(privateAttributes);
+                    this.settings().forEachRemaining(attributeSetting -> {
+                        String attributeName = (String) settings.get(attributeSetting);
+
+                        if (false == Strings.isNullOrBlank(attributeName) && privateAttributesSet.contains(attributeName)) {
+                            throw new SettingsException(
+                                "SAML Attribute ["
+                                    + attributeName
+                                    + "] cannot be both configured for ["
+                                    + key
+                                    + "] and ["
+                                    + attributeSetting.getKey()
+                                    + "] settings."
+                            );
+                        }
+                    });
+                }
+            }
+        }, Setting.Property.NodeScope)
+    );
+
     public static final Function<String, Setting.AffixSetting<List<String>>> EXCLUDE_ROLES = (type) -> Setting.affixKeySetting(
         RealmSettings.realmSettingPrefix(type),
         "exclude_roles",
@@ -201,7 +252,8 @@ public class SamlRealmSettings {
             ENCRYPTION_KEY_ALIAS.apply(type),
             SIGNING_KEY_ALIAS.apply(type),
             SIGNING_MESSAGE_TYPES.apply(type),
-            REQUESTED_AUTHN_CONTEXT_CLASS_REF.apply(type)
+            REQUESTED_AUTHN_CONTEXT_CLASS_REF.apply(type),
+            PRIVATE_ATTRIBUTES.apply(type)
         );
 
         set.addAll(X509KeyPairSettings.affix(RealmSettings.realmSettingPrefix(type), ENCRYPTION_SETTING_KEY, false));

+ 12 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

@@ -304,6 +304,7 @@ import org.elasticsearch.xpack.security.authc.TokenService;
 import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
 import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
 import org.elasticsearch.xpack.security.authc.jwt.JwtRealm;
+import org.elasticsearch.xpack.security.authc.saml.SamlAuthenticateResponseHandler;
 import org.elasticsearch.xpack.security.authc.service.CachingServiceAccountTokenStore;
 import org.elasticsearch.xpack.security.authc.service.CompositeServiceAccountTokenStore;
 import org.elasticsearch.xpack.security.authc.service.FileServiceAccountTokenStore;
@@ -628,6 +629,7 @@ public class Security extends Plugin
     private final SetOnce<FileRoleValidator> fileRoleValidator = new SetOnce<>();
     private final SetOnce<SecondaryAuthActions> secondaryAuthActions = new SetOnce<>();
     private final SetOnce<QueryableBuiltInRolesProviderFactory> queryableRolesProviderFactory = new SetOnce<>();
+    private final SetOnce<SamlAuthenticateResponseHandler.Factory> samlAuthenticateResponseHandlerFactory = new SetOnce<>();
 
     private final SetOnce<SecurityMigrations.Manager> migrationManager = new SetOnce<>();
     private final SetOnce<List<Closeable>> closableComponents = new SetOnce<>();
@@ -957,6 +959,15 @@ public class Security extends Plugin
         if (fileRoleValidator.get() == null) {
             fileRoleValidator.set(new FileRoleValidator.Default());
         }
+        if (samlAuthenticateResponseHandlerFactory.get() == null) {
+            samlAuthenticateResponseHandlerFactory.set(new SamlAuthenticateResponseHandler.DefaultFactory());
+        }
+        components.add(
+            new PluginComponentBinding<>(
+                SamlAuthenticateResponseHandler.class,
+                samlAuthenticateResponseHandlerFactory.get().create(settings, tokenService, getClock())
+            )
+        );
         this.fileRolesStore.set(
             new FileRolesStore(settings, environment, resourceWatcherService, getLicenseState(), xContentRegistry, fileRoleValidator.get())
         );
@@ -2419,6 +2430,7 @@ public class Security extends Plugin
         loadSingletonExtensionAndSetOnce(loader, fileRoleValidator, FileRoleValidator.class);
         loadSingletonExtensionAndSetOnce(loader, secondaryAuthActions, SecondaryAuthActions.class);
         loadSingletonExtensionAndSetOnce(loader, queryableRolesProviderFactory, QueryableBuiltInRolesProviderFactory.class);
+        loadSingletonExtensionAndSetOnce(loader, samlAuthenticateResponseHandlerFactory, SamlAuthenticateResponseHandler.Factory.class);
     }
 
     private <T> void loadSingletonExtensionAndSetOnce(ExtensionLoader loader, SetOnce<T> setOnce, Class<T> clazz) {

+ 7 - 26
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/saml/TransportSamlAuthenticateAction.java

@@ -12,7 +12,6 @@ import org.elasticsearch.action.support.ActionFilters;
 import org.elasticsearch.action.support.HandledTransportAction;
 import org.elasticsearch.common.util.concurrent.EsExecutors;
 import org.elasticsearch.common.util.concurrent.ThreadContext;
-import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.injection.guice.Inject;
 import org.elasticsearch.tasks.Task;
 import org.elasticsearch.threadpool.ThreadPool;
@@ -25,11 +24,9 @@ import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
 import org.elasticsearch.xpack.core.security.user.User;
 import org.elasticsearch.xpack.security.authc.AuthenticationService;
-import org.elasticsearch.xpack.security.authc.TokenService;
-import org.elasticsearch.xpack.security.authc.saml.SamlRealm;
+import org.elasticsearch.xpack.security.authc.saml.SamlAuthenticateResponseHandler;
 import org.elasticsearch.xpack.security.authc.saml.SamlToken;
 
-import java.util.Map;
 import java.util.concurrent.Executor;
 
 /**
@@ -39,7 +36,7 @@ public final class TransportSamlAuthenticateAction extends HandledTransportActio
 
     private final ThreadPool threadPool;
     private final AuthenticationService authenticationService;
-    private final TokenService tokenService;
+    private final SamlAuthenticateResponseHandler tokenHandler;
     private final SecurityContext securityContext;
     private final Executor genericExecutor;
 
@@ -49,7 +46,7 @@ public final class TransportSamlAuthenticateAction extends HandledTransportActio
         TransportService transportService,
         ActionFilters actionFilters,
         AuthenticationService authenticationService,
-        TokenService tokenService,
+        SamlAuthenticateResponseHandler tokenHandler,
         SecurityContext securityContext
     ) {
         // TODO replace DIRECT_EXECUTOR_SERVICE when removing workaround for https://github.com/elastic/elasticsearch/issues/97916
@@ -62,7 +59,7 @@ public final class TransportSamlAuthenticateAction extends HandledTransportActio
         );
         this.threadPool = threadPool;
         this.authenticationService = authenticationService;
-        this.tokenService = tokenService;
+        this.tokenHandler = tokenHandler;
         this.securityContext = securityContext;
         this.genericExecutor = threadPool.generic();
     }
@@ -88,25 +85,9 @@ public final class TransportSamlAuthenticateAction extends HandledTransportActio
                 }
                 assert authentication != null : "authentication should never be null at this point";
                 assert false == authentication.isRunAs() : "saml realm authentication cannot have run-as";
-                @SuppressWarnings("unchecked")
-                final Map<String, Object> tokenMeta = (Map<String, Object>) result.getMetadata().get(SamlRealm.CONTEXT_TOKEN_DATA);
-                tokenService.createOAuth2Tokens(
-                    authentication,
-                    originatingAuthentication,
-                    tokenMeta,
-                    true,
-                    ActionListener.wrap(tokenResult -> {
-                        final TimeValue expiresIn = tokenService.getExpirationDelay();
-                        listener.onResponse(
-                            new SamlAuthenticateResponse(
-                                authentication,
-                                tokenResult.getAccessToken(),
-                                tokenResult.getRefreshToken(),
-                                expiresIn
-                            )
-                        );
-                    }, listener::onFailure)
-                );
+                assert result.isAuthenticated();
+                tokenHandler.handleTokenResponse(authentication, originatingAuthentication, result, listener);
+
             }, e -> {
                 logger.debug(() -> "SamlToken [" + saml + "] could not be authenticated", e);
                 listener.onFailure(e);

+ 46 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/DefaultSamlAuthenticateResponseHandler.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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.security.authc.saml;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.core.TimeValue;
+import org.elasticsearch.xpack.core.security.action.saml.SamlAuthenticateResponse;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
+import org.elasticsearch.xpack.core.security.user.User;
+import org.elasticsearch.xpack.security.authc.TokenService;
+
+import java.util.Map;
+
+/**
+ * Default implementation of {@link SamlAuthenticateResponseHandler} that returns tokens crested using the {@link TokenService}.
+ */
+public final class DefaultSamlAuthenticateResponseHandler implements SamlAuthenticateResponseHandler {
+
+    private final TokenService tokenService;
+
+    public DefaultSamlAuthenticateResponseHandler(TokenService tokenService) {
+        this.tokenService = tokenService;
+    }
+
+    @Override
+    public void handleTokenResponse(
+        Authentication authentication,
+        Authentication originatingAuthentication,
+        AuthenticationResult<User> authenticationResult,
+        ActionListener<SamlAuthenticateResponse> listener
+    ) {
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> tokenMeta = (Map<String, Object>) authenticationResult.getMetadata().get(SamlRealm.CONTEXT_TOKEN_DATA);
+        tokenService.createOAuth2Tokens(authentication, originatingAuthentication, tokenMeta, true, ActionListener.wrap(tokenResult -> {
+            final TimeValue expiresIn = tokenService.getExpirationDelay();
+            listener.onResponse(
+                new SamlAuthenticateResponse(authentication, tokenResult.getAccessToken(), tokenResult.getRefreshToken(), expiresIn)
+            );
+        }, listener::onFailure));
+    }
+}

+ 93 - 8
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAttributes.java

@@ -7,7 +7,10 @@
 package org.elasticsearch.xpack.security.authc.saml;
 
 import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.core.IOUtils;
 import org.elasticsearch.core.Nullable;
+import org.elasticsearch.core.Releasable;
 import org.opensaml.saml.saml2.core.Attribute;
 import org.opensaml.saml.saml2.core.NameIDType;
 
@@ -17,7 +20,7 @@ import java.util.Objects;
 /**
  * An lightweight collection of SAML attributes
  */
-public class SamlAttributes {
+public class SamlAttributes implements Releasable {
 
     public static final String NAMEID_SYNTHENTIC_ATTRIBUTE = "nameid";
     public static final String PERSISTENT_NAMEID_SYNTHENTIC_ATTRIBUTE = "nameid:persistent";
@@ -25,11 +28,13 @@ public class SamlAttributes {
     private final SamlNameId name;
     private final String session;
     private final List<SamlAttribute> attributes;
+    private final List<SamlPrivateAttribute> privateAttributes;
 
-    SamlAttributes(SamlNameId name, String session, List<SamlAttribute> attributes) {
+    SamlAttributes(SamlNameId name, String session, List<SamlAttribute> attributes, List<SamlPrivateAttribute> privateAttributes) {
         this.name = name;
         this.session = session;
         this.attributes = attributes;
+        this.privateAttributes = privateAttributes;
     }
 
     /**
@@ -54,10 +59,28 @@ public class SamlAttributes {
             .toList();
     }
 
+    List<SecureString> getPrivateAttributeValues(String attributeId) {
+        if (Strings.isNullOrEmpty(attributeId)) {
+            return List.of();
+        }
+        return privateAttributes.stream()
+            .filter(attr -> attributeId.equals(attr.name) || attributeId.equals(attr.friendlyName))
+            .flatMap(attr -> attr.values.stream())
+            .toList();
+    }
+
     List<SamlAttribute> attributes() {
         return attributes;
     }
 
+    List<SamlPrivateAttribute> privateAttributes() {
+        return privateAttributes;
+    }
+
+    boolean isEmpty() {
+        return attributes.isEmpty() && privateAttributes.isEmpty();
+    }
+
     SamlNameId name() {
         return name;
     }
@@ -68,13 +91,40 @@ public class SamlAttributes {
 
     @Override
     public String toString() {
-        return getClass().getSimpleName() + "(" + name + ")[" + session + "]{" + attributes + "}";
+        return getClass().getSimpleName() + "(" + name + ")[" + session + "]{" + attributes + "}{" + privateAttributes + "}";
     }
 
-    static class SamlAttribute {
+    @Override
+    public void close() {
+        IOUtils.closeWhileHandlingException(privateAttributes);
+    }
+
+    abstract static class AbstractSamlAttribute<T> {
+
         final String name;
         final String friendlyName;
-        final List<String> values;
+        final List<T> values;
+
+        protected AbstractSamlAttribute(String name, @Nullable String friendlyName, List<T> values) {
+            this.name = Objects.requireNonNull(name, "Attribute name cannot be null");
+            this.friendlyName = friendlyName;
+            this.values = values;
+        }
+
+        String name() {
+            return name;
+        }
+
+        String friendlyName() {
+            return friendlyName;
+        }
+
+        List<T> values() {
+            return values;
+        }
+    }
+
+    static class SamlAttribute extends AbstractSamlAttribute<String> {
 
         SamlAttribute(Attribute attribute) {
             this(
@@ -85,9 +135,7 @@ public class SamlAttributes {
         }
 
         SamlAttribute(String name, @Nullable String friendlyName, List<String> values) {
-            this.name = Objects.requireNonNull(name, "Attribute name cannot be null");
-            this.friendlyName = friendlyName;
-            this.values = values;
+            super(name, friendlyName, values);
         }
 
         @Override
@@ -103,4 +151,41 @@ public class SamlAttributes {
         }
     }
 
+    static class SamlPrivateAttribute extends AbstractSamlAttribute<SecureString> implements Releasable {
+
+        SamlPrivateAttribute(Attribute attribute) {
+            super(
+                attribute.getName(),
+                attribute.getFriendlyName(),
+                attribute.getAttributeValues()
+                    .stream()
+                    .map(x -> x.getDOM().getTextContent())
+                    .filter(Objects::nonNull)
+                    .map(String::toCharArray)
+                    .map(SecureString::new)
+                    .toList()
+            );
+        }
+
+        SamlPrivateAttribute(String name, @Nullable String friendlyName, List<SecureString> values) {
+            super(name, friendlyName, values);
+        }
+
+        @Override
+        public String toString() {
+            StringBuilder str = new StringBuilder();
+            if (Strings.isNullOrEmpty(friendlyName)) {
+                str.append(name);
+            } else {
+                str.append(friendlyName).append('(').append(name).append(')');
+            }
+            str.append("=[").append(values.size()).append(" value(s)]");
+            return str.toString();
+        }
+
+        @Override
+        public void close() {
+            IOUtils.closeWhileHandlingException(values);
+        }
+    }
 }

+ 51 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticateResponseHandler.java

@@ -0,0 +1,51 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.security.authc.saml;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.xpack.core.security.action.saml.SamlAuthenticateResponse;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
+import org.elasticsearch.xpack.core.security.user.User;
+import org.elasticsearch.xpack.security.authc.TokenService;
+
+import java.time.Clock;
+
+/**
+ * Interface for handling successful SAML authentications.
+ */
+public interface SamlAuthenticateResponseHandler {
+
+    /**
+     * Called to handle and return a ({@link SamlAuthenticateResponse}) after successful SAML authentication.
+     */
+    void handleTokenResponse(
+        Authentication authentication,
+        Authentication originatingAuthentication,
+        AuthenticationResult<User> authenticationResult,
+        ActionListener<SamlAuthenticateResponse> listener
+    );
+
+    /**
+     * The factory is used to make handler pluggable.
+     */
+    interface Factory {
+        SamlAuthenticateResponseHandler create(Settings settings, TokenService tokenService, Clock clock);
+    }
+
+    /**
+     * The default factory that creates {@link DefaultSamlAuthenticateResponseHandler}.
+     */
+    class DefaultFactory implements Factory {
+
+        @Override
+        public SamlAuthenticateResponseHandler create(Settings settings, TokenService tokenService, Clock clock) {
+            return new DefaultSamlAuthenticateResponseHandler(tokenService);
+        }
+    }
+}

+ 31 - 8
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticator.java

@@ -35,6 +35,7 @@ import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
 import static org.elasticsearch.core.Strings.format;
@@ -50,9 +51,17 @@ class SamlAuthenticator extends SamlResponseHandler {
 
     private static final String RESPONSE_TAG_NAME = "Response";
     private static final Set<String> SPECIAL_ATTRIBUTE_NAMES = Set.of(NAMEID_SYNTHENTIC_ATTRIBUTE, PERSISTENT_NAMEID_SYNTHENTIC_ATTRIBUTE);
-
-    SamlAuthenticator(Clock clock, IdpConfiguration idp, SpConfiguration sp, TimeValue maxSkew) {
+    private final Predicate<Attribute> privateAttributePredicate;
+
+    SamlAuthenticator(
+        Clock clock,
+        IdpConfiguration idp,
+        SpConfiguration sp,
+        TimeValue maxSkew,
+        Predicate<Attribute> privateAttributePredicate
+    ) {
         super(clock, idp, sp, maxSkew);
+        this.privateAttributePredicate = privateAttributePredicate;
     }
 
     /**
@@ -108,16 +117,19 @@ class SamlAuthenticator extends SamlResponseHandler {
         final Assertion assertion = details.v1();
         final SamlNameId nameId = SamlNameId.forSubject(assertion.getSubject());
         final String session = getSessionIndex(assertion);
-        final List<SamlAttributes.SamlAttribute> attributes = details.v2().stream().map(SamlAttributes.SamlAttribute::new).toList();
+        final SamlAttributes samlAttributes = buildSamlAttributes(nameId, session, details.v2());
         if (logger.isTraceEnabled()) {
             StringBuilder sb = new StringBuilder();
             sb.append("The SAML Assertion contained the following attributes: \n");
-            for (SamlAttributes.SamlAttribute attr : attributes) {
+            for (SamlAttributes.SamlAttribute attr : samlAttributes.attributes()) {
+                sb.append(attr).append("\n");
+            }
+            for (SamlAttributes.SamlPrivateAttribute attr : samlAttributes.privateAttributes()) {
                 sb.append(attr).append("\n");
             }
             logger.trace(sb.toString());
         }
-        if (attributes.isEmpty() && nameId == null) {
+        if (samlAttributes.isEmpty() && nameId == null) {
             logger.debug(
                 "The Attribute Statements of SAML Response with ID [{}] contained no attributes and the SAML Assertion Subject "
                     + "did not contain a SAML NameID. Please verify that the Identity Provider configuration with regards to attribute "
@@ -126,8 +138,20 @@ class SamlAuthenticator extends SamlResponseHandler {
             );
             throw samlException("Could not process any SAML attributes in {}", response.getElementQName());
         }
+        return samlAttributes;
+    }
 
-        return new SamlAttributes(nameId, session, attributes);
+    private SamlAttributes buildSamlAttributes(SamlNameId nameId, String session, List<Attribute> attributes) {
+        List<SamlAttributes.SamlAttribute> samlAttributes = new ArrayList<>();
+        List<SamlAttributes.SamlPrivateAttribute> samlPrivateAttributes = new ArrayList<>();
+        for (Attribute attribute : attributes) {
+            if (privateAttributePredicate.test(attribute)) {
+                samlPrivateAttributes.add(new SamlAttributes.SamlPrivateAttribute(attribute));
+            } else {
+                samlAttributes.add(new SamlAttributes.SamlAttribute(attribute));
+            }
+        }
+        return new SamlAttributes(nameId, session, samlAttributes, samlPrivateAttributes);
     }
 
     private static String getSessionIndex(Assertion assertion) {
@@ -194,7 +218,6 @@ class SamlAuthenticator extends SamlResponseHandler {
 
     private List<Attribute> processAssertion(Assertion assertion, boolean requireSignature, Collection<String> allowedSamlRequestIds) {
         if (logger.isTraceEnabled()) {
-            logger.trace("(Possibly decrypted) Assertion: {}", SamlUtils.getXmlContent(assertion, true));
             logger.trace(SamlUtils.describeSamlObject(assertion));
         }
         // Do not further process unsigned Assertions
@@ -220,7 +243,7 @@ class SamlAuthenticator extends SamlResponseHandler {
             for (EncryptedAttribute enc : statement.getEncryptedAttributes()) {
                 final Attribute attribute = decrypt(enc);
                 if (attribute != null) {
-                    logger.trace("Successfully decrypted attribute: {}" + SamlUtils.getXmlContent(attribute, true));
+                    logger.trace("Successfully decrypted attribute: {}", attribute.getName());
                     attributes.add(attribute);
                 }
             }

+ 35 - 2
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlRealm.java

@@ -21,6 +21,7 @@ import org.elasticsearch.ExceptionsHelper;
 import org.elasticsearch.SpecialPermission;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.settings.Setting;
 import org.elasticsearch.common.settings.SettingsException;
 import org.elasticsearch.common.ssl.SslConfiguration;
@@ -52,6 +53,7 @@ import org.elasticsearch.xpack.core.ssl.SSLService;
 import org.elasticsearch.xpack.security.PrivilegedFileWatcher;
 import org.elasticsearch.xpack.security.authc.Realms;
 import org.elasticsearch.xpack.security.authc.TokenService;
+import org.elasticsearch.xpack.security.authc.saml.SamlAttributes.SamlPrivateAttribute;
 import org.elasticsearch.xpack.security.authc.support.DelegatedAuthorizationSupport;
 import org.elasticsearch.xpack.security.authc.support.mapper.ExcludingRoleMapper;
 import org.opensaml.core.criterion.EntityIdCriterion;
@@ -62,6 +64,7 @@ import org.opensaml.saml.metadata.resolver.impl.AbstractReloadingMetadataResolve
 import org.opensaml.saml.metadata.resolver.impl.FilesystemMetadataResolver;
 import org.opensaml.saml.metadata.resolver.impl.HTTPMetadataResolver;
 import org.opensaml.saml.metadata.resolver.impl.PredicateRoleDescriptorResolver;
+import org.opensaml.saml.saml2.core.Attribute;
 import org.opensaml.saml.saml2.core.AuthnRequest;
 import org.opensaml.saml.saml2.core.LogoutRequest;
 import org.opensaml.saml.saml2.core.LogoutResponse;
@@ -101,6 +104,7 @@ import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
+import java.util.function.Predicate;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -132,6 +136,7 @@ import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings
 import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.NAME_ATTRIBUTE;
 import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.POPULATE_USER_METADATA;
 import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.PRINCIPAL_ATTRIBUTE;
+import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.PRIVATE_ATTRIBUTES;
 import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.SIGNING_KEY_ALIAS;
 import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.SIGNING_MESSAGE_TYPES;
 import static org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings.SIGNING_SETTING_KEY;
@@ -154,6 +159,8 @@ public final class SamlRealm extends Realm implements Releasable {
     public static final String TOKEN_METADATA_NAMEID_SP_PROVIDED_ID = "saml_nameid_sp_id";
     public static final String TOKEN_METADATA_SESSION = "saml_session";
     public static final String TOKEN_METADATA_REALM = "saml_realm";
+
+    public static final String PRIVATE_ATTRIBUTES_METADATA = "saml_private_attributes";
     // Although we only use this for IDP metadata loading, the SSLServer only loads configurations where "ssl." is a top-level element
     // in the realm group configuration, so it has to have this name.
 
@@ -217,7 +224,14 @@ public final class SamlRealm extends Realm implements Releasable {
         final Clock clock = Clock.systemUTC();
         final IdpConfiguration idpConfiguration = getIdpConfiguration(config, metadataResolver, idpDescriptor);
         final TimeValue maxSkew = config.getSetting(CLOCK_SKEW);
-        final SamlAuthenticator authenticator = new SamlAuthenticator(clock, idpConfiguration, serviceProvider, maxSkew);
+        final Predicate<Attribute> secureAttributePredicate = secureAttributePredicate(config);
+        final SamlAuthenticator authenticator = new SamlAuthenticator(
+            clock,
+            idpConfiguration,
+            serviceProvider,
+            maxSkew,
+            secureAttributePredicate
+        );
         final SamlLogoutRequestHandler logoutHandler = new SamlLogoutRequestHandler(clock, idpConfiguration, serviceProvider, maxSkew);
         final SamlLogoutResponseHandler logoutResponseHandler = new SamlLogoutResponseHandler(
             clock,
@@ -245,6 +259,20 @@ public final class SamlRealm extends Realm implements Releasable {
         return realm;
     }
 
+    static Predicate<Attribute> secureAttributePredicate(RealmConfig config) {
+        if (false == config.hasSetting(PRIVATE_ATTRIBUTES)) {
+            return attribute -> false;
+        }
+        final List<String> secureAttributeNames = config.getSetting(PRIVATE_ATTRIBUTES);
+        if (secureAttributeNames == null || secureAttributeNames.isEmpty()) {
+            return attribute -> false;
+        }
+
+        final Set<String> secureAttributeNamesSet = Set.copyOf(secureAttributeNames);
+        return attribute -> attribute != null
+            && (secureAttributeNamesSet.contains(attribute.getName()) || secureAttributeNamesSet.contains(attribute.getFriendlyName()));
+    }
+
     public SpConfiguration getServiceProvider() {
         return serviceProvider;
     }
@@ -547,7 +575,7 @@ public final class SamlRealm extends Realm implements Releasable {
                 final SamlToken token = (SamlToken) authenticationToken;
                 final SamlAttributes attributes = authenticator.authenticate(token);
                 logger.debug("Parsed token [{}] to attributes [{}]", token, attributes);
-                buildUser(attributes, listener);
+                buildUser(attributes, ActionListener.releaseAfter(listener, attributes));
             } catch (ElasticsearchSecurityException e) {
                 if (SamlUtils.isSamlException(e)) {
                     listener.onResponse(AuthenticationResult.unsuccessful("Provided SAML response is not valid for realm " + this, e));
@@ -574,11 +602,16 @@ public final class SamlRealm extends Realm implements Releasable {
         }
 
         final Map<String, Object> tokenMetadata = createTokenMetadata(attributes.name(), attributes.session());
+        final Map<String, List<SecureString>> privateAttributesMetadata = attributes.privateAttributes()
+            .stream()
+            .collect(Collectors.toMap(SamlPrivateAttribute::name, SamlPrivateAttribute::values));
+
         ActionListener<AuthenticationResult<User>> wrappedListener = baseListener.delegateFailureAndWrap((l, auth) -> {
             if (auth.isAuthenticated()) {
                 // Add the SAML token details as metadata on the authentication
                 Map<String, Object> metadata = new HashMap<>(auth.getMetadata());
                 metadata.put(CONTEXT_TOKEN_DATA, tokenMetadata);
+                metadata.put(PRIVATE_ATTRIBUTES_METADATA, privateAttributesMetadata);
                 auth = AuthenticationResult.success(auth.getValue(), metadata);
             }
             l.onResponse(auth);

+ 11 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/saml/SamlAttributesTests.java

@@ -7,6 +7,7 @@
 
 package org.elasticsearch.xpack.security.authc.saml;
 
+import org.elasticsearch.common.settings.SecureString;
 import org.hamcrest.Matchers;
 import org.opensaml.saml.saml2.core.NameID;
 
@@ -29,6 +30,13 @@ public class SamlAttributesTests extends SamlTestCase {
                     "groups",
                     List.of("employees", "engineering", "managers")
                 )
+            ),
+            List.of(
+                new SamlAttributes.SamlPrivateAttribute(
+                    "urn:oid:0.9.2342.19200300.100.1.3",
+                    "mail",
+                    List.of(new SecureString("peter@ng.com".toCharArray()))
+                )
             )
         );
         assertThat(
@@ -45,6 +53,9 @@ public class SamlAttributesTests extends SamlTestCase {
                     + ", "
                     + "groups(urn:oid:1.3.6.1.4.1.5923.1.5.1.1)=[employees, engineering, managers](len=3)"
                     + "]}"
+                    + "{["
+                    + "mail(urn:oid:0.9.2342.19200300.100.1.3)=[1 value(s)]"
+                    + "]}"
             )
         );
     }

+ 30 - 1
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticatorTests.java

@@ -10,6 +10,7 @@ import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.util.NamedFormatter;
 import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.core.Tuple;
@@ -142,7 +143,7 @@ public class SamlAuthenticatorTests extends SamlResponseHandlerTests {
             spEncryptionCredentials,
             reqAuthnCtxClassRef
         );
-        return new SamlAuthenticator(clock, idp, sp, maxSkew);
+        return new SamlAuthenticator(clock, idp, sp, maxSkew, attribute -> "batman".equals(attribute.getName()));
     }
 
     public void testParseEmptyContentIsRejected() throws Exception {
@@ -328,6 +329,12 @@ public class SamlAuthenticatorTests extends SamlResponseHandlerTests {
         assertThat(attributes.name().spNameQualifier, equalTo(SP_ENTITY_ID));
 
         assertThat(attributes.session(), equalTo(session));
+
+        assertThat(attributes.privateAttributes(), iterableWithSize(1));
+        final List<SecureString> batmanIdentity = attributes.getPrivateAttributeValues("batman");
+        assertThat(batmanIdentity, iterableWithSize(1));
+        assertThat(batmanIdentity.getFirst(), equalTo(new SecureString("Bruce Wayne".toCharArray())));
+
     }
 
     public void testSuccessfullyParseContentFromRawXmlWithSignedAssertion() throws Exception {
@@ -346,6 +353,11 @@ public class SamlAuthenticatorTests extends SamlResponseHandlerTests {
         assertThat(attributes.name(), notNullValue());
         assertThat(attributes.name().format, equalTo(TRANSIENT));
         assertThat(attributes.name().value, equalTo(nameId));
+        assertThat(attributes.privateAttributes(), iterableWithSize(1));
+        final List<SecureString> batmanIdentity = attributes.getPrivateAttributeValues("batman");
+        assertThat(batmanIdentity, iterableWithSize(1));
+        assertThat(batmanIdentity.getFirst(), equalTo(new SecureString("Bruce Wayne".toCharArray())));
+
     }
 
     public void testSuccessfullyParseContentFromRawXmlWithSignedUnicodeAssertion() throws Exception {
@@ -367,6 +379,11 @@ public class SamlAuthenticatorTests extends SamlResponseHandlerTests {
         assertThat(attributes.name(), notNullValue());
         assertThat(attributes.name().format, equalTo(nameIdFormat));
         assertThat(attributes.name().value, equalTo(nameId));
+
+        assertThat(attributes.privateAttributes(), iterableWithSize(1));
+        final List<SecureString> batmanIdentity = attributes.getPrivateAttributeValues("batman");
+        assertThat(batmanIdentity, iterableWithSize(1));
+        assertThat(batmanIdentity.getFirst(), equalTo(new SecureString("Bruce Wayne".toCharArray())));
     }
 
     public void testSuccessfullyParseContentFromEncryptedAssertion() throws Exception {
@@ -1628,8 +1645,15 @@ public class SamlAuthenticatorTests extends SamlResponseHandlerTests {
             "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
             List.of("defenders", "netflix")
         );
+        final Attribute attribute3 = getAttribute(
+            "batman",
+            null,
+            "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
+            List.of("Bruce Wayne")
+        );
         attributeStatement.getAttributes().add(attribute1);
         attributeStatement.getAttributes().add(attribute2);
+        attributeStatement.getAttributes().add(attribute3);
         assertion.getAttributeStatements().add(attributeStatement);
         response.getAssertions().add(assertion);
         return response;
@@ -1683,6 +1707,11 @@ public class SamlAuthenticatorTests extends SamlResponseHandlerTests {
             + "      </assert:Attribute>"
             + "    </assert:AttributeStatement>"
             + "    <assert:AttributeStatement>"
+            + "      <assert:Attribute Name='batman' NameFormat='urn:oasis:names:tc:SAML:2.0:attrname-format:uri'>"
+            + "           <assert:AttributeValue xsi:type='xs:string'>Bruce Wayne</assert:AttributeValue>"
+            + "      </assert:Attribute>"
+            + "    </assert:AttributeStatement>"
+            + "    <assert:AttributeStatement>"
             + "      <assert:Attribute "
             + "         NameFormat='urn:oasis:names:tc:SAML:2.0:attrname-format:uri' Name='urn:oid:1.3.6.1.4.1.5923.1.5.1.1'>"
             + "      <assert:AttributeValue xsi:type='xs:string'>defenders</assert:AttributeValue>"

+ 115 - 15
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/saml/SamlRealmTests.java

@@ -10,6 +10,7 @@ import org.elasticsearch.ElasticsearchSecurityException;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.action.support.PlainActionFuture;
 import org.elasticsearch.common.settings.MockSecureSettings;
+import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.settings.SettingsException;
 import org.elasticsearch.common.ssl.PemUtils;
@@ -72,6 +73,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -83,6 +85,7 @@ import java.util.stream.Stream;
 import static org.elasticsearch.core.Strings.format;
 import static org.elasticsearch.test.ActionListenerUtils.anyActionListener;
 import static org.elasticsearch.test.TestMatchers.throwableWithMessage;
+import static org.elasticsearch.xpack.security.authc.saml.SamlRealm.PRIVATE_ATTRIBUTES_METADATA;
 import static org.hamcrest.Matchers.arrayContainingInAnyOrder;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.containsInAnyOrder;
@@ -392,7 +395,8 @@ public class SamlRealmTests extends SamlTestCase {
             randomBoolean() ? REALM_NAME : null,
             testWithDelimiter ? List.of("STRIKE Team: Delta$shield") : Arrays.asList("avengers", "shield"),
             testWithDelimiter ? "$" : null,
-            randomBoolean() ? List.of("superuser", "kibana_admin") : randomFrom(List.of(), null)
+            randomBoolean() ? List.of("superuser", "kibana_admin") : randomFrom(List.of(), null),
+            null
         );
         assertThat(result, notNullValue());
         assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.SUCCESS));
@@ -419,7 +423,8 @@ public class SamlRealmTests extends SamlTestCase {
             authenticatingRealm,
             testWithDelimiter ? List.of("STRIKE Team: Delta$shield") : Arrays.asList("avengers", "shield"),
             testWithDelimiter ? "$" : null,
-            rolesToExclude
+            rolesToExclude,
+            Map.of("top_secret", List.of("Batman's secret identity is Bruce Wayne!"))
         );
 
         assertThat(result, notNullValue());
@@ -440,8 +445,24 @@ public class SamlRealmTests extends SamlTestCase {
             assertThat(result.getValue().metadata().get("saml_nameid"), equalTo(nameIdValue));
             assertThat(result.getValue().metadata().get("saml_uid"), instanceOf(Iterable.class));
             assertThat((Iterable<?>) result.getValue().metadata().get("saml_uid"), contains(uidValue));
+            assertThat(result.getValue().metadata().get("saml_top_secret"), nullValue());
         }
 
+        assertThat(result.getMetadata(), notNullValue());
+        assertThat(result.getMetadata().containsKey(PRIVATE_ATTRIBUTES_METADATA), is(true));
+        @SuppressWarnings("unchecked")
+        Map<String, List<SecureString>> privateAttributesMetadata = (Map<String, List<SecureString>>) result.getMetadata()
+            .get(PRIVATE_ATTRIBUTES_METADATA);
+        assertThat(privateAttributesMetadata, notNullValue());
+        assertThat(privateAttributesMetadata.keySet(), containsInAnyOrder("top_secret"));
+        List<SecureString> secretAttribute = privateAttributesMetadata.get("top_secret");
+        assertThat(secretAttribute, notNullValue());
+        assertThat(secretAttribute.size(), equalTo(1));
+        assertEquals(
+            "SecureString has already been closed",
+            expectThrows(IllegalStateException.class, () -> secretAttribute.getFirst().getChars()).getMessage()
+        );
+
         assertThat(userData.get().getUsername(), equalTo(useNameId ? "clint.barton" : "cbarton"));
         if (testWithDelimiter) {
             assertThat(userData.get().getGroups(), containsInAnyOrder("STRIKE Team: Delta", "shield"));
@@ -533,6 +554,7 @@ public class SamlRealmTests extends SamlTestCase {
             authenticatingRealm,
             groups,
             groupsDelimiter,
+            null,
             null
         );
     }
@@ -546,7 +568,8 @@ public class SamlRealmTests extends SamlTestCase {
         String authenticatingRealm,
         List<String> groups,
         String groupsDelimiter,
-        List<String> rolesToExclude
+        List<String> rolesToExclude,
+        Map<String, List<String>> secureAttributes
     ) throws Exception {
         final EntityDescriptor idp = mockIdp();
         final SpConfiguration sp = new SingleSamlSpConfiguration("<sp>", "https://saml/", null, null, null, Collections.emptyList());
@@ -610,6 +633,12 @@ public class SamlRealmTests extends SamlTestCase {
                 String.join(",", rolesToExclude)
             );
         }
+        if (secureAttributes != null) {
+            settingsBuilder.put(
+                SingleSpSamlRealmSettings.getFullSettingKey(REALM_NAME, SamlRealmSettings.PRIVATE_ATTRIBUTES),
+                String.join(",", secureAttributes.keySet())
+            );
+        }
         if (useAuthorizingRealm) {
             settingsBuilder.putList(
                 RealmSettings.getFullSettingKey(
@@ -640,15 +669,39 @@ public class SamlRealmTests extends SamlTestCase {
         final SamlAttributes attributes = new SamlAttributes(
             new SamlNameId(NameIDType.PERSISTENT, nameIdValue, idp.getEntityID(), sp.getEntityId(), null),
             randomAlphaOfLength(16),
-            Arrays.asList(
+            List.of(
                 new SamlAttributes.SamlAttribute("urn:oid:0.9.2342.19200300.100.1.1", "uid", Collections.singletonList(uidValue)),
                 new SamlAttributes.SamlAttribute("urn:oid:1.3.6.1.4.1.5923.1.5.1.1", "groups", groups),
                 new SamlAttributes.SamlAttribute("urn:oid:0.9.2342.19200300.100.1.3", "mail", Arrays.asList("cbarton@shield.gov"))
-            )
+            ),
+            secureAttributes == null
+                ? List.of()
+                : secureAttributes.entrySet()
+                    .stream()
+                    .map(
+                        a -> new SamlAttributes.SamlPrivateAttribute(
+                            a.getKey(),
+                            null,
+                            a.getValue().stream().map(SecureString::new).toList()
+                        )
+                    )
+                    .toList()
         );
         when(authenticator.authenticate(token)).thenReturn(attributes);
 
-        final PlainActionFuture<AuthenticationResult<User>> future = new PlainActionFuture<>();
+        final PlainActionFuture<AuthenticationResult<User>> future = new PlainActionFuture<>() {
+            @Override
+            public void onResponse(AuthenticationResult<User> result) {
+                if (secureAttributes != null && result.isAuthenticated()) {
+                    assertThat(result.getMetadata(), notNullValue());
+                    assertThat(result.getMetadata().containsKey(PRIVATE_ATTRIBUTES_METADATA), is(true));
+                    @SuppressWarnings("unchecked")
+                    var metadata = (Map<String, List<SecureString>>) result.getMetadata().get(PRIVATE_ATTRIBUTES_METADATA);
+                    secureAttributes.forEach((name, value) -> assertThat(metadata.get(name), equalTo(value)));
+                }
+                super.onResponse(result);
+            }
+        };
         realm.authenticate(token, future);
         return future.get();
     }
@@ -725,13 +778,14 @@ public class SamlRealmTests extends SamlTestCase {
         final SamlAttributes attributes = new SamlAttributes(
             new SamlNameId(NameIDType.TRANSIENT, randomAlphaOfLength(24), null, null, null),
             randomAlphaOfLength(16),
-            Collections.singletonList(
+            List.of(
                 new SamlAttributes.SamlAttribute(
                     "departments",
                     "departments",
                     Collections.singletonList(String.join(delimiter, returnedGroups))
                 )
-            )
+            ),
+            List.of()
         );
         return parser.getAttribute(attributes);
     }
@@ -796,13 +850,14 @@ public class SamlRealmTests extends SamlTestCase {
         final SamlAttributes attributes = new SamlAttributes(
             new SamlNameId(NameIDType.TRANSIENT, randomAlphaOfLength(24), null, null, null),
             randomAlphaOfLength(16),
-            Collections.singletonList(
+            List.of(
                 new SamlAttributes.SamlAttribute(
                     "departments",
                     "departments",
                     List.of("engineering", String.join(delimiter, "elasticsearch-admins", "employees"))
                 )
-            )
+            ),
+            List.of()
         );
 
         ElasticsearchSecurityException securityException = expectThrows(
@@ -828,13 +883,14 @@ public class SamlRealmTests extends SamlTestCase {
         final SamlAttributes attributes = new SamlAttributes(
             new SamlNameId(NameIDType.TRANSIENT, randomAlphaOfLength(24), null, null, null),
             randomAlphaOfLength(16),
-            Collections.singletonList(
+            List.of(
                 new SamlAttributes.SamlAttribute(
                     "urn:oid:0.9.2342.19200300.100.1.3",
                     "mail",
                     Arrays.asList("john.smith@personal.example.net", "john.smith@corporate.example.com", "jsmith@corporate.example.com")
                 )
-            )
+            ),
+            List.of()
         );
 
         final List<String> strings = parser.getAttribute(attributes);
@@ -909,6 +965,51 @@ public class SamlRealmTests extends SamlTestCase {
         );
     }
 
+    public void testPrivateAttributesSettingValidation() {
+        String attributeName = randomAlphaOfLength(10);
+        String attributeSetting = RealmSettings.getFullSettingKey(
+            REALM_NAME,
+            randomFrom(
+                SamlRealmSettings.PRINCIPAL_ATTRIBUTE.apply(SingleSpSamlRealmSettings.TYPE).getAttribute(),
+                SamlRealmSettings.GROUPS_ATTRIBUTE.apply(SingleSpSamlRealmSettings.TYPE).getAttributeSetting().getAttribute(),
+                SamlRealmSettings.DN_ATTRIBUTE.apply(SingleSpSamlRealmSettings.TYPE).getAttribute(),
+                SamlRealmSettings.NAME_ATTRIBUTE.apply(SingleSpSamlRealmSettings.TYPE).getAttribute(),
+                SamlRealmSettings.MAIL_ATTRIBUTE.apply(SingleSpSamlRealmSettings.TYPE).getAttribute()
+            )
+        );
+        {
+            Settings settings = buildSettings("https://example.com").put(
+                SingleSpSamlRealmSettings.getFullSettingKey(REALM_NAME, SamlRealmSettings.PRIVATE_ATTRIBUTES),
+                attributeName
+            ).put(attributeSetting, attributeName).build();
+            final RealmConfig config = realmConfigFromGlobalSettings(settings);
+
+            var e = expectThrows(IllegalArgumentException.class, () -> config.getSetting(SamlRealmSettings.PRIVATE_ATTRIBUTES));
+            assertThat(
+                e.getCause().getMessage(),
+                containsString(
+                    "SAML Attribute ["
+                        + attributeName
+                        + "] cannot be both configured for ["
+                        + REALM_SETTINGS_PREFIX
+                        + ".private_attributes] and ["
+                        + attributeSetting
+                        + "] settings."
+                )
+            );
+        }
+        {
+            String otherAttributeName = randomAlphaOfLength(9);
+            Settings settings = buildSettings("https://example.com").put(
+                SingleSpSamlRealmSettings.getFullSettingKey(REALM_NAME, SamlRealmSettings.PRIVATE_ATTRIBUTES),
+                attributeName
+            ).put(attributeSetting, otherAttributeName).build();
+            final RealmConfig config = realmConfigFromGlobalSettings(settings);
+            assertThat(config.getSetting(SamlRealmSettings.PRIVATE_ATTRIBUTES), containsInAnyOrder(attributeName));
+            assertThat(config.settings().get(attributeSetting), equalTo(otherAttributeName));
+        }
+    }
+
     public void testMissingPrincipalSettingThrowsSettingsException() throws Exception {
         final Settings realmSettings = Settings.EMPTY;
         final RealmConfig config = buildConfig(realmSettings);
@@ -952,9 +1053,8 @@ public class SamlRealmTests extends SamlTestCase {
             final SamlAttributes attributes = new SamlAttributes(
                 new SamlNameId(NameIDType.TRANSIENT, randomAlphaOfLength(12), null, null, null),
                 randomAlphaOfLength(16),
-                Collections.singletonList(
-                    new SamlAttributes.SamlAttribute("urn:oid:0.9.2342.19200300.100.1.3", "mail", Collections.singletonList(mail))
-                )
+                List.of(new SamlAttributes.SamlAttribute("urn:oid:0.9.2342.19200300.100.1.3", "mail", Collections.singletonList(mail))),
+                List.of()
             );
             when(authenticator.authenticate(token)).thenReturn(attributes);
 

+ 10 - 0
x-pack/qa/saml-idp-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticationIT.java

@@ -86,7 +86,9 @@ import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.greaterThan;
 import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
 import static org.hamcrest.Matchers.startsWith;
 
 /**
@@ -122,6 +124,7 @@ public class SamlAuthenticationIT extends ESRestTestCase {
         .setting("xpack.security.authc.realms.saml.shibboleth.attributes.name", "urn:oid:2.5.4.3")
         .setting("xpack.security.authc.realms.saml.shibboleth.signing.key", "sp-signing.key")
         .setting("xpack.security.authc.realms.saml.shibboleth.signing.certificate", "sp-signing.crt")
+        .setting("xpack.security.authc.realms.saml.shibboleth.private_attributes", "mail")
         // SAML realm 2 (uses authorization_realms)
         .setting("xpack.security.authc.realms.saml.shibboleth_native.order", "2")
         .setting("xpack.security.authc.realms.saml.shibboleth_native.idp.entity_id", "https://test.shibboleth.elastic.local/")
@@ -308,6 +311,13 @@ public class SamlAuthenticationIT extends ESRestTestCase {
             assertThat(authentication, instanceOf(Map.class));
             assertEquals("thor", ((Map) authentication).get("username"));
 
+            // "mail" attribute should be treated as private
+            // and not returned as part of user's metadata
+            final Object metadata = ((Map) authentication).get("metadata");
+            assertThat(metadata, notNullValue());
+            assertThat(metadata, instanceOf(Map.class));
+            assertThat(((Map) metadata).get("saml_mail"), is(nullValue()));
+
             return new Tuple<>((String) accessToken, (String) refreshToken);
         }
     }