Skip to content

Commit

Permalink
idp mappers (#293)
Browse files Browse the repository at this point in the history
* formatting and stub AdvancedClaimToOrgRoleMapper HardcodedOrgRoleMapper

* config property definitions

* grantOrgRole for hardcoded mapper

* imports

* added ability to add membership if the user is not already

* test org membership before assigning roles/orgs

* added saml

* added saml mapper. refactored into a common interface

* spi annotation to AdvancedAttributeToOrgRoleMapper
  • Loading branch information
xgp authored Jan 9, 2025
1 parent 44e5432 commit 9ef07a2
Show file tree
Hide file tree
Showing 19 changed files with 768 additions and 127 deletions.
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.java.package>io.phasetwo.service</main.java.package>
<junit.version>5.11.2</junit.version>
<keycloak.version>26.0.2</keycloak.version>
<keycloak-admin-client.version>26.0.1</keycloak-admin-client.version>
<keycloak.version>26.0.7</keycloak.version>
<keycloak-admin-client.version>26.0.3</keycloak-admin-client.version>
<resteasy.version>6.2.7.Final</resteasy.version>
<lombok.version>1.18.34</lombok.version>
<guava.version>33.0.0-jre</guava.version>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;

@SuppressWarnings("rawtypes")
@AutoService(ActionTokenHandlerFactory.class)
public class PortalLinkActionTokenHandlerFactory
implements ActionTokenHandlerFactory<PortalLinkActionToken> {
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/io/phasetwo/service/broker/Mappers.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package io.phasetwo.service.broker;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import lombok.extern.jbosslog.JBossLog;
import org.keycloak.models.IdentityProviderSyncMode;

@JBossLog
public final class Mappers {

public static final String ARE_ATTRIBUTE_VALUES_REGEX_PROPERTY_NAME =
"are.attribute.values.regex";
public static final String ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME = "are.claim.values.regex";
public static final String ATTRIBUTE_PROPERTY_NAME = "attributes";
public static final String CLAIM_PROPERTY_NAME = "claims";
public static final String ORG_ADD_PROPERTY_NAME = "org_add";
public static final String ORG_PROPERTY_NAME = "org";
public static final String ORG_ROLE_PROPERTY_NAME = "org_role";

public static final Set<IdentityProviderSyncMode> IDENTITY_PROVIDER_SYNC_MODES =
new HashSet<>(Arrays.asList(IdentityProviderSyncMode.values()));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package io.phasetwo.service.broker.oidc.mappers;

import static io.phasetwo.service.broker.Mappers.*;
import static org.keycloak.utils.RegexUtils.valueMatchesRegex;

import com.google.auto.service.AutoService;
import io.phasetwo.service.broker.OrgRoleMapper;
import io.phasetwo.service.model.OrganizationModel;
import io.phasetwo.service.model.OrganizationRoleModel;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.extern.jbosslog.JBossLog;
import org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.broker.oidc.mappers.AbstractClaimMapper;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityProviderMapper;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.ProviderConfigProperty;

@JBossLog
@AutoService(IdentityProviderMapper.class)
public class AdvancedClaimToOrgRoleMapper extends AbstractClaimMapper implements OrgRoleMapper {

public static final String[] COMPATIBLE_PROVIDERS = {
KeycloakOIDCIdentityProviderFactory.PROVIDER_ID, OIDCIdentityProviderFactory.PROVIDER_ID
};

private static final List<ProviderConfigProperty> configProperties = new ArrayList<>();

static {
ProviderConfigProperty claimsProperty = new ProviderConfigProperty();
claimsProperty.setName(CLAIM_PROPERTY_NAME);
claimsProperty.setLabel("Claims");
claimsProperty.setHelpText(
"Name and value of the claims to search for in token. You can reference nested claims using a '.', i.e. 'address.locality'. To use dot (.) literally, escape it with backslash (\\.)");
claimsProperty.setType(ProviderConfigProperty.MAP_TYPE);
configProperties.add(claimsProperty);
ProviderConfigProperty isClaimValueRegexProperty = new ProviderConfigProperty();
isClaimValueRegexProperty.setName(ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME);
isClaimValueRegexProperty.setLabel("Regex Claim Values");
isClaimValueRegexProperty.setHelpText(
"If enabled claim values are interpreted as regular expressions.");
isClaimValueRegexProperty.setType(ProviderConfigProperty.BOOLEAN_TYPE);
configProperties.add(isClaimValueRegexProperty);

OrgRoleMapper.addOrgConfigProperties(configProperties);
}

public static final String PROVIDER_ID = "oidc-advanced-org-role-idp-mapper";

@Override
public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) {
return IDENTITY_PROVIDER_SYNC_MODES.contains(syncMode);
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}

@Override
public String getId() {
return PROVIDER_ID;
}

@Override
public String[] getCompatibleProviders() {
return COMPATIBLE_PROVIDERS;
}

@Override
public String getDisplayCategory() {
return "Role Importer";
}

@Override
public String getDisplayType() {
return "Advanced Claim to Org Role";
}

@Override
public String getHelpText() {
return "If all claims exists, grant the user the specified organization role.";
}

protected boolean applies(
IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
Map<String, List<String>> claims = mapperModel.getConfigMap(CLAIM_PROPERTY_NAME);
boolean areClaimValuesRegex =
Boolean.parseBoolean(
mapperModel.getConfig().getOrDefault(ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME, "false"));

for (Map.Entry<String, List<String>> claim : claims.entrySet()) {
Object claimValue = getClaimValue(context, claim.getKey());
for (String value : claim.getValue()) {
boolean claimValuesMismatch =
!(areClaimValuesRegex
? valueMatchesRegex(value, claimValue)
: valueEquals(value, claimValue));
if (claimValuesMismatch) {
return false;
}
}
}

return true;
}

@Override
public void importNewUser(
KeycloakSession session,
RealmModel realm,
UserModel user,
IdentityProviderMapperModel mapperModel,
BrokeredIdentityContext context) {
OrganizationModel org = getOrg(session, realm, mapperModel);
if (org == null) {
return;
}
OrganizationRoleModel role = getOrgRole(session, realm, mapperModel);
if (role == null) {
return;
}

if (applies(mapperModel, context)) {
addUserToOrg(org, user, mapperModel);
grantOrgRole(org, role, user);
}
}

@Override
public void updateBrokeredUserLegacy(
KeycloakSession session,
RealmModel realm,
UserModel user,
IdentityProviderMapperModel mapperModel,
BrokeredIdentityContext context) {
OrganizationModel org = getOrg(session, realm, mapperModel);
if (org == null) {
return;
}
OrganizationRoleModel role = getOrgRole(session, realm, mapperModel);
if (role == null) {
return;
}

if (!applies(mapperModel, context)) {
revokeOrgRole(org, role, user);
}
}

@Override
public void updateBrokeredUser(
KeycloakSession session,
RealmModel realm,
UserModel user,
IdentityProviderMapperModel mapperModel,
BrokeredIdentityContext context) {
OrganizationModel org = getOrg(session, realm, mapperModel);
if (org == null) {
return;
}
OrganizationRoleModel role = getOrgRole(session, realm, mapperModel);
if (role == null) {
return;
}

if (applies(mapperModel, context)) {
addUserToOrg(org, user, mapperModel);
grantOrgRole(org, role, user);
} else {
revokeOrgRole(org, role, user);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package io.phasetwo.service.broker;

import static io.phasetwo.service.broker.Mappers.*;

import com.google.common.base.Strings;
import io.phasetwo.service.model.OrganizationModel;
import io.phasetwo.service.model.OrganizationProvider;
import io.phasetwo.service.model.OrganizationRoleModel;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.ProviderConfigProperty;

public interface OrgRoleMapper {

static final Logger log = Logger.getLogger(OrgRoleMapper.class);

static void addOrgConfigProperties(List<ProviderConfigProperty> configProperties) {
ProviderConfigProperty orgAdd = new ProviderConfigProperty();
orgAdd.setName(ORG_ADD_PROPERTY_NAME);
orgAdd.setLabel("Add To Organization");
orgAdd.setHelpText("Add user to the organization as a member if not already.");
orgAdd.setType(ProviderConfigProperty.BOOLEAN_TYPE);
configProperties.add(orgAdd);
ProviderConfigProperty org = new ProviderConfigProperty();
org.setName(ORG_PROPERTY_NAME);
org.setLabel("Organization");
org.setHelpText("Organization containing the role to grant to user.");
org.setType(ProviderConfigProperty.STRING_TYPE);
configProperties.add(org);
ProviderConfigProperty orgRole = new ProviderConfigProperty();
orgRole.setName(ORG_ROLE_PROPERTY_NAME);
orgRole.setLabel("Organization Role");
orgRole.setHelpText("Organization role to grant to user.");
orgRole.setType(ProviderConfigProperty.STRING_TYPE);
configProperties.add(orgRole);
}

default boolean orgAdd(IdentityProviderMapperModel mapperModel) {
return Boolean.parseBoolean(
mapperModel.getConfig().getOrDefault(ORG_ADD_PROPERTY_NAME, "false"));
}

default void addUserToOrg(
OrganizationModel org, UserModel user, IdentityProviderMapperModel mapperModel) {
if (orgAdd(mapperModel) && !org.hasMembership(user)) {
org.grantMembership(user);
}
}

default OrganizationModel getOrg(
KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel) {
OrganizationProvider orgs = session.getProvider(OrganizationProvider.class);
String orgName = mapperModel.getConfig().getOrDefault(ORG_PROPERTY_NAME, null);
if (Strings.isNullOrEmpty(orgName)) return null;
return orgs.getOrganizationByName(realm, orgName);
}

default OrganizationRoleModel getOrgRole(
KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel) {
OrganizationProvider orgs = session.getProvider(OrganizationProvider.class);
String orgName = mapperModel.getConfig().getOrDefault(ORG_PROPERTY_NAME, null);
String orgRoleName = mapperModel.getConfig().getOrDefault(ORG_ROLE_PROPERTY_NAME, null);
if (Strings.isNullOrEmpty(orgName) || Strings.isNullOrEmpty(orgRoleName)) return null;
else return getOrgRole(orgs, orgName, orgRoleName, realm);
}

default OrganizationRoleModel getOrgRole(
OrganizationProvider orgs, String orgName, String orgRoleName, RealmModel realm) {
OrganizationModel org = orgs.getOrganizationByName(realm, orgName);
if (org == null) {
log.debugf("Cannot map non-existent org %s", orgName);
return null;
}
OrganizationRoleModel role = org.getRoleByName(orgRoleName);
if (role == null) {
log.debugf("Cannot map non-existent org role %s - %s", orgName, orgRoleName);
return null;
}
return role;
}

default void grantOrgRole(OrganizationModel org, OrganizationRoleModel role, UserModel user) {
if (org.hasMembership(user)) {
role.grantRole(user);
}
}

default void revokeOrgRole(OrganizationModel org, OrganizationRoleModel role, UserModel user) {
if (org.hasMembership(user)) {
role.revokeRole(user);
}
}

default void grantOrgRole(
KeycloakSession session,
RealmModel realm,
UserModel user,
IdentityProviderMapperModel mapperModel) {
OrganizationProvider orgs = session.getProvider(OrganizationProvider.class);
String orgName = mapperModel.getConfig().getOrDefault(ORG_PROPERTY_NAME, null);
String orgRoleName = mapperModel.getConfig().getOrDefault(ORG_ROLE_PROPERTY_NAME, null);
if (Strings.isNullOrEmpty(orgName) || Strings.isNullOrEmpty(orgRoleName)) return;

OrganizationModel org = orgs.getOrganizationByName(realm, orgName);
OrganizationRoleModel role = getOrgRole(orgs, orgName, orgRoleName, realm);
if (org != null && role != null) {
if (orgAdd(mapperModel)) {
if (!org.hasMembership(user)) {
log.infof("Granting org: %s membership to %s", orgName, user.getUsername());
org.grantMembership(user);
}
}
if (org.hasMembership(user)) {
log.infof("Granting org: %s - role: %s to %s", orgName, orgRoleName, user.getUsername());
role.grantRole(user);
}
}
}
}
Loading

0 comments on commit 9ef07a2

Please sign in to comment.