Generic Business Rules for Job Position Assignment

Hello everyone,

I am working with OpenIAM 4.1.2.14 and I am looking for guidance on the best practice for managing a large number of job positions (more than 200).

My current design is:

Job Position (Role Parent)
→ Business Role
→ Provisioning Profile
→ Technical Permissions / Groups

For example:

RRHH ANALYST
→ Second Line Role
→ Second Line Profile
→ AD Groups / Technical Entitlements

Currently, for each job position I have to create:

One Business Rule with “Add User to Role” + EvaluateObjectsToUserBasedOnRole.groovy
One Business Rule with “Remove User from Role” + RevokeObjectsToUserBasedOnRole.groovy

This means that with more than 200 job positions I would need to maintain more than 400 Business Rules.

My question is:

Is this the recommended approach in OpenIAM?

Or is there a way to create only two generic Business Rules (one for add and one for remove) and dynamically determine the role based on the user’s Title or Job Position?

In my environment, all child roles, profiles, and technical permissions are already configured under the parent job position role. The Business Rule is only responsible for assigning or removing the parent job position role from the user.

Example:

User Title = “RRHH ANALYST”

The Business Rule would dynamically find the role with the same name and assign it, instead of having a dedicated “Add User to Role” Business Rule for every position.

I would appreciate any recommendations, examples, or best practices for handling a large number of job positions in OpenIAM.

Thank you.

@ameet_shah Could you please help me with this issue, or what would be the best way to set it up?

Hello @Dan,

Apologies for the delay in getting back to you. This is a great design challenge and one that comes up frequently at scale.

Before diving in, we want to confirm one assumption about your environment:

Are all your 200+ Job Position roles already children of a single parent role in OpenIAM?

JOB_POSITIONS (Parent)
├── RRHH ANALYST
├── IT MANAGER
└── FINANCE LEAD

If yes — your existing structure is already perfect. No hierarchy changes needed.


The Solution — 2 Generic Business Rules

Based on your description, here is the clean approach that eliminates all 400 rules.

How it works

Instead of one rule per position, you create 2 rules only — one ADD and one REMOVE — where the Groovy script dynamically finds the correct role from user.getTitle() at runtime.


Business Rule Configuration

Field Rule 1 (ADD) Rule 2 (REMOVE)
Condition TITLE IS NOT NULL (native, no Groovy needed) TITLE IS NULL
DO action type GROOVY GROOVY
DO action script AddJobPositionRole.groovy RemoveJobPositionRole.groovy
UNDO action type GROOVY GROOVY
UNDO action script RemoveJobPositionRole.groovy AddJobPositionRole.groovy

Important: Use native TITLE condition — no Groovy condition script needed. OpenIAM’s BusinessRuleDataExtractor handles TITLE natively with IS_NULL, EQUALS, EQUALS_IGNORE_CASE etc.


AddJobPositionRole.groovy

import org.openiam.base.ws.MatchType
import org.openiam.base.ws.SearchParam
import org.openiam.br.groovy.IActionExecutor
import org.openiam.common.beans.mq.RoleRabbitMQService
import org.openiam.idm.searchbeans.RoleSearchBean
import org.openiam.idm.srvc.role.dto.Role
import org.openiam.idm.srvc.user.dto.User
import org.apache.commons.collections.CollectionUtils
import org.apache.commons.lang3.StringUtils
import org.springframework.context.ApplicationContext

class AddJobPositionRole implements IActionExecutor {
    ApplicationContext context

    @Override
    void perform(User user) {
        String title = user.getTitle()
        if (StringUtils.isBlank(title)) return

        RoleRabbitMQService roleMQService = context.getBean(RoleRabbitMQService.class)
        RoleSearchBean searchBean = new RoleSearchBean()
        searchBean.setNameToken(
            new SearchParam(title.trim().toUpperCase(), MatchType.EXACT))
        List<Role> roles = roleMQService.findBeans(searchBean, null, 0, 1)

        if (CollectionUtils.isNotEmpty(roles)) {
            user.addRole(roles.get(0), Set.of("IS_CERTIFIED"), null, null, null)
        }
    }
}

RemoveJobPositionRole.groovy

