Skip to content

Commit a085362

Browse files
authored
Add wildcard service providers to IdP (#54477)
This adds the ability for the IdP to define wildcard service providers in a JSON file within the ES node's config directory. If a request is made for a service provider that has not been registered, then the set of wildcard services is consulted. If the SP entity-id and ACS match one of the wildcard patterns, then a dynamic service provider is defined from the associated mustache template. Backport of: #54148
1 parent 915435b commit a085362

32 files changed

+1176
-161
lines changed

x-pack/plugin/identity-provider/qa/idp-rest-tests/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ testClusters.integTest {
2727
extraConfigFile 'roles.yml', file('src/test/resources/roles.yml')
2828
extraConfigFile 'idp-sign.crt', file('src/test/resources/idp-sign.crt')
2929
extraConfigFile 'idp-sign.key', file('src/test/resources/idp-sign.key')
30+
extraConfigFile 'wildcard_services.json', file('src/test/resources/wildcard_services.json')
3031

3132
user username: "admin_user", password: "admin-password"
3233
user username: "idp_user", password: "idp-password", role: "idp_role"

x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/IdpRestTestCase.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,34 @@
55
*/
66
package org.elasticsearch.xpack.idp;
77

8+
import org.elasticsearch.client.RequestOptions;
9+
import org.elasticsearch.client.RestHighLevelClient;
10+
import org.elasticsearch.client.security.DeleteRoleRequest;
11+
import org.elasticsearch.client.security.DeleteUserRequest;
12+
import org.elasticsearch.client.security.PutRoleRequest;
13+
import org.elasticsearch.client.security.PutUserRequest;
14+
import org.elasticsearch.client.security.RefreshPolicy;
15+
import org.elasticsearch.client.security.user.User;
16+
import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges;
17+
import org.elasticsearch.client.security.user.privileges.IndicesPrivileges;
18+
import org.elasticsearch.client.security.user.privileges.Role;
819
import org.elasticsearch.common.settings.SecureString;
920
import org.elasticsearch.common.settings.Settings;
1021
import org.elasticsearch.common.util.concurrent.ThreadContext;
1122
import org.elasticsearch.test.rest.ESRestTestCase;
1223

24+
import java.io.IOException;
25+
import java.util.Collection;
26+
import java.util.Collections;
27+
28+
import static java.util.Arrays.asList;
29+
import static java.util.Collections.emptyMap;
1330
import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
1431

1532
public abstract class IdpRestTestCase extends ESRestTestCase {
1633

34+
private RestHighLevelClient highLevelAdminClient;
35+
1736
@Override
1837
protected Settings restAdminSettings() {
1938
String token = basicAuthHeaderValue("admin_user", new SecureString("admin-password".toCharArray()));
@@ -29,4 +48,48 @@ protected Settings restClientSettings() {
2948
.put(ThreadContext.PREFIX + ".Authorization", token)
3049
.build();
3150
}
51+
52+
private RestHighLevelClient getHighLevelAdminClient() {
53+
if (highLevelAdminClient == null) {
54+
highLevelAdminClient = new RestHighLevelClient(
55+
adminClient(),
56+
ignore -> {
57+
},
58+
Collections.emptyList()) {
59+
};
60+
}
61+
return highLevelAdminClient;
62+
}
63+
64+
protected User createUser(String username, SecureString password, String... roles) throws IOException {
65+
final RestHighLevelClient client = getHighLevelAdminClient();
66+
final User user = new User(username, asList(roles), emptyMap(), username + " in " + getTestName(), username + "@test.example.com");
67+
final PutUserRequest request = PutUserRequest.withPassword(user, password.getChars(), true, RefreshPolicy.WAIT_UNTIL);
68+
client.security().putUser(request, RequestOptions.DEFAULT);
69+
return user;
70+
}
71+
72+
protected void deleteUser(String username) throws IOException {
73+
final RestHighLevelClient client = getHighLevelAdminClient();
74+
final DeleteUserRequest request = new DeleteUserRequest(username, RefreshPolicy.WAIT_UNTIL);
75+
client.security().deleteUser(request, RequestOptions.DEFAULT);
76+
}
77+
78+
protected void createRole(String name, Collection<String> clusterPrivileges, Collection<IndicesPrivileges> indicesPrivileges,
79+
Collection<ApplicationResourcePrivileges> applicationPrivileges) throws IOException {
80+
final RestHighLevelClient client = getHighLevelAdminClient();
81+
final Role role = Role.builder()
82+
.name(name)
83+
.clusterPrivileges(clusterPrivileges)
84+
.indicesPrivileges(indicesPrivileges)
85+
.applicationResourcePrivileges(applicationPrivileges)
86+
.build();
87+
client.security().putRole(new PutRoleRequest(role, null), RequestOptions.DEFAULT);
88+
}
89+
90+
protected void deleteRole(String name) throws IOException {
91+
final RestHighLevelClient client = getHighLevelAdminClient();
92+
final DeleteRoleRequest request = new DeleteRoleRequest(name, RefreshPolicy.WAIT_UNTIL);
93+
client.security().deleteRole(request, RequestOptions.DEFAULT);
94+
}
3295
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
package org.elasticsearch.xpack.idp;
7+
8+
import org.elasticsearch.client.Request;
9+
import org.elasticsearch.client.Response;
10+
import org.elasticsearch.client.security.user.User;
11+
import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges;
12+
import org.elasticsearch.common.bytes.BytesReference;
13+
import org.elasticsearch.common.collect.MapBuilder;
14+
import org.elasticsearch.common.settings.SecureString;
15+
import org.elasticsearch.common.xcontent.XContentBuilder;
16+
import org.elasticsearch.common.xcontent.XContentType;
17+
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
18+
19+
import java.io.IOException;
20+
import java.io.UnsupportedEncodingException;
21+
import java.net.URLEncoder;
22+
import java.util.Arrays;
23+
import java.util.Collections;
24+
import java.util.Map;
25+
26+
import static org.hamcrest.Matchers.containsInAnyOrder;
27+
import static org.hamcrest.Matchers.containsString;
28+
import static org.hamcrest.Matchers.equalTo;
29+
import static org.hamcrest.Matchers.instanceOf;
30+
import static org.hamcrest.Matchers.notNullValue;
31+
32+
public class WildcardServiceProviderRestIT extends IdpRestTestCase {
33+
34+
// From build.gradle
35+
private final String IDP_ENTITY_ID = "https://idp.test.es.elasticsearch.org/";
36+
// From SAMLConstants
37+
private final String REDIRECT_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
38+
39+
public void testGetWildcardServiceProviderMetadata() throws Exception {
40+
final String owner = randomAlphaOfLength(8);
41+
final String service = randomAlphaOfLength(8);
42+
// From "wildcard_services.json"
43+
final String entityId = "service:" + owner + ":" + service;
44+
final String acs = "https://" + service + ".services.example.com/saml/acs";
45+
getMetaData(entityId, acs);
46+
}
47+
48+
public void testInitSingleSignOnToWildcardServiceProvider() throws Exception {
49+
final String owner = randomAlphaOfLength(8);
50+
final String service = randomAlphaOfLength(8);
51+
// From "wildcard_services.json"
52+
final String entityId = "service:" + owner + ":" + service;
53+
final String acs = "https://" + service + ".services.example.com/api/v1/saml";
54+
55+
final String username = randomAlphaOfLength(6);
56+
final SecureString password = new SecureString((randomAlphaOfLength(6) + randomIntBetween(10, 99)).toCharArray());
57+
final String roleName = username + "_role";
58+
final User user = createUser(username, password, roleName);
59+
60+
final ApplicationResourcePrivileges applicationPrivilege = new ApplicationResourcePrivileges(
61+
"elastic-cloud", Collections.singletonList("sso:admin"), Collections.singletonList("sso:" + entityId)
62+
);
63+
createRole(roleName, Collections.emptyList(), Collections.emptyList(), Collections.singletonList(applicationPrivilege));
64+
65+
final String samlResponse = initSso(entityId, acs, new UsernamePasswordToken(username, password));
66+
67+
for (String attr : Arrays.asList("principal", "email", "name", "roles")) {
68+
assertThat(samlResponse, containsString("Name=\"saml:attribute:" + attr + "\""));
69+
assertThat(samlResponse, containsString("FriendlyName=\"" + attr + "\""));
70+
}
71+
72+
assertThat(samlResponse, containsString(user.getUsername()));
73+
assertThat(samlResponse, containsString(user.getEmail()));
74+
assertThat(samlResponse, containsString(user.getFullName()));
75+
assertThat(samlResponse, containsString(">admin<"));
76+
77+
deleteUser(username);
78+
deleteRole(roleName);
79+
}
80+
81+
private void getMetaData(String entityId, String acs) throws IOException {
82+
final Map<String, Object> map = getAsMap("/_idp/saml/metadata/" + encode(entityId) + "?acs=" + encode(acs));
83+
assertThat(map, notNullValue());
84+
assertThat(map.keySet(), containsInAnyOrder("metadata"));
85+
final Object metadata = map.get("metadata");
86+
assertThat(metadata, notNullValue());
87+
assertThat(metadata, instanceOf(String.class));
88+
assertThat((String) metadata, containsString(IDP_ENTITY_ID));
89+
assertThat((String) metadata, containsString(REDIRECT_BINDING));
90+
}
91+
92+
private String initSso(String entityId, String acs, UsernamePasswordToken secondaryAuth) throws IOException {
93+
final Request request = new Request("POST", "/_idp/saml/init/");
94+
request.setJsonEntity(toJson(MapBuilder.<String, Object>newMapBuilder().put("entity_id", entityId).put("acs", acs).map()));
95+
request.setOptions(request.getOptions().toBuilder().addHeader("es-secondary-authorization",
96+
UsernamePasswordToken.basicAuthHeaderValue(secondaryAuth.principal(), secondaryAuth.credentials())));
97+
Response response = client().performRequest(request);
98+
99+
final Map<String, Object> map = entityAsMap(response);
100+
assertThat(map, notNullValue());
101+
assertThat(map.keySet(), containsInAnyOrder("post_url", "saml_response", "service_provider"));
102+
assertThat(map.get("post_url"), equalTo(acs));
103+
assertThat(map.get("saml_response"), instanceOf(String.class));
104+
105+
final String samlResponse = (String) map.get("saml_response");
106+
assertThat(samlResponse, containsString(entityId));
107+
assertThat(samlResponse, containsString(acs));
108+
109+
return samlResponse;
110+
}
111+
112+
private String toJson(Map<String, Object> body) throws IOException {
113+
try (XContentBuilder builder = XContentBuilder.builder(XContentType.JSON.xContent()).map(body)) {
114+
return BytesReference.bytes(builder).utf8ToString();
115+
}
116+
}
117+
118+
private String encode(String param) throws UnsupportedEncodingException {
119+
return URLEncoder.encode(param, "UTF-8");
120+
}
121+
122+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"services": {
3+
"wildcard-app1": {
4+
"entity_id": "service:(?<owner>\\w+):(?<service>\\w+)",
5+
"acs": "https://(?<service>\\w+).services.example.com/saml/acs",
6+
"tokens": [ "service" ],
7+
"template": {
8+
"name": "Application 1 ({{service}})",
9+
"privileges": {
10+
"resource": "sso:{{entity_id}}",
11+
"roles": {
12+
"admin": "sso:admin"
13+
}
14+
},
15+
"attributes": {
16+
"principal": "saml:attribute:principal",
17+
"name": "saml:attribute:name",
18+
"email": "saml:attribute:email",
19+
"roles": "saml:attribute:roles"
20+
}
21+
}
22+
},
23+
"wildcard-app2": {
24+
"entity_id": "service:(?<owner>\\w+):(?<service>\\w+)",
25+
"acs": "https://(?<service>\\w+).services.example.com/api/v1/saml",
26+
"tokens": [ "service" ],
27+
"template": {
28+
"name": "Application 2 ({{service}})",
29+
"privileges": {
30+
"resource": "sso:{{entity_id}}",
31+
"roles": {
32+
"admin": "sso:admin"
33+
}
34+
},
35+
"attributes": {
36+
"principal": "saml:attribute:principal",
37+
"name": "saml:attribute:name",
38+
"email": "saml:attribute:email",
39+
"roles": "saml:attribute:roles"
40+
}
41+
}
42+
}
43+
}
44+
}
45+

x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,21 @@
4545
import org.elasticsearch.xpack.idp.action.TransportSamlMetadataAction;
4646
import org.elasticsearch.xpack.idp.action.TransportSamlValidateAuthnRequestAction;
4747
import org.elasticsearch.xpack.idp.privileges.UserPrivilegeResolver;
48-
import org.elasticsearch.xpack.idp.saml.rest.action.RestDeleteSamlServiceProviderAction;
4948
import org.elasticsearch.xpack.idp.saml.idp.SamlIdentityProvider;
50-
import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults;
5149
import org.elasticsearch.xpack.idp.saml.idp.SamlIdentityProviderBuilder;
52-
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlMetadataAction;
50+
import org.elasticsearch.xpack.idp.saml.rest.action.RestDeleteSamlServiceProviderAction;
51+
import org.elasticsearch.xpack.idp.saml.rest.action.RestPutSamlServiceProviderAction;
5352
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlInitiateSingleSignOnAction;
53+
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlMetadataAction;
5454
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlValidateAuthenticationRequestAction;
55-
import org.elasticsearch.xpack.idp.saml.support.SamlFactory;
56-
import org.elasticsearch.xpack.idp.saml.support.SamlInit;
57-
import org.elasticsearch.xpack.idp.saml.rest.action.RestPutSamlServiceProviderAction;
55+
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderFactory;
5856
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex;
5957
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderResolver;
58+
import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderCacheSettings;
59+
import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults;
60+
import org.elasticsearch.xpack.idp.saml.sp.WildcardServiceProviderResolver;
61+
import org.elasticsearch.xpack.idp.saml.support.SamlFactory;
62+
import org.elasticsearch.xpack.idp.saml.support.SamlInit;
6063

6164
import java.util.ArrayList;
6265
import java.util.Arrays;
@@ -96,8 +99,12 @@ public Collection<Object> createComponents(Client client, ClusterService cluster
9699

97100

98101
final ServiceProviderDefaults serviceProviderDefaults = ServiceProviderDefaults.forSettings(settings);
99-
final SamlServiceProviderResolver resolver = new SamlServiceProviderResolver(settings, index, serviceProviderDefaults);
100-
final SamlIdentityProvider idp = SamlIdentityProvider.builder(resolver)
102+
final SamlServiceProviderFactory serviceProviderFactory = new SamlServiceProviderFactory(serviceProviderDefaults);
103+
final SamlServiceProviderResolver registeredServiceProviderResolver
104+
= new SamlServiceProviderResolver(settings, index, serviceProviderFactory);
105+
final WildcardServiceProviderResolver wildcardServiceProviderResolver
106+
= WildcardServiceProviderResolver.create(environment, resourceWatcherService, scriptService, serviceProviderFactory);
107+
final SamlIdentityProvider idp = SamlIdentityProvider.builder(registeredServiceProviderResolver, wildcardServiceProviderResolver)
101108
.fromSettings(environment)
102109
.serviceProviderDefaults(serviceProviderDefaults)
103110
.build();
@@ -148,7 +155,9 @@ public List<Setting<?>> getSettings() {
148155
List<Setting<?>> settings = new ArrayList<>();
149156
settings.add(ENABLED_SETTING);
150157
settings.addAll(SamlIdentityProviderBuilder.getSettings());
158+
settings.addAll(ServiceProviderCacheSettings.getSettings());
151159
settings.addAll(ServiceProviderDefaults.getSettings());
160+
settings.addAll(WildcardServiceProviderResolver.getSettings());
152161
settings.addAll(X509KeyPairSettings.withPrefix("xpack.idp.signing.", false).getAllSettings());
153162
settings.addAll(X509KeyPairSettings.withPrefix("xpack.idp.metadata_signing.", false).getAllSettings());
154163
return Collections.unmodifiableList(settings);

x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,20 @@
1313
import org.elasticsearch.common.io.stream.StreamOutput;
1414
import org.elasticsearch.xpack.idp.saml.support.SamlAuthenticationState;
1515

16-
import static org.elasticsearch.action.ValidateActions.addValidationError;
17-
1816
import java.io.IOException;
1917

18+
import static org.elasticsearch.action.ValidateActions.addValidationError;
19+
2020
public class SamlInitiateSingleSignOnRequest extends ActionRequest {
2121

2222
private String spEntityId;
23+
private String assertionConsumerService;
2324
private SamlAuthenticationState samlAuthenticationState;
2425

2526
public SamlInitiateSingleSignOnRequest(StreamInput in) throws IOException {
2627
super(in);
2728
spEntityId = in.readString();
29+
assertionConsumerService = in.readString();
2830
samlAuthenticationState = in.readOptionalWriteable(SamlAuthenticationState::new);
2931
}
3032

@@ -37,12 +39,16 @@ public ActionRequestValidationException validate() {
3739
if (Strings.isNullOrEmpty(spEntityId)) {
3840
validationException = addValidationError("entity_id is missing", validationException);
3941
}
42+
if (Strings.isNullOrEmpty(assertionConsumerService)) {
43+
validationException = addValidationError("acs is missing", validationException);
44+
}
4045
if (samlAuthenticationState != null) {
4146
final ValidationException authnStateException = samlAuthenticationState.validate();
42-
if (validationException != null) {
43-
ActionRequestValidationException actionRequestValidationException = new ActionRequestValidationException();
44-
actionRequestValidationException.addValidationErrors(authnStateException.validationErrors());
45-
validationException = addValidationError("entity_id is missing", actionRequestValidationException);
47+
if (authnStateException != null && authnStateException.validationErrors().isEmpty() == false) {
48+
if (validationException == null) {
49+
validationException = new ActionRequestValidationException();
50+
}
51+
validationException.addValidationErrors(authnStateException.validationErrors());
4652
}
4753
}
4854
return validationException;
@@ -56,6 +62,14 @@ public void setSpEntityId(String spEntityId) {
5662
this.spEntityId = spEntityId;
5763
}
5864

65+
public String getAssertionConsumerService() {
66+
return assertionConsumerService;
67+
}
68+
69+
public void setAssertionConsumerService(String assertionConsumerService) {
70+
this.assertionConsumerService = assertionConsumerService;
71+
}
72+
5973
public SamlAuthenticationState getSamlAuthenticationState() {
6074
return samlAuthenticationState;
6175
}
@@ -68,11 +82,13 @@ public void setSamlAuthenticationState(SamlAuthenticationState samlAuthenticatio
6882
public void writeTo(StreamOutput out) throws IOException {
6983
super.writeTo(out);
7084
out.writeString(spEntityId);
85+
out.writeString(assertionConsumerService);
7186
out.writeOptionalWriteable(samlAuthenticationState);
7287
}
7388

7489
@Override
7590
public String toString() {
76-
return getClass().getSimpleName() + "{spEntityId='" + spEntityId + "'}";
91+
return getClass().getSimpleName() + "{spEntityId='" + spEntityId + "', acs='" + assertionConsumerService + "'}";
7792
}
93+
7894
}

0 commit comments

Comments
 (0)