PropertiesBasedRoleMapper.java
/*
* Copyright 2019 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.adapters.saml;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.Collections;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;
import org.jboss.logging.Logger;
import org.keycloak.adapters.saml.config.parsers.ResourceLoader;
/**
* A {@link RoleMappingsProvider} implementation that uses a {@code properties} file to determine the mappings that should be applied
* to the SAML principal and roles. It is always identified by the id {@code properties-based-role-mapper} in {@code keycloak-saml.xml}.
* <p/>
* This provider relies on two configuration properties that can be used to specify the location of the {@code properties} file
* that will be used. First, it checks if the {@code properties.file.location} property has been specified, using the configured
* value to locate the {@code properties} file in the filesystem. If the configured file is not located, the provider throws a
* {@link RuntimeException}. The following snippet shows an example of provider using the {@code properties.file.configuration}
* option to load the {@code roles.properties} file from the {@code /opt/mappers/} directory in the filesystem:
*
* <pre>
* <RoleMappingsProvider id="properties-based-role-mapper">
* <Property name="properties.file.location" value="/opt/mappers/roles.properties"/>
* </RoleMappingsProvider>
* </pre>
*
* If the {@code properties.file.location} configuration property is not present, the provider checks the {@code properties.resource.location}
* property, using the configured value to load the {@code properties} file from the WAR resource. If no value is found, it
* finally attempts to load a file named {@code role-mappings.properties} from the {@code WEB-INF} directory of the application.
* Failure to load the file from the resource will result in the provider throwing a {@link RuntimeException}. The following
* snippet shows an example of provider using the {@code properties.resource.location} to load the {@code roles.properties}
* file from the application's {@code /WEB-INF/conf/} directory:
*
* <pre>
* <RoleMappingsProvider id="properties-based-role-mapper">
* <Property name="properties.resource.location" value="/WEB-INF/conf/roles.properties"/>
* </RoleMappingsProvider>
* </pre>
*
* The {@code properties} file can contain both roles and principals as keys, and a list of zero or more roles separated by comma
* as values. When the {@code {@link #map(String, Set)}} method is called, the implementation iterates through the set of roles
* that were extracted from the assertion and checks, for eache role, if a mapping exists. If the role maps to an empty role,
* it is discarded. If it maps to a set of one ore more different roles, then these roles are set in the result set. If no
* mapping is found for the role then it is included as is in the result set.
*
* Once the roles have been processed, the implementation checks if the principal extracted from the assertion contains an entry
* in the {@code properties} file. If a mapping for the principal exists, any roles listed as value are added to the result set. This
* allows the assignment of extra roles to a principal.
*
* For example, consider the following {@code properties} file:
*
* <pre>
* # role to roles mappings
* samlRoleA=jeeRoleX,jeeRoleY
* samlRoleB=
*
* # principal to roles mappings
* kc-user=jeeRoleZ
* </pre>
*
* If the {@code {@link #map(String, Set)}} method is called with {@code kc-user} as principal and a set containing roles
* {@code samlRoleA,samlRoleB,samlRoleC}, the result set will be formed by the roles {@code jeeRoleX,jeeRoleY,samlRoleC,jeeRoleZ}.
* In this case, {@code samlRoleA} is mapped to two roles ({@code jeeRoleX,jeeRoleY}), {@code samlRoleB} is discarded as it is
* mapped to an empty role, {@code samlRoleC} is used as is and the principal is also assigned {@code jeeRoleZ}.
*
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
*/
public class PropertiesBasedRoleMapper implements RoleMappingsProvider {
private static final Logger logger = Logger.getLogger(PropertiesBasedRoleMapper.class);
public static final String PROVIDER_ID = "properties-based-role-mapper";
private static final String PROPERTIES_FILE_LOCATION = "properties.file.location";
private static final String PROPERTIES_RESOURCE_LOCATION = "properties.resource.location";
private static final String DEFAULT_RESOURCE_LOCATION = "/WEB-INF/role-mappings.properties";
private Properties roleMappings;
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public void init(final SamlDeployment deployment, final ResourceLoader loader, final Properties config) {
this.roleMappings = new Properties();
// try to load the properties from the filesystem first.
String path = config.getProperty(PROPERTIES_FILE_LOCATION);
if (path != null) {
File file = new File(path);
if (file.exists()) {
try (FileInputStream is = new FileInputStream(file)){
this.roleMappings.load(is);
logger.debugf("Successfully loaded role mappings from %s", path);
} catch (Exception e) {
logger.debugv(e, "Unable to load role mappings from %s", path);
}
} else {
throw new RuntimeException("Unable to load role mappings from " + path + ": file does not exist in filesystem");
}
} else {
// try to load the properties from the resource (WAR).
path = config.getProperty(PROPERTIES_RESOURCE_LOCATION, DEFAULT_RESOURCE_LOCATION);
InputStream is = loader.getResourceAsStream(path);
if (is != null) {
try {
this.roleMappings.load(is);
logger.debugf("Resource loader successfully loaded role mappings from %s", path);
} catch (Exception e) {
logger.debugv(e, "Resource loader unable to load role mappings from %s", path);
}
} else {
throw new RuntimeException("Unable to load role mappings from " + path + ": file does not exist in the resource");
}
}
}
@Override
public Set<String> map(final String principalName, final Set<String> roles) {
if (this.roleMappings == null || this.roleMappings.isEmpty())
return roles;
Set<String> resolvedRoles = new HashSet<>();
// first check if we have role -> role(s) mappings.
for (String role : roles) {
if (this.roleMappings.containsKey(role)) {
// role that was mapped to empty string is not considered (it is discarded from the set of specified roles).
this.extractRolesIntoSet(role, resolvedRoles);
} else {
// no mapping found for role - add it as is.
resolvedRoles.add(role);
}
}
// now check if we have a principal -> role(s) mapping with additional roles to be added.
if (this.roleMappings.containsKey(principalName)) {
this.extractRolesIntoSet(principalName, resolvedRoles);
}
return resolvedRoles;
}
/**
* Obtains the list of comma separated roles associated with the specified entry, trims any whitespaces from said roles
* and adds them to the specified set.
*
* @param entry the entry in the properties file.
* @param roles the {@link Set<String>} into which the extracted roles are to be added.
*/
private void extractRolesIntoSet(final String entry, final Set<String> roles) {
String value = this.roleMappings.getProperty(entry);
if (!value.isEmpty()) {
String[] mappedRoles = value.split(",");
for (String mappedRole : mappedRoles) {
String trimmedRole = mappedRole.trim();
if (!trimmedRole.isEmpty()) {
roles.add(trimmedRole);
}
}
}
}
}