import org.openiam.base.AttributeOperationEnum
import org.openiam.base.ws.MatchType
import org.openiam.base.ws.SearchParam
import org.openiam.br.groovy.IActionExecutor
import org.openiam.common.beans.mq.RoleRabbitMQService
import org.openiam.idm.searchbeans.RoleSearchBean
import org.openiam.idm.srvc.entitlements.EntitlementsCollection
import org.openiam.idm.srvc.role.dto.Role
import org.openiam.idm.srvc.user.dto.User
import org.openiam.idm.srvc.user.dto.UserToRoleMembershipXref
import org.apache.commons.collections.CollectionUtils
import org.springframework.context.ApplicationContext

class RemoveJobPositionRole implements IActionExecutor {
    ApplicationContext context

    // Name of parent role containing all Job Position roles as children
    private static final String JOB_POSITION_PARENT_ROLE = "Job Positions"

    @Override
    void perform(User user) {
        if (CollectionUtils.isEmpty(user.getRoles())) return

        RoleRabbitMQService roleMQService = context.getBean(RoleRabbitMQService.class)
        RoleSearchBean parentSearch = new RoleSearchBean()
        parentSearch.setNameToken(
            new SearchParam(JOB_POSITION_PARENT_ROLE, MatchType.EXACT))
        List<Role> parents = roleMQService.findBeans(
            parentSearch, 
            [EntitlementsCollection.ROLES] as EntitlementsCollection[], 
            0, 1)

        if (CollectionUtils.isEmpty(parents)) return

        // Collect all child job-position role IDs dynamically
        Role parent = parents.get(0)
        Set<String> jobPositionRoleIds = parent.getRoles()
            ?.collect { it.getId() }
            ?.toSet() ?: []

        if (jobPositionRoleIds.isEmpty()) return

        // Mark matching roles for deletion
        for (UserToRoleMembershipXref xref : user.getRoles()) {
            if (xref.getOperation() != AttributeOperationEnum.DELETE
                    && jobPositionRoleIds.contains(xref.getEntityId())) {
                xref.setOperation(AttributeOperationEnum.DELETE)
            }
        }
    }
}

Why This Works

ADD script — looks up role by user.getTitle() at runtime using RoleSearchBean. Zero hardcoding.

REMOVE script — dynamically fetches all children of your Job Positions parent role at runtime, then marks any matching role on the user for deletion. Zero hardcoding.

Assumptions

Assumption Action needed
All 200+ roles are direct children of one parent role Confirm parent role name matches "Job Positions" in script — or update the constant
Role names match title values case-insensitively Script uses toUpperCase() + EXACT match — ensure role names are also uppercase
1 Like

Hello @ameet_shah
Thank you for taking the time to review my question and provide your feedback.
I’ve been testing a generic script as a business rule to cover scenarios where a user is onboarded, changes job position, or no longer has an assigned position.
Do you think it’s necessary for the script to explicitly handle all these cases, or does OpenIAM already cover them with the script you previously shared?

At the moment, the job role configuration is as follows:

Analista IT (Job Title)

→ Admin Role (Managed System A)
→ Analyst Role (Managed System B)

Please note that the target systems (platforms) do not have out-of-the-box connectors, so a custom connector was developed using microservices.
Additionally, we are evaluating how to handle the access reconciliation process so that it runs once per day.

The script I have been using is as follows:

import org.openiam.br.groovy.IActionExecutor
import org.openiam.base.AttributeOperationEnum
import org.openiam.common.beans.mq.RoleRabbitMQService
import org.openiam.common.beans.mq.GroupRabbitMQService
import org.openiam.idm.searchbeans.RoleSearchBean
import org.openiam.idm.searchbeans.GroupSearchBean
import org.openiam.idm.srvc.entitlements.EntitlementsCollection
import org.openiam.idm.srvc.grp.dto.Group
import org.openiam.idm.srvc.role.dto.Role
import org.openiam.idm.srvc.user.dto.User
import org.openiam.idm.srvc.user.dto.UserStatusEnum
import org.openiam.idm.srvc.membership.dto.MembershipXref
import org.openiam.idm.srvc.user.dto.UserToRoleMembershipXref
import org.openiam.idm.srvc.user.dto.UserToGroupMembershipXref
import org.apache.commons.collections.CollectionUtils
import org.springframework.context.ApplicationContext

