Skip to content

Commit 077b47d

Browse files
authored
Add support for splitting saml groups by delimiter (#102769)
* Add support for splitting saml groups by delimiter
1 parent d7c6c22 commit 077b47d

File tree

4 files changed

+284
-14
lines changed

4 files changed

+284
-14
lines changed

docs/reference/settings/security-settings.asciidoc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,20 @@ As per `attribute_patterns.principal`, but for the _mail_ property.
12531253
As per `attribute_patterns.principal`, but for the _dn_ property.
12541254
// end::saml-attributes-patterns-dn-tag[]
12551255

1256+
// tag::saml-attributes-delimiters-groups-tag[]
1257+
`attribute_delimiters.groups` {ess-icon}::
1258+
(<<static-cluster-setting,Static>>)
1259+
A plain string that is used as a delimiter to split a single-valued SAML
1260+
attribute specified by attributes.groups before it is applied to the user's
1261+
groups property. For example, splitting the SAML attribute value
1262+
engineering,elasticsearch-admins,employees on a delimiter value of , will
1263+
result in engineering, elasticsearch-admins, and employees as the list of
1264+
groups for the user. The delimiter will always be split on, regardless of
1265+
escaping in the input string. This setting does not support multi-valued SAML
1266+
attributes. It cannot be used together with the attribute_patterns setting.
1267+
You can only configure this setting for the groups attribute.
1268+
// end::saml-attributes-delimiters-groups-tag[]
1269+
12561270
// tag::saml-nameid-format-tag[]
12571271
`nameid_format` {ess-icon}::
12581272
(<<static-cluster-setting,Static>>)

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.elasticsearch.xpack.core.ssl.SSLConfigurationSettings;
1616
import org.elasticsearch.xpack.core.ssl.X509KeyPairSettings;
1717

18+
import java.util.ArrayList;
1819
import java.util.Arrays;
1920
import java.util.Collection;
2021
import java.util.List;
@@ -102,7 +103,7 @@ public class SamlRealmSettings {
102103
);
103104

104105
public static final AttributeSetting PRINCIPAL_ATTRIBUTE = new AttributeSetting("principal");
105-
public static final AttributeSetting GROUPS_ATTRIBUTE = new AttributeSetting("groups");
106+
public static final AttributeSettingWithDelimiter GROUPS_ATTRIBUTE = new AttributeSettingWithDelimiter("groups");
106107
public static final AttributeSetting DN_ATTRIBUTE = new AttributeSetting("dn");
107108
public static final AttributeSetting NAME_ATTRIBUTE = new AttributeSetting("name");
108109
public static final AttributeSetting MAIL_ATTRIBUTE = new AttributeSetting("mail");
@@ -221,4 +222,40 @@ public Setting.AffixSetting<String> getPattern() {
221222
return pattern;
222223
}
223224
}
225+
226+
/**
227+
* The SAML realm offers a setting where a multivalued attribute can be configured to have a delimiter for its values, for the case
228+
* when all values are provided in a single string item, separated by a delimiter.
229+
* As in {@link AttributeSetting} there are two settings:
230+
* <ul>
231+
* <li>The name of the SAML attribute to use</li>
232+
* <li>A delimiter to apply to that attribute value in order to extract the substrings that should be used.</li>
233+
* </ul>
234+
* For example, the Elasticsearch Group could be configured to come from the SAML "department" attribute, where all groups are provided
235+
* as a csv value in a single list item.
236+
*/
237+
public static final class AttributeSettingWithDelimiter {
238+
public static final String ATTRIBUTE_DELIMITERS_PREFIX = "attribute_delimiters.";
239+
private final Setting.AffixSetting<String> delimiter;
240+
private final AttributeSetting attributeSetting;
241+
242+
public AttributeSetting getAttributeSetting() {
243+
return attributeSetting;
244+
}
245+
246+
public AttributeSettingWithDelimiter(String name) {
247+
this.attributeSetting = new AttributeSetting(name);
248+
this.delimiter = RealmSettings.simpleString(TYPE, ATTRIBUTE_DELIMITERS_PREFIX + name, Setting.Property.NodeScope);
249+
}
250+
251+
public Setting.AffixSetting<String> getDelimiter() {
252+
return this.delimiter;
253+
}
254+
255+
public Collection<Setting.AffixSetting<?>> settings() {
256+
List<Setting.AffixSetting<?>> settings = new ArrayList<>(attributeSetting.settings());
257+
settings.add(getDelimiter());
258+
return settings;
259+
}
260+
}
224261
}

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ public SpConfiguration getServiceProvider() {
278278
this.populateUserMetadata = config.getSetting(POPULATE_USER_METADATA);
279279
this.principalAttribute = AttributeParser.forSetting(logger, PRINCIPAL_ATTRIBUTE, config, true);
280280

281-
this.groupsAttribute = AttributeParser.forSetting(logger, GROUPS_ATTRIBUTE, config, false);
281+
this.groupsAttribute = AttributeParser.forSetting(logger, GROUPS_ATTRIBUTE, config);
282282
this.dnAttribute = AttributeParser.forSetting(logger, DN_ATTRIBUTE, config, false);
283283
this.nameAttribute = AttributeParser.forSetting(logger, NAME_ATTRIBUTE, config, false);
284284
this.mailAttribute = AttributeParser.forSetting(logger, MAIL_ATTRIBUTE, config, false);
@@ -1004,6 +1004,66 @@ public String toString() {
10041004
return name;
10051005
}
10061006

1007+
static AttributeParser forSetting(Logger logger, SamlRealmSettings.AttributeSettingWithDelimiter setting, RealmConfig realmConfig) {
1008+
SamlRealmSettings.AttributeSetting attributeSetting = setting.getAttributeSetting();
1009+
if (realmConfig.hasSetting(setting.getDelimiter())) {
1010+
if (realmConfig.hasSetting(attributeSetting.getAttribute()) == false) {
1011+
throw new SettingsException(
1012+
"Setting ["
1013+
+ RealmSettings.getFullSettingKey(realmConfig, setting.getDelimiter())
1014+
+ "] cannot be set unless ["
1015+
+ RealmSettings.getFullSettingKey(realmConfig, attributeSetting.getAttribute())
1016+
+ "] is also set"
1017+
);
1018+
}
1019+
if (realmConfig.hasSetting(attributeSetting.getPattern())) {
1020+
throw new SettingsException(
1021+
"Setting ["
1022+
+ RealmSettings.getFullSettingKey(realmConfig, attributeSetting.getPattern())
1023+
+ "] can not be set when ["
1024+
+ RealmSettings.getFullSettingKey(realmConfig, setting.getDelimiter())
1025+
+ "] is set"
1026+
);
1027+
}
1028+
1029+
String attributeName = realmConfig.getSetting(attributeSetting.getAttribute());
1030+
String delimiter = realmConfig.getSetting(setting.getDelimiter());
1031+
return new AttributeParser(
1032+
"SAML Attribute ["
1033+
+ attributeName
1034+
+ "] with delimiter ["
1035+
+ delimiter
1036+
+ "] for ["
1037+
+ attributeSetting.name(realmConfig)
1038+
+ "]",
1039+
attributes -> {
1040+
List<String> attributeValues = attributes.getAttributeValues(attributeName);
1041+
if (attributeValues.size() > 1) {
1042+
throw SamlUtils.samlException(
1043+
"Expected single string value for attribute: ["
1044+
+ attributeName
1045+
+ "], but got list with "
1046+
+ attributeValues.size()
1047+
+ " values"
1048+
);
1049+
}
1050+
return attributeValues.stream()
1051+
.map(s -> s.split(Pattern.quote(delimiter)))
1052+
.flatMap(Arrays::stream)
1053+
.filter(attribute -> {
1054+
if (Strings.isNullOrEmpty(attribute)) {
1055+
logger.debug("Attribute [{}] has empty components when using delimiter [{}]", attributeName, delimiter);
1056+
return false;
1057+
}
1058+
return true;
1059+
})
1060+
.collect(Collectors.toList());
1061+
}
1062+
);
1063+
}
1064+
return AttributeParser.forSetting(logger, attributeSetting, realmConfig, false);
1065+
}
1066+
10071067
static AttributeParser forSetting(
10081068
Logger logger,
10091069
SamlRealmSettings.AttributeSetting setting,

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

Lines changed: 171 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -352,14 +352,30 @@ public void testAuthenticateWithRoleMapping() throws Exception {
352352
final boolean principalIsEmailAddress = randomBoolean();
353353
final Boolean populateUserMetadata = randomFrom(Boolean.TRUE, Boolean.FALSE, null);
354354
final String authenticatingRealm = randomBoolean() ? REALM_NAME : null;
355-
AuthenticationResult<User> result = performAuthentication(
356-
roleMapper,
357-
useNameId,
358-
principalIsEmailAddress,
359-
populateUserMetadata,
360-
false,
361-
authenticatingRealm
362-
);
355+
final boolean testWithDelimiter = randomBoolean();
356+
final AuthenticationResult<User> result;
357+
358+
if (testWithDelimiter) {
359+
result = performAuthentication(
360+
roleMapper,
361+
useNameId,
362+
principalIsEmailAddress,
363+
populateUserMetadata,
364+
false,
365+
authenticatingRealm,
366+
List.of("STRIKE Team: Delta$shield"),
367+
"$"
368+
);
369+
} else {
370+
result = performAuthentication(
371+
roleMapper,
372+
useNameId,
373+
principalIsEmailAddress,
374+
populateUserMetadata,
375+
false,
376+
authenticatingRealm
377+
);
378+
}
363379
assertThat(result, notNullValue());
364380
assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.SUCCESS));
365381
assertThat(result.getValue().principal(), equalTo(useNameId ? "clint.barton" : "cbarton"));
@@ -377,7 +393,11 @@ public void testAuthenticateWithRoleMapping() throws Exception {
377393
}
378394

379395
assertThat(userData.get().getUsername(), equalTo(useNameId ? "clint.barton" : "cbarton"));
380-
assertThat(userData.get().getGroups(), containsInAnyOrder("avengers", "shield"));
396+
if (testWithDelimiter) {
397+
assertThat(userData.get().getGroups(), containsInAnyOrder("STRIKE Team: Delta", "shield"));
398+
} else {
399+
assertThat(userData.get().getGroups(), containsInAnyOrder("avengers", "shield"));
400+
}
381401
}
382402

