Commit c6d0977b authored by Brian Long's avatar Brian Long
Browse files

Merge branch 'develop' into stable

parents 2405a8a3 a55d1c32
Loading
Loading
Loading
Loading

README.md

0 → 100644
+67 −0
Original line number Diff line number Diff line
# Keycloak Extension for Activiti

This library was created to expand the functionality of keycloak integration within the APS (Activiti App) application.  It includes a similar implementation for core Activiti (Activiti Engine), but the core functional is not delivered with that OOTB application at this time.

The Activiti App delivers SSO capability and that is about it.  The user must already exist and group synchronization may only happen outside of the context of authentication.  Namely over another protocol (LDAP).

This module expands SSO to include user creation and group synchronization.  Group synchronization uses the standard access token for Open ID Connect.  These groups are termed "roles".

## Installation

The installation is simple.  Just include the JAR in the classpath of your Activiti App application.  This is best done by not chaning the `activiti-app.war` file, but instead including it within the classpath using your web container configuration.  For Apache Tomcat, you would add or modify the following context file: `conf/Catalina/localhost/activiti-app.xml`.  Its related contents would be:

```xml
<Context>
        <Resources>
                <PostResources base="${catalina.base}/ext" className="org.apache.catalina.webresources.DirResourceSet" webAppMount="/WEB-INF/lib" readOnly="true" />
        </Resources>
</Context>
```

Notice the use of `PostResources` instead of `PreResources`.  This library needs to be loaded after the web application.  This is the best way to load any other extensions or customization to the Activiti App, including `JavaDelegate` implementations.

## Configuration

The library is highly configurable.  You configure it with properties specified in the `activiti-app.properties` file, which exists somewhere in the root of the classpath.  That is typically in the `lib` folder.  The properties to configure are enumerated in the table below.

### Common

| Property                                       | Default   | Description |
| ---------------------------------------------- | --------- | ----------- |
| `keycloak-ext.ais.enabled`                     | `false`   | Enable AIS integration, overriding and extending the OOTB AIS provider. |
| `keycloak-ext.ootbSecurityConfig.enabled`      | `true`    | Enable OOTB functionality as if this module were not installed.  This adapter operates at priority `0`.  This means it only works if other adapters are disabled (default). |
| `keycloak-ext.default.admins.users`            |           | A default set of administrators to add to the administration role on application startup. |
| `keycloak-ext.clearNewUserDefaultGroups`       | `true`    | When creating a new user, clear any default groups added to that user.  This will not impact existing users. |
| `keycloak-ext.resource.include.regex.patterns` |           | OIDC provides roles in the realm and all permitted clients/resources.  By default all resources are included.  You can limit it with regular expressions with this property. |
| `keycloak-ext.group.format.regex.patterns`     |           | Reformat roles that match the specified regular expressions.  The replacements are specified in another property.  Multiple expressions may be specified by using commas.  Whitespace is not stripped. |
| `keycloak-ext.group.format.regex.replacements` |           | Reformat roles with the specified replacement expressions.  The regular expressions are specified in another property.  Multiple expressions may be specified by using commas.  Whitespace is not stripped. |
| `keycloak-ext.group.include.regex.patterns`    |           | If specified, only the roles that match the specified regular expressions will be considered; otherwise all roles are included. |
| `keycloak-ext.group.exclude.regex.patterns`    |           | If specified, the roles that match the specified regular expressions will be ignored.  This overrides any role explicitly included. |
| `keycloak-ext.syncInternalGroup`               | `false`   | If an internal group with the same name already exists, use that group instead of creating a new one with the same name.  Also register that internal group as external. |

### For Activiti App Only

| Property                                  | Default        | Description |
| ----------------------------------------- | -------------- | ----------- |
| `keycloak-ext.syncGroupAs`                | `organization` | When creating a new group, should it be a functional (`organization`) group or a system (`capability`) group? |
| `keycloak-ext.external.id`                | `ais`            | When creating a new group or registering an internal group as external, use this ID as a prefix to the external group ID. |

### Rare