class RecalculateAccessByCargo implements IActionExecutor {

ApplicationContext context

private static final Set<String> EXCLUDED_ROLE_IDS = new HashSet<String>()
private static final Set<String> EXCLUDED_GROUP_IDS = new HashSet<String>()

@Override
void perform(User user) {

    println "========== RecalculateAccessByCargo START =========="

    if (user == null) {
        println "User is null. Skip."
        return
    }

    if (user.getStatus() == UserStatusEnum.TERMINATED ||
        user.getStatus() == UserStatusEnum.DISABLED) {

        println "User terminated/disabled. Removing all roles/groups."
        endDateAllRoles(user)
        endDateAllGroups(user)
        return
    }

    String cargoName = user.getTitle()?.trim()

    println "Charge received from user.title: ${cargoName}"

    if (!cargoName) {
        println "user.title empty. Cannot be recalculated."
        return
    }

    Role cargoRole = fetchCargoRoleByName(cargoName)

    if (cargoRole == null) {
        println "No parent role with name was found: ${cargoName}"
        return
    }

    Set<String> expectedRoleIds = new LinkedHashSet<String>()
    Set<String> certifiedRoleIds = new LinkedHashSet<String>()
    Set<String> expectedGroupIds = new LinkedHashSet<String>()

    buildExpectedAccessFromCargo(cargoRole, expectedRoleIds, certifiedRoleIds, expectedGroupIds)

    println "Expected roles: ${expectedRoleIds}"
    println "Expected groups: ${expectedGroupIds}"

    reconcileRoles(user, expectedRoleIds)
    reconcileGroups(user, expectedGroupIds)

    addMissingRoles(user, expectedRoleIds, certifiedRoleIds)
    addMissingGroups(user, expectedGroupIds)

    println "========== RecalculateAccessByCargo END =========="
}

private void buildExpectedAccessFromCargo(Role cargoRole,
                                          Set<String> expectedRoleIds,
                                          Set<String> certifiedRoleIds,
                                          Set<String> expectedGroupIds) {

    /*
     * Add the parent position
     */
    expectedRoleIds.add(cargoRole.getId())
    certifiedRoleIds.add(cargoRole.getId())

    /*
     * If the position had direct reports
     */
    addGroupsFromRole(cargoRole, expectedGroupIds)

    if (CollectionUtils.isEmpty(cargoRole.getChildRoles())) {
        return
    }

    for (MembershipXref roleXref : cargoRole.getChildRoles()) {

        Role childRole = fetchRoleById(roleXref.getEntityId())

        if (childRole == null) {
            continue
        }

        /*
         * Direct son role of the position
         */
        expectedRoleIds.add(childRole.getId())
        certifiedRoleIds.add(childRole.getId())

        addGroupsFromRole(childRole, expectedGroupIds)

        if (CollectionUtils.isNotEmpty(childRole.getChildRoles())) {

            for (MembershipXref perfilXref : childRole.getChildRoles()) {

                Role perfilRole = fetchRoleById(perfilXref.getEntityId())

                if (perfilRole == null) {
                    continue
                }

                /*
                 * Perfil
                 */
                expectedRoleIds.add(perfilRole.getId())

                addGroupsFromRole(perfilRole, expectedGroupIds)
            }
        }
    }
}

private void addGroupsFromRole(Role role, Set<String> expectedGroupIds) {

    if (role == null || CollectionUtils.isEmpty(role.getGroups())) {
        return
    }

    for (MembershipXref groupXref : role.getGroups()) {
        if (groupXref.getEntityId() != null) {
            expectedGroupIds.add(groupXref.getEntityId())
        }
    }
}

private void reconcileRoles(User user, Set<String> expectedRoleIds) {

    if (CollectionUtils.isEmpty(user.getRoles())) {
        return
    }

    for (UserToRoleMembershipXref existingRole : user.getRoles()) {

        String roleId = existingRole.getEntityId()

        if (!roleId) {
            continue
        }

        if (existingRole.getOperation() == AttributeOperationEnum.DELETE) {
            continue
        }

        if (EXCLUDED_ROLE_IDS.contains(roleId)) {
            println "Role excluido, no se toca: ${roleId}"
            continue
        }

        if (!expectedRoleIds.contains(roleId)) {
            existingRole.setEndDate(new Date())
            existingRole.setOperation(AttributeOperationEnum.DELETE)
            println "Role removed because it does not correspond to the current position: ${roleId}"
        } else {
            println "Role is maintained: ${roleId}"
        }
    }
}

private void reconcileGroups(User user, Set<String> expectedGroupIds) {

    if (CollectionUtils.isEmpty(user.getGroups())) {
        return
    }

    for (UserToGroupMembershipXref existingGroup : user.getGroups()) {

        String groupId = existingGroup.getEntityId()

        if (!groupId) {
            continue
        }

        if (existingGroup.getOperation() == AttributeOperationEnum.DELETE) {
            continue
        }

        if (EXCLUDED_GROUP_IDS.contains(groupId)) {
            println "Excluded group, untouchable: ${groupId}"
            continue
        }

        if (!expectedGroupIds.contains(groupId)) {
            existingGroup.setEndDate(new Date())
            existingGroup.setOperation(AttributeOperationEnum.DELETE)
            println "Group removed because it does not correspond to the current position: ${groupId}"
        } else {
            println "Group remains: ${groupId}"
        }
    }
}

private void addMissingRoles(User user,
                             Set<String> expectedRoleIds,
                             Set<String> certifiedRoleIds) {

    for (String roleId : expectedRoleIds) {

        if (hasActiveRole(user, roleId)) {
            continue
        }

        Role role = fetchRoleById(roleId)

        if (role == null) {
            continue
        }

        Boolean certified = certifiedRoleIds.contains(roleId)

        addRole(user, role, certified)
    }
}

private void addMissingGroups(User user, Set<String> expectedGroupIds) {

    for (String groupId : expectedGroupIds) {

        if (hasActiveGroup(user, groupId)) {
            continue
        }

        Group group = fetchGroupById(groupId)

        if (group == null) {
            continue
        }

        addGroup(user, group)
    }
}

private Role fetchCargoRoleByName(String cargoName) {

    println "Looking for a job title by exact job title.: ${cargoName}"

    RoleRabbitMQService roleMQService =
        context.getBean(RoleRabbitMQService.class) as RoleRabbitMQService

    RoleSearchBean searchBean = new RoleSearchBean()
    searchBean.setDeepCopy(false)

    EntitlementsCollection[] collections = [
        EntitlementsCollection.CHILDRENS,
        EntitlementsCollection.GROUPS
    ]

    List<Role> roles =
        roleMQService.findBeans(searchBean, collections, 0, Integer.MAX_VALUE)

    if (CollectionUtils.isEmpty(roles)) {
        println "No se encontraron roles."
        return null
    }

    for (Role role : roles) {

        if (role.getName() != null &&
            role.getName().trim().equalsIgnoreCase(cargoName.trim())) {

            println "Role found: ${role.getName()}"
            return fetchRoleById(role.getId())
        }
    }

    println "Role with name not found: ${cargoName}"
    return null
}

private Role fetchRoleById(String roleId) {

    if (!roleId) {
        return null
    }

    RoleRabbitMQService roleMQService =
        context.getBean(RoleRabbitMQService.class) as RoleRabbitMQService

    RoleSearchBean searchBean = new RoleSearchBean()
    searchBean.setDeepCopy(false)
    searchBean.setKeySet(List.of(roleId))

    EntitlementsCollection[] collections = [
        EntitlementsCollection.CHILDRENS,
        EntitlementsCollection.GROUPS
    ]

    List<Role> roles =
        roleMQService.findBeans(searchBean, collections, 0, 1)

    if (CollectionUtils.isNotEmpty(roles)) {
        return roles.get(0)
    }

    return null
}

private Group fetchGroupById(String groupId) {

    if (!groupId) {
        return null
    }

    GroupRabbitMQService groupMQService =
        context.getBean(GroupRabbitMQService.class) as GroupRabbitMQService

    GroupSearchBean searchBean = new GroupSearchBean()
    searchBean.setKeySet(List.of(groupId))

    List<Group> groups =
        groupMQService.findBeans(searchBean, 0, 1)

    if (CollectionUtils.isNotEmpty(groups)) {
        return groups.get(0)
    }

    return null
}

private void addRole(User user, Role role, Boolean certified) {

    if (role == null) {
        return
    }

    Date startDate = getStartDate(user)

    if (certified) {
        user.addRole(role, Set.of("IS_CERTIFIED"), startDate, null)
    } else {
        user.addRole(role, null, startDate, null)
    }

    println "Role agregado: ${role.getName()}"
}

private void addGroup(User user, Group group) {

    if (group == null) {
        return
    }

    Date startDate = getStartDate(user)
    user.addGroup(group, null, startDate, null)

    println "Group agregado: ${group.getName()}"
}

private boolean hasActiveRole(User user, String roleId) {

    if (CollectionUtils.isEmpty(user.getRoles())) {
        return false
    }

    for (UserToRoleMembershipXref existingRole : user.getRoles()) {

        if (existingRole.getEntityId() != null &&
            existingRole.getEntityId().equals(roleId) &&
            existingRole.getOperation() != AttributeOperationEnum.DELETE) {

            return true
        }
    }

    return false
}

private boolean hasActiveGroup(User user, String groupId) {

    if (CollectionUtils.isEmpty(user.getGroups())) {
        return false
    }

    for (UserToGroupMembershipXref existingGroup : user.getGroups()) {

        if (existingGroup.getEntityId() != null &&
            existingGroup.getEntityId().equals(groupId) &&
            existingGroup.getOperation() != AttributeOperationEnum.DELETE) {

            return true
        }
    }

    return false
}

private Date getStartDate(User user) {

    if (user.getStartDate() != null && user.getStartDate().after(new Date())) {
        return user.getStartDate()
    }

    return new Date()
}

private void endDateAllRoles(User user) {

    if (CollectionUtils.isEmpty(user.getRoles())) {
        return
    }

    for (UserToRoleMembershipXref existingRole : user.getRoles()) {
        existingRole.setEndDate(new Date())
        existingRole.setOperation(AttributeOperationEnum.DELETE)
    }
}

private void endDateAllGroups(User user) {

    if (CollectionUtils.isEmpty(user.getGroups())) {
        return
    }

    for (UserToGroupMembershipXref existingGroup : user.getGroups()) {
        existingGroup.setEndDate(new Date())
        existingGroup.setOperation(AttributeOperationEnum.DELETE)
    }
}

}

