diff --git a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/PerunAdapterMethods.java b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/PerunAdapterMethods.java index 0cdac9e78a308b0460fe916f472447202a8da4f4..d823ea00892f6c5a854052d00cf72dde6126e673 100644 --- a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/PerunAdapterMethods.java +++ b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/PerunAdapterMethods.java @@ -341,4 +341,6 @@ public interface PerunAdapterMethods { PerunUser getPerunUser(Long userId); + Set<Long> getUserVoIds(Long userId); + } diff --git a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterImpl.java b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterImpl.java index 489c124bb0951e2e8a0bbb55bce70cc636d2623d..33ecd0a2bbaf1de67da66ace9cae886c7b54668d 100644 --- a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterImpl.java +++ b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterImpl.java @@ -414,4 +414,17 @@ public class PerunAdapterImpl extends PerunAdapter { } } + @Override + public Set<Long> getUserVoIds(Long userId) { + try { + return this.getAdapterPrimary().getUserVoIds(userId); + } catch (UnsupportedOperationException e) { + if (this.isCallFallback()) { + return this.getAdapterFallback().getUserVoIds(userId); + } else { + throw e; + } + } + } + } diff --git a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterLdap.java b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterLdap.java index 5216f099de5e8497f84a34dc5bfdb122606f848c..ba6b673e17f0f33c0968a48274f08568c260bc05 100644 --- a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterLdap.java +++ b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterLdap.java @@ -531,6 +531,28 @@ public class PerunAdapterLdap extends PerunAdapterWithMappingServices implements return getPerunUser(filter); } + @Override + public Set<Long> getUserVoIds(Long userId) { + if (userId == null) { + throw new IllegalArgumentException("No userId"); + } + SearchScope scope = SearchScope.ONELEVEL; + final String[] attributes = {PERUN_VO_ID}; + + String uniqueMember = getDnPrefixForUserId(userId) + ',' + this.connectorLdap.getBaseDN(); + FilterBuilder filter = and(equal(UNIQUE_MEMBER, uniqueMember), equal(OBJECT_CLASS, PERUN_VO)); + EntryMapper<Long> mapper = e -> { + if (!checkHasAttributes(e, attributes)) { + return null; + } + + return Long.valueOf(e.get(PERUN_VO_ID).getString()); + }; + + List<Long> voIds = connectorLdap.search(null, filter, scope, attributes, mapper); + return voIds.stream().filter(Objects::nonNull).collect(Collectors.toSet()); + } + private PerunUser getPerunUser(FilterBuilder filter) { SearchScope scope = SearchScope.ONELEVEL; String[] attributes = new String[]{PERUN_USER_ID, GIVEN_NAME, SN}; diff --git a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterRpc.java b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterRpc.java index 4f50438203a56101db29c2c9f48f6ae8a6741493..68be610d78885e878a27c757aed4819c5b5f0777 100644 --- a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterRpc.java +++ b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/adapters/impl/PerunAdapterRpc.java @@ -950,6 +950,23 @@ public class PerunAdapterRpc extends PerunAdapterWithMappingServices implements return RpcMapper.mapPerunUser(response); } + @Override + public Set<Long> getUserVoIds(Long userId) { + if (!this.connectorRpc.isEnabled()) { + return Collections.emptySet(); + } else if (userId == null) { + throw new IllegalArgumentException("No userId"); + } + List<Member> members = getMembersByUser(userId); + Set<Long> voIds = new HashSet<>(); + for (Member member: members) { + if (VALID == member.getStatus()) { + voIds.add(member.getVoId()); + } + } + return voIds; + } + private Member getMemberByUser(Long userId, Long voId) { if (!this.connectorRpc.isEnabled()) { return null; diff --git a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/claims/sources/VoBasedEdupersonScopedAffiliationsClaimSource.java b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/claims/sources/VoBasedEdupersonScopedAffiliationsClaimSource.java index 63ff439f43c799abe8a1b02bc8b0aed1d568f2ec..262e77d78aa84eb561b6e0e29344b2bf901635c4 100644 --- a/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/claims/sources/VoBasedEdupersonScopedAffiliationsClaimSource.java +++ b/perun-oidc-server/src/main/java/cz/muni/ics/oidc/server/claims/sources/VoBasedEdupersonScopedAffiliationsClaimSource.java @@ -4,55 +4,59 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import cz.muni.ics.oidc.exceptions.ConfigurationException; import cz.muni.ics.oidc.server.claims.ClaimSource; import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext; import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext; import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** - * Claim source for eduperson_scoped_affiliations MUNI. + * Claim source for generating affiliation values based on VO membership(s). * * Configuration (replace [claimName] with the name of the claim): * <ul> * <li> - * <b>custom.claim.[claimName].source.config_file</b> - path to the YML config file, see - * 'eduperson_scoped_affiliations_mu_source.yml' for example configuration + * <b>custom.claim.[claimName].source.valueMap</b> - Mapping of voIds to affiliation values. Has to be specified + * in a format 'voId:aff,aff|voId:aff,aff', where 'voId' is an ID of the VO and 'aff' is the value of an + * affiliation to be added to the output if the user is a valid member of the respective VO with the specified + * identifier. * </li> * </ul> * - * @author Dominik Baránek <baranek@ics.muni.cz> * @author Dominik Frantisek Bucik <bucik@ics.muni.cz> */ @Slf4j -public class EdupersonScopedAffiliationsMUSource extends ClaimSource { +public class VoBasedEdupersonScopedAffiliationsClaimSource extends ClaimSource { - private static final String CONFIG_FILE = "config_file"; - private static final String KEY_SCOPE = "scope"; - private static final String KEY_VO_ID = "voId"; - private static final String KEY_AFFILIATIONS = "affiliations"; - private static final String KEY_VALUE = "value"; - private static final String KEY_GROUPS = "groups"; + private final Pattern epsaPattern = Pattern.compile( + "(member|student|faculty|staff|alum|affiliate|unknown|library-walk-in)@.+" + ); - private static final String DEFAULT_PATH = "/etc/perun/eduperson_scoped_affiliations_mu_source.yml"; + private static final String KEY_VALUE_MAP = "valueMap"; - private final Map<List<Long>, String> affiliations = new HashMap<>(); - private Long voId = 363L; - private String valueScope = "muni.cz"; + private final Map<Long, Set<String>> voIdValuesMap = new HashMap<>(); - public EdupersonScopedAffiliationsMUSource(ClaimSourceInitContext ctx) { + public VoBasedEdupersonScopedAffiliationsClaimSource(ClaimSourceInitContext ctx) { super(ctx); - parseConfigFile(ctx.getProperty(CONFIG_FILE, DEFAULT_PATH)); - log.debug("{} - affiliations: '{}', voId: '{}', valueScope: '{}'", - getClaimName(), affiliations, voId, valueScope); + String valueMapProp = ctx.getProperty(KEY_VALUE_MAP, null); + if (!StringUtils.hasText(valueMapProp)) { + throw new ConfigurationException("Invalid configuration for claim " + getClaimName() + ": valueMap must be provided"); + } + voIdValuesMap.putAll(parseValueMap(valueMapProp)); + log.debug("{} - voIdAffiliationsMap: '{}'", getClaimName(), voIdValuesMap); } @Override @@ -63,24 +67,18 @@ public class EdupersonScopedAffiliationsMUSource extends ClaimSource { @Override public JsonNode produceValue(ClaimSourceProduceContext pctx) { Long userId = pctx.getPerunUserId(); - ArrayNode result = JsonNodeFactory.instance.arrayNode(); - Set<Long> groups = pctx.getPerunAdapter().getUserGroupsIds(userId, voId); - for (Map.Entry<List<Long>, String> entry : affiliations.entrySet()) { - for (Long id: entry.getKey()) { - if (groups.contains(id)) { - String affiliation = entry.getValue() + '@' + valueScope; - log.trace("{} - added affiliation '{}' due to membership in group '{}'", - getClaimName(), affiliation, id); - result.add(affiliation); - break; - } + Set<String> userAffiliations = new HashSet<>(); + Set<Long> userVoIds = pctx.getPerunAdapter().getUserVoIds(userId); + for (Long userVoId: userVoIds) { + Set<String> affiliationsToBeAdded = voIdValuesMap.getOrDefault(userVoId, new HashSet<>()); + if (!affiliationsToBeAdded.isEmpty()) { + log.trace("{} - added affiliations '{}' due to membership in vo '{}'", + getClaimName(), affiliationsToBeAdded, userVoId); + userAffiliations.addAll(affiliationsToBeAdded); } } - - if (result.size() == 0) { - String affiliation = "affiliate@" + valueScope; - log.trace("{} - user is not a member in any special groups, added default affiliation: '{}'", - getClaimName(), affiliation); + ArrayNode result = JsonNodeFactory.instance.arrayNode(); + for (String affiliation : userAffiliations) { result.add(affiliation); } @@ -88,23 +86,69 @@ public class EdupersonScopedAffiliationsMUSource extends ClaimSource { return result; } - private void parseConfigFile(String file) { - log.trace("{} - Loading config file {}", getClaimName(), file); - YAMLMapper mapper = new YAMLMapper(); - try { - JsonNode root = mapper.readValue(new File(file), JsonNode.class); - valueScope = root.get(KEY_SCOPE).asText(); - voId = root.get(KEY_VO_ID).longValue(); - for (JsonNode affiliationMapping : root.path(KEY_AFFILIATIONS)) { - String value = affiliationMapping.path(KEY_VALUE).asText(); - List<Long> gids = new ArrayList<>(); - for (JsonNode gid : affiliationMapping.path(KEY_GROUPS)) { - gids.add(gid.asLong()); - } - affiliations.put(gids, value); + private Map<Long, Set<String>> parseValueMap(String valueMapProp) { + String[] valueMapParts = valueMapProp.split("\\|"); + if (valueMapParts.length == 0) { + throw getConfigurationException( + "Could not parse valueMap property. Needs to be in format voId1:aff1,aff2|voId2:aff3" + ); + } + for (String idValue: valueMapParts) { + if (!StringUtils.hasText(idValue)) { + throw getConfigurationException( + "Could not parse id and affiliations mapping, empty String encountered" + ); } - } catch (IOException ex) { - log.warn("{} - cannot read claim configuration file: '{}'", getClaimName(), file); + String[] idValueParts = idValue.split(":"); + if (idValueParts.length != 2) { + throw getConfigurationException( + "Could not parse id and affiliations mapping. Needs to be in format voId:aff1,aff2" + ); + } + long voId; + try { + voId = Long.parseLong(idValueParts[0]); + } catch (NumberFormatException ex) { + throw getConfigurationException("Could not parse VO id out of subcomponent " + idValue, ex); + } + Set<String> voAffiliations = parseAffiliations(idValueParts[1]); + if (voAffiliations.isEmpty()) { + throw getConfigurationException("No affiliation values found for voId " + voId); + } + voIdValuesMap.put(voId, voAffiliations); + } + return voIdValuesMap; + } + + private Set<String> parseAffiliations(String idValuePart) { + String[] affiliations = idValuePart.split(","); + Set<String> resolvedAffiliations = new HashSet<>(); + for (String affiliation : affiliations) { + if (!epsaPattern.matcher(affiliation).matches()) { + throw getConfigurationException( + "Value '" + affiliation + "' is not a valid eduPersonScopedAffiliation value" + ); + } + resolvedAffiliations.add(affiliation); + } + return resolvedAffiliations; + } + + private ConfigurationException getConfigurationException(String message) { + return getConfigurationException(message, null); + } + + private ConfigurationException getConfigurationException(String message, Throwable cause) { + StringBuilder fullMessage = new StringBuilder(); + fullMessage.append("Invalid configuration for claim ").append(getClaimName()); + if (StringUtils.hasText(message)) { + fullMessage.append(": ").append(message); + } + + if (cause != null) { + throw new ConfigurationException(fullMessage.toString(), cause); + } else { + throw new ConfigurationException(fullMessage.toString()); } }