383403
public void testAuthenticateWithAuthorizingRealm() throws Exception {
@@ -431,6 +451,28 @@ private AuthenticationResult<User> performAuthentication(
431451
Boolean populateUserMetadata,
432452
boolean useAuthorizingRealm,
433453
String authenticatingRealm
454+
) throws Exception {
455+
return performAuthentication(
456+
roleMapper,
457+
useNameId,
458+
principalIsEmailAddress,
459+
populateUserMetadata,
460+
useAuthorizingRealm,
461+
authenticatingRealm,
462+
Arrays.asList("avengers", "shield"),
463+
null
464+
);
465+
}
466+
467+
private AuthenticationResult<User> performAuthentication(
468+
UserRoleMapper roleMapper,
469+
boolean useNameId,
470+
boolean principalIsEmailAddress,
471+
Boolean populateUserMetadata,
472+
boolean useAuthorizingRealm,
473+
String authenticatingRealm,
474+
List<String> groups,
475+
String groupsDelimiter
434476
) throws Exception {
435477
final EntityDescriptor idp = mockIdp();
436478
final SpConfiguration sp = new SpConfiguration("<sp>", "https://saml/", null, null, null, Collections.emptyList());
@@ -453,8 +495,12 @@ private AuthenticationResult<User> performAuthentication(
453495

454496
final Settings.Builder settingsBuilder = Settings.builder()
455497
.put(getFullSettingKey(REALM_NAME, SamlRealmSettings.PRINCIPAL_ATTRIBUTE.getAttribute()), useNameId ? "nameid" : "uid")
456-
.put(getFullSettingKey(REALM_NAME, SamlRealmSettings.GROUPS_ATTRIBUTE.getAttribute()), "groups")
498+
.put(getFullSettingKey(REALM_NAME, SamlRealmSettings.GROUPS_ATTRIBUTE.getAttributeSetting().getAttribute()), "groups")
457499
.put(getFullSettingKey(REALM_NAME, SamlRealmSettings.MAIL_ATTRIBUTE.getAttribute()), "mail");
500+
501+
if (groupsDelimiter != null) {
502+
settingsBuilder.put(getFullSettingKey(REALM_NAME, SamlRealmSettings.GROUPS_ATTRIBUTE.getDelimiter()), groupsDelimiter);
503+
}
458504
if (principalIsEmailAddress) {
459505
final boolean anchoredMatch = randomBoolean();
460506
settingsBuilder.put(
@@ -497,7 +543,7 @@ private AuthenticationResult<User> performAuthentication(
497543
randomAlphaOfLength(16),
498544
Arrays.asList(
499545
new SamlAttributes.SamlAttribute("urn:oid:0.9.2342.19200300.100.1.1", "uid", Collections.singletonList(uidValue)),
500-
new SamlAttributes.SamlAttribute("urn:oid:1.3.6.1.4.1.5923.1.5.1.1", "groups", Arrays.asList("avengers", "shield")),
546+
new SamlAttributes.SamlAttribute("urn:oid:1.3.6.1.4.1.5923.1.5.1.1", "groups", groups),
501547
new SamlAttributes.SamlAttribute("urn:oid:0.9.2342.19200300.100.1.3", "mail", Arrays.asList("[email protected]"))
502548
)
503549
);
@@ -534,7 +580,120 @@ public SamlRealm buildRealm(
534580
}
535581
}
536582

537-
public void testAttributeSelectionWithRegex() throws Exception {
583+
public void testAttributeSelectionWithSplit() {
584+
List<String> strings = performAttributeSelectionWithSplit(",", "departments", "engineering", "elasticsearch-admins", "employees");
585+
assertThat("For attributes: " + strings, strings, contains("engineering", "elasticsearch-admins", "employees"));
586+
}
587+
588+
public void testAttributeSelectionWithSplitEmptyInput() {
589+
List<String> strings = performAttributeSelectionWithSplit(",", "departments");
590+
assertThat("For attributes: " + strings, strings, is(empty()));
591+
}
592+
593+
public void testAttributeSelectionWithSplitJustDelimiter() {
594+
List<String> strings = performAttributeSelectionWithSplit(",", ",");
595+
assertThat("For attributes: " + strings, strings, is(empty()));
596+
}
597+
598+
public void testAttributeSelectionWithSplitNoDelimiter() {
599+
List<String> strings = performAttributeSelectionWithSplit(",", "departments", "elasticsearch-team");
600+
assertThat("For attributes: " + strings, strings, contains("elasticsearch-team"));
601+
}
602+
603+
private List<String> performAttributeSelectionWithSplit(String delimiter, String groupAttributeName, String... returnedGroups) {
604+
final Settings settings = Settings.builder()
605+
.put(REALM_SETTINGS_PREFIX + ".attributes.groups", groupAttributeName)
606+
.put(REALM_SETTINGS_PREFIX + ".attribute_delimiters.groups", delimiter)
607+
.build();
608+
609+
final RealmConfig config = buildConfig(settings);
610+
611+
final SamlRealmSettings.AttributeSettingWithDelimiter groupSetting = new SamlRealmSettings.AttributeSettingWithDelimiter("groups");
612+
final SamlRealm.AttributeParser parser = SamlRealm.AttributeParser.forSetting(logger, groupSetting, config);
613+
614+
final SamlAttributes attributes = new SamlAttributes(
615+
new SamlNameId(NameIDType.TRANSIENT, randomAlphaOfLength(24), null, null, null),
616+
randomAlphaOfLength(16),
617+
Collections.singletonList(
618+
new SamlAttributes.SamlAttribute(
619+
"departments",
620+
"departments",
621+
Collections.singletonList(String.join(delimiter, returnedGroups))
622+
)
623+
)
624+
);
625+
return parser.getAttribute(attributes);
626+
}
627+
628+
public void testAttributeSelectionWithDelimiterAndPatternThrowsSettingsException() throws Exception {
629+
final Settings settings = Settings.builder()
630+
.put(REALM_SETTINGS_PREFIX + ".attributes.groups", "departments")
631+
.put(REALM_SETTINGS_PREFIX + ".attribute_delimiters.groups", ",")
632+
.put(REALM_SETTINGS_PREFIX + ".attribute_patterns.groups", "^(.+)@\\w+.example.com$")
633+
.build();
634+
635+
final RealmConfig config = buildConfig(settings);
636+
637+
final SamlRealmSettings.AttributeSettingWithDelimiter groupSetting = new SamlRealmSettings.AttributeSettingWithDelimiter("groups");
638+
639+
final SettingsException settingsException = expectThrows(
640+
SettingsException.class,
641+
() -> SamlRealm.AttributeParser.forSetting(logger, groupSetting, config)
642+
);
643+
644+
assertThat(settingsException.getMessage(), containsString(REALM_SETTINGS_PREFIX + ".attribute_delimiters.groups"));
645+
assertThat(settingsException.getMessage(), containsString(REALM_SETTINGS_PREFIX + ".attribute_patterns.groups"));
646+
}
647+
648+
public void testAttributeSelectionNoGroupsConfiguredThrowsSettingsException() {
649+
String delimiter = ",";
650+
final Settings settings = Settings.builder().put(REALM_SETTINGS_PREFIX + ".attribute_delimiters.groups", delimiter).build();
651+
final RealmConfig config = buildConfig(settings);
652+
final SamlRealmSettings.AttributeSettingWithDelimiter groupSetting = new SamlRealmSettings.AttributeSettingWithDelimiter("groups");
653+
654+
final SettingsException settingsException = expectThrows(
655+
SettingsException.class,
656+
() -> SamlRealm.AttributeParser.forSetting(logger, groupSetting, config)
657+
);
658+
659+
assertThat(settingsException.getMessage(), containsString(REALM_SETTINGS_PREFIX + ".attribute_delimiters.groups"));
660+
assertThat(settingsException.getMessage(), containsString(REALM_SETTINGS_PREFIX + ".attributes.groups"));
661+
}
662+
663+
public void testAttributeSelectionWithSplitAndListThrowsSecurityException() {
664+
String delimiter = ",";
665+
666+
final Settings settings = Settings.builder()
667+
.put(REALM_SETTINGS_PREFIX + ".attributes.groups", "departments")
668+
.put(REALM_SETTINGS_PREFIX + ".attribute_delimiters.groups", delimiter)
669+
.build();
670+
671+
final RealmConfig config = buildConfig(settings);
672+
673+
final SamlRealmSettings.AttributeSettingWithDelimiter groupSetting = new SamlRealmSettings.AttributeSettingWithDelimiter("groups");
674+
final SamlRealm.AttributeParser parser = SamlRealm.AttributeParser.forSetting(logger, groupSetting, config);
675+
676+
final SamlAttributes attributes = new SamlAttributes(
677+
new SamlNameId(NameIDType.TRANSIENT, randomAlphaOfLength(24), null, null, null),
678+
randomAlphaOfLength(16),
679+
Collections.singletonList(
680+
new SamlAttributes.SamlAttribute(
681+
"departments",
682+
"departments",
683+
List.of("engineering", String.join(delimiter, "elasticsearch-admins", "employees"))
684+
)
685+
)
686+
);
687+
688+
ElasticsearchSecurityException securityException = expectThrows(
689+
ElasticsearchSecurityException.class,
690+
() -> parser.getAttribute(attributes)
691+
);
692+
693+
assertThat(securityException.getMessage(), containsString("departments"));
694+
}
695+
696+
public void testAttributeSelectionWithRegex() {
538697
final boolean useFriendlyName = randomBoolean();
539698
final Settings settings = Settings.builder()
540699
.put(REALM_SETTINGS_PREFIX + ".attributes.principal", useFriendlyName ? "mail" : "urn:oid:0.9.2342.19200300.100.1.3")

0 commit comments

Comments
 (0)