Hello @Dan,

Apologies for the delay in getting back to you.

Right now, your script does not fully cover the third scenario — the OpenIAM entitlement engine doesn’t automatically infer “no assigned position” for you, so the script has to handle it explicitly, and today it doesn’t.

Specifically: when user.getTitle() is blank, or when the title doesn’t match any Role name, the script currently just logs a message and returns. It never reaches reconcileRoles/reconcileGroups, so a user who loses their position keeps every role and group from their last title indefinitely. Onboarding and job-change (title A → title B) are both handled correctly by the reconcile-then-add logic.

Fix: route both of those early-return branches through the same reconciliation call you already use for termination, but against an empty expected-role/group set — that will end-date everything not explicitly excluded, same as the rest of the script already does for TERMINATED/DISABLED.

Two other things worth fixing before this runs against your full population:

  • fetchCargoRoleByName currently pulls every role in the system (findBeans with Integer.MAX_VALUE) and filters by name in Groovy instead of at the query level. Use RoleSearchBean.setNameToken(new SearchParam(cargoName, MatchType.EXACT)) so the DB does the filtering — this also removes the risk of grabbing the wrong role if two roles ever share a name.

  • The script calls out to the role service once per child role and once per grandchild role (N+1 remote calls). For a daily reconciliation run across your whole user base this adds up fast and is the kind of pattern that tends to produce timeouts under load — worth batching the role tree fetch if reconciliation is going to run once a day as planned.

We have put together an updated version of the script below with these fixes applied — blank/unmatched title now reconciles against an empty access set instead of skipping, role/group lookups are batched, exclusion lists are respected during termination too, and the remote calls are wrapped in try/catch so a transient timeout doesn’t abort the whole user save.

RecalculateAccess.groovy (15.6 KB)

Thanks,

Ameet