| Property                                  | Default         | Description |
| ----------------------------------------- | --------------- | ----------- |
| `keycloak-ext.ais.priority`               | `-10`           | The order of configurable adapters to use with the application.  Only the lowest priority enabled adapter will be used.  Values of `1`+ will only load if the OOTB adapter is disabled. |
| `keycloak-ext.group.admins.validate`      | `false`         | Whether or not to validate the existence and capabilities of an administrators group on appliation startup.  This is only applicable for when one is accidently removed and no one has the rights to create one. |
| `keycloak-ext.group.admins.name`          | `admins`        | The name of an administrators group to potentially add and default users on application startup. |
| `keycloak-ext.group.admins.externalId`    | `admins`        | The name of an administrators group to potentially add and default users on application startup. |
| `keycloak-ext.createMissingUser`          | `true`          | Before authentication, check to make sure the user exists as an APS user; if they don't, create the user. |
| `keycloak-ext.createMissingGroup`         | `true`          | Before authorization, check to make sure groups exist for the roles the user claims; if they don't, create the groups. |
| `keycloak-ext.syncGroupAdd`               | `true`          | If the user belongs to a role but not its corresponding group, add the user to the group. |
| `keycloak-ext.syncGroupRemove`            | `true`          | If the user belongs to a group but does not have the corresponding role, remove the user from the group. |

### Untested

| Property                                  | Default         | Description |
| ----------------------------------------- | --------------- | ----------- |
| `keycloak-ext.keycloak.enabled`           | `false`         | Enable Keycloak integration, overriding and extending the OOTB Keycloak provider (*untested*). |
| `keycloak-ext.keycloak.priority`          | `-5`            | The order of configurable adapters to use with the application.  Only the lowest priority enabled adapter will be used.  Values of `1`+ will only load if the OOTB adapter is disabled. |
+14 −5
Original line number Diff line number Diff line
@@ -78,12 +78,21 @@
			<id>activiti-releases</id>
			<url>https://artifacts.alfresco.com/nexus/content/repositories/activiti-enterprise-releases</url>
		</repository>
		<repository>
			<id>inteligr8-releases</id>
			<url>https://repos.inteligr8.com/nexus/repository/inteligr8-private</url>
		</repository>
	</repositories>
	
	<pluginRepositories>
		<pluginRepository>
	<distributionManagement>
		<repository>
			<id>inteligr8-releases</id>
			<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
		</pluginRepository>
	</pluginRepositories>
			<url>https://repos.inteligr8.com/nexus/repository/inteligr8-private</url>
		</repository>
		<snapshotRepository>
			<id>inteligr8-snapshots</id>
			<url>https://repos.inteligr8.com/nexus/repository/inteligr8-snapshots</url>
		</snapshotRepository>
	</distributionManagement>
	
</project>
 No newline at end of file
+37 −8
Original line number Diff line number Diff line
@@ -5,7 +5,9 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -17,6 +19,7 @@ import org.springframework.stereotype.Component;

import com.activiti.api.security.AlfrescoSecurityConfigOverride;
import com.activiti.domain.idm.Group;
import com.activiti.domain.idm.GroupCapability;
import com.activiti.domain.idm.Tenant;
import com.activiti.domain.idm.User;
import com.activiti.service.api.GroupService;
@@ -40,6 +43,15 @@ public class Inteligr8SecurityConfigurationRegistry implements AlfrescoSecurityC
	
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    
    private final List<String> adminCapabilities = Arrays.asList(
    		"access-all-models-in-tenant",
    		"access-editor",
    		"access-reports",
    		"publish-app-to-dashboard",
    		"tenant-admin",
    		"tenant-admin-api",
    		"upload-license");
    
    @Autowired
    private List<ActivitiSecurityConfigAdapter> adapters;
    
@@ -61,7 +73,7 @@ public class Inteligr8SecurityConfigurationRegistry implements AlfrescoSecurityC
    @Value("${keycloak-ext.group.admins.name:admins}")
    private String adminGroupName;
    
    @Value("${keycloak-ext.group.admins.externalId:aps-admin}")
    @Value("${keycloak-ext.group.admins.externalId:#{null}}")
    private String adminGroupExternalId;
    
    @Value("${keycloak-ext.group.admins.validate:false}")
@@ -108,15 +120,32 @@ public class Inteligr8SecurityConfigurationRegistry implements AlfrescoSecurityC
			return;
		
    	Long tenantId = this.findDefaultTenantId();
		Group group = this.groupService.getGroupByExternalId(this.adminGroupExternalId);
		Group group = this.groupService.getGroupByExternalIdAndTenantId(this.adminGroupExternalId, tenantId);
		if (group == null) {
			List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.adminGroupName, tenantId);
			if (!groups.isEmpty())
				group = groups.iterator().next();
		}

		if (group == null) {
			this.logger.info("Creating '{}' group ...", this.adminGroupName);
			this.logger.info("Creating group: {} ({})", this.adminGroupName, this.adminGroupExternalId);
			if (this.adminGroupExternalId != null) {
				group = this.groupService.createGroupFromExternalStore(
						this.adminGroupExternalId, tenantId, Group.TYPE_SYSTEM_GROUP, null, this.adminGroupName, new Date());
			} else {
				group = this.groupService.createGroup(this.adminGroupName, tenantId, Group.TYPE_SYSTEM_GROUP, null);
			}
		}

		this.logger.info("Granting '{}' group all capabilities ...", group.getName());
		this.groupService.addCapabilitiesToGroup(group.getId(), Arrays.asList("access-all-models-in-tenant", "access-editor", "access-reports", "publish-app-to-dashboard", "tenant-admin", "tenant-admin-api", "upload-license"));
		this.logger.debug("Checking group capabilities: {}", group.getName());
		Group groupWithCaps = this.groupService.getGroup(group.getId(), false, true, false, false);
		Set<String> adminCaps = new HashSet<>(this.adminCapabilities);
		for (GroupCapability cap : groupWithCaps.getCapabilities())
			adminCaps.remove(cap.getName());
		if (!adminCaps.isEmpty()) {
			this.logger.info("Granting group '{}' capabilities: {}", group.getName(), adminCaps);
			this.groupService.addCapabilitiesToGroup(group.getId(), new ArrayList<>(adminCaps));
		}
	}
	
	private void associateAdmins() {
+25 −3
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ package com.inteligr8.activiti.keycloak;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@@ -34,8 +35,8 @@ public abstract class AbstractKeycloakActivitiAuthenticator implements Authentic
    @Value("${keycloak-ext.createMissingUser:true}")
    protected boolean createMissingUser;

    @Value("${keycloak-ext.clearNewUserGroups:true}")
    protected boolean clearNewUserGroups;
    @Value("${keycloak-ext.clearNewUserDefaultGroups:true}")
    protected boolean clearNewUserDefaultGroups;

    @Value("${keycloak-ext.createMissingGroup:true}")
    protected boolean createMissingGroup;
@@ -46,6 +47,9 @@ public abstract class AbstractKeycloakActivitiAuthenticator implements Authentic
    @Value("${keycloak-ext.syncGroupRemove:true}")
    protected boolean syncGroupRemove;

    @Value("${keycloak-ext.syncInternalGroups:false}")
    protected boolean syncInternalGroups;
    
    @Value("${keycloak-ext.resource.include.regex.patterns:#{null}}")
    protected String resourceRegexIncludes;
    
@@ -99,7 +103,7 @@ public abstract class AbstractKeycloakActivitiAuthenticator implements Authentic
    

    
    protected Map<String, String> getRoles(Authentication auth) {
    protected Map<String, String> getKeycloakRoles(Authentication auth) {
    	Map<String, String> authorities = new HashMap<>();
		
		AccessToken atoken = this.getKeycloakAccessToken(auth);
@@ -234,6 +238,24 @@ public abstract class AbstractKeycloakActivitiAuthenticator implements Authentic
		}
    }
    
    protected <K, V> boolean removeMapEntriesByValue(Map<K, V> map, V value) {
    	if (value == null)
    		throw new IllegalArgumentException();
    	
    	int found = 0;
    	
    	Iterator<Entry<K, V>> i = map.entrySet().iterator();
    	while (i.hasNext()) {
    		Entry<K, V> entry = i.next();
    		if (entry.getValue() != null && value.equals(entry.getValue())) {
    			i.remove();
    			found++;
    		}
    	}
    	
    	return found > 0;
    }
    
    protected Set<String> toSet(Collection<? extends GrantedAuthority> grantedAuthorities) {
    	Set<String> authorities = new HashSet<>(Math.max(grantedAuthorities.size(), 16));
    	for (GrantedAuthority grantedAuthority : grantedAuthorities) {
+78 −41
Original line number Diff line number Diff line
package com.inteligr8.activiti.keycloak;

import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
@@ -15,6 +14,7 @@ import org.keycloak.representations.AccessToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
@@ -48,7 +48,6 @@ public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAu
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    
    private final Pattern emailNamesPattern = Pattern.compile("([A-Za-z]+)[A-Za-z0-9]*\\.([A-Za-z]+)[A-Za-z0-9]*@.*");
    private final String externalIdmSource = "ais";

    @Autowired
    private LicenseService licenseService;
@@ -62,6 +61,20 @@ public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAu
    @Autowired
    private GroupService groupService;
    
    @Value("${keycloak-ext.external.id:ais}")
    protected String externalIdmSource;

    @Value("${keycloak-ext.syncGroupAs:organization}")
    protected String syncGroupAs;
    
    protected boolean syncGroupAsOrganization() {
    	return !this.syncGroupAsCapability();
    }
    
    protected boolean syncGroupAsCapability() {
    	return this.syncGroupAs != null && this.syncGroupAs.toLowerCase().startsWith("cap");
    }
    
    /**
     * This method validates that the user exists, if not, it creates the
     * missing user.  Without this functionality, SSO straight up fails in APS.
@@ -79,16 +92,26 @@ public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAu
	    		user = this.createUser(auth, tenantId);
	    		this.logger.debug("Created user: {} => {}", user.getId(), user.getExternalId());
	    		
	    		if (this.clearNewUserGroups) {
	    		if (this.clearNewUserDefaultGroups) {
		    		this.logger.debug("Clearing groups: {}", user.getId());
	    			// fetch and remove default groups
	    			user = this.userService.findUserByEmailFetchGroups(user.getEmail());
	    			user = this.userService.getUser(user.getId(), true);
	    			for (Group group : user.getGroups())
	    				this.groupService.deleteUserFromGroup(group, user);
	    		}
    		} else {
    			this.logger.info("User does not exist; user creation is disabled: {}", auth.getName());
    		}
    	} else if (user.getExternalOriginalSrc() == null || user.getExternalOriginalSrc().length() == 0) {
    		this.logger.debug("User exists, but not created by an external source: {}", auth.getName());
    		this.logger.info("Linking user '{}' with external IDM '{}'", auth.getName(), this.externalIdmSource);
    		user.setExternalId(auth.getName());
    		user.setExternalOriginalSrc(this.externalIdmSource);
    		this.userService.save(user);
    	} else if (!this.externalIdmSource.equals(user.getExternalOriginalSrc())) {
    		this.logger.debug("User '{}' exists, but created by another source: {}", auth.getName(), user.getExternalOriginalSrc());
    	} else {
    		this.logger.trace("User already exists: {}", auth.getName());
    	}
    }
    
@@ -141,7 +164,7 @@ public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAu
    		Matcher emailNamesMatcher = this.emailNamesPattern.matcher(auth.getName());
    		if (!emailNamesMatcher.matches()) {
        		this.logger.warn("The email address could not be parsed for names: {}", auth.getName());
    			return this.userService.createNewUserFromExternalStore(auth.getName(), "Unknown", "User", tenantId, auth.getName(), this.externalIdmSource, new Date());
    			return this.userService.createNewUserFromExternalStore(auth.getName(), "Unknown", "Person", tenantId, auth.getName(), this.externalIdmSource, new Date());
    		} else {
    			String firstName = StringUtils.capitalize(emailNamesMatcher.group(1));
    			String lastName = StringUtils.capitalize(emailNamesMatcher.group(2));
@@ -153,22 +176,28 @@ public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAu
    }

    private void syncUserRoles(User user, Authentication auth, Long tenantId) {
    	Map<String, String> roles = this.getRoles(auth);
    	Map<String, String> roles = this.getKeycloakRoles(auth);
    	if (roles == null) {
    		this.logger.debug("The user roles could not be determined; skipping sync: {}", user.getEmail());
    		return;
    	}
    	
    	boolean syncAsOrg = this.syncGroupAsOrganization();
    	
		// check Activiti groups
		User userWithGroups = this.userService.findUserByEmailFetchGroups(user.getEmail());
		User userWithGroups = this.userService.getUser(user.getId(), true);
		for (Group group : userWithGroups.getGroups()) {
			if (group.getExternalId() == null && !this.syncInternalGroups)
				continue;
			
			this.logger.trace("Inspecting group: {} => {} ({})", group.getId(), group.getName(), group.getExternalId());
			
			if (group.getExternalId() == null) {
				// skip APS system groups
			} else if (roles.remove(group.getExternalId()) != null) {
				// all good
			if (group.getExternalId() != null && this.removeMapEntriesByValue(roles, this.apsGroupExternalIdToKeycloakRole(group.getExternalId()))) {
				// role already existed and the user is already a member
			} else if (group.getExternalId() == null && roles.remove(this.apsGroupNameToKeycloakRole(group.getName())) != null) {
				// internal role already existed and the user is already a member
			} else {
				// at this point, we have a group that the user does not have a corresponding role for
				if (this.syncGroupRemove) {
					this.logger.trace("Removing user '{}' from group '{}'", user.getExternalId(), group.getName());
					this.groupService.deleteUserFromGroup(group, userWithGroups);
@@ -184,20 +213,35 @@ public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAu
			
			Group group;
			try {
				group = this.groupService.getGroupByExternalId(role.getKey());
				group = this.groupService.getGroupByExternalIdAndTenantId(this.keycloakRoleToApsGroupExternalId(role.getKey()), tenantId);
			} catch (NonUniqueResultException nure) {
				if (this.logger.isDebugEnabled()) {
					// FIXME only added to address a former bug
					group = this.fixMultipleGroups(role.getKey(), tenantId);
				} else {
					throw nure;
				this.logger.warn("There are multiple groups with the external ID; not adding user to group: {}", role.getKey());
				continue;
			}

			if (group == null && this.syncInternalGroups) {
				List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.keycloakRoleToApsGroupName(role.getValue()), tenantId);
				if (groups.size() > 1) {
					this.logger.warn("There are multiple groups with the same name; not adding user to group: {}", role.getValue());
					continue;
				} else if (groups.size() == 1) {
					group = groups.iterator().next();
					this.logger.debug("Found an internal group; registering as external: {}", group.getName());
					group.setExternalId(this.keycloakRoleToApsGroupExternalId(role.getKey()));
					group.setLastSyncTimeStamp(new Date());
					group.setLastUpdate(new Date());
					this.groupService.save(group);
				}
			}
			
			if (group == null) {
				if (this.createMissingGroup) {
					this.logger.trace("Creating new group: {}", role);
					group = this.groupService.createGroupFromExternalStore(role.getValue(), tenantId, Group.TYPE_SYSTEM_GROUP, null, role.getKey(), new Date());
					this.logger.trace("Creating new group for role: {}", role);
					String name = this.keycloakRoleToApsGroupName(role.getValue());
					String externalId = this.keycloakRoleToApsGroupExternalId(role.getKey());
					int type = syncAsOrg ? Group.TYPE_FUNCTIONAL_GROUP : Group.TYPE_SYSTEM_GROUP;
					this.logger.trace("Creating new group: {} ({}) [type: {}]", name, externalId, type);
					group = this.groupService.createGroupFromExternalStore(name, tenantId, type, null, externalId, new Date());
				} else {
	    			this.logger.debug("Group does not exist; group creation is disabled: {}", role);
				}
@@ -212,28 +256,21 @@ public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAu
		}
    }
    
    private Group fixMultipleGroups(String externalId, Long tenantId) {
    	List<Group> groupsToDelete = new LinkedList<>();
    	Date earliestDate = new Date();
    	Group earliestGroup = null;
    	
    	for (Group group : this.groupService.getSystemGroups(tenantId)) {
    		if (externalId.equals(group.getExternalId())) {
    			if (group.getLastUpdate().before(earliestDate)) {
    				if (earliestGroup != null)
    					groupsToDelete.add(earliestGroup);
    				earliestDate = group.getLastUpdate();
    				earliestGroup = group;
    			} else {
        			groupsToDelete.add(group);
    			}
    private String keycloakRoleToApsGroupExternalId(String role) {
    	return this.externalIdmSource + "_" + role;
    }
    
    private String apsGroupExternalIdToKeycloakRole(String externalId) {
    	int underscorePos = externalId.indexOf('_');
    	return underscorePos < 0 ? externalId : externalId.substring(underscorePos + 1);
    }
    
    	for (Group group : groupsToDelete)
    		this.groupService.deleteGroup(group.getId());
    private String keycloakRoleToApsGroupName(String role) {
    	return role;
    }
    
    	return earliestGroup;
    private String apsGroupNameToKeycloakRole(String externalId) {
    	return externalId;
    }
    
}
Loading