Runtime User Role Modifications

Sometimes a fixed role assignment is not good enough for our dynamic world; in those situations, the RoleContributor is for you...

Introduction

Liferay has, for a long time, supported RBAC, the role-based access control. It is, of course, backed by the database so (inheritance aside for the moment) a user will be assigned a list of roles that is mostly fixed or unchanging. To change these role assignments, typically an admin is going to log into Liferay and, using the control panel, add or remove roles as necessary.

This works well in a land where everything is neat and tidy and stable and not varied by external context. But here in the real world, we know that things are not neat, tidy, stable or free from external contextual influence.

The Use Cases

Sounds a little out there, right? Maybe some examples will help...

Sue is our normal Liferay administrator Monday through Friday, but on weekends Tom is the administrator. While we could just give them both the Administrator role and forget about it, from a security perspective this would not be as controlled as only giving them the Administrator role on days when they actually are the administrator.

Or consider shift work, where Bill approves workflows during the day, but Donna is taking over at night.

Yesterday Phil informed us he's going out on paternity leave for 6 weeks starting at the 1st of the month. During that time, Venkat will be taking over Phil's role as content publisher. I suppose could make a note to have the Liferay admin make the role changes on the 1st of the month and again six weeks later.

Mikayla is one of our content editors, but for security purposes the content editors should only be allowed to edit content when they are either on the internal network or coming through the VPN; if they're logging in from home, they should not be able to edit content. As a security feature, this will ensure that if someone is able to get Mikayla's credentials for her Liferay account, they won't be able to just log in and start editing content.

Some of these use cases we can solve by just giving out the roles to the users and trust they will use their access correctly. Some we can solve by having the Administrator log in at the appropriate time and make the changes. Some, like Mikayla's case, can't be solved by a fixed list of roles.

All of these cases could be solved however if we programmatically changed role assignments. Through code we could tell which day of the week it was and give Admin to Sue or Tom as appropriate. Through code we could handle the time of day aspect of shift work to give or take roles. Through code we could grant the content publisher role to Venkat starting at the 1st of the month and ending 6 weeks later. And through code we can solve the problem of taking away roles if the user is not connecting internally or via the VPN, something we can't do otherwise.

In order to handle these kinds of situations, the only avenue I could find to make this sort of thing doable is to code up a solution to add or remove a role dynamically via a post login hook, basically changing the list of assigned roles when the user logged in knowing that list of assigned roles would work for that login session, and the next time they logged in it could be changed to a different set, ... The idea was that for a specific login session, I knew what roles they would have assigned, but it was still primarily a fixed list of roles that didn't change during the session.

This wasn't a perfect solution, though, because the user may be logged in before the change is supposed to happen (i.e. Venkat logs in on the last day of the month) and stays logged in through the time the triggering event makes the change, but if they don't log out they wouldn't see the change.

So clearly something better was needed, and fortunately it was delivered to us starting in Liferay 7.2...

Role Contributors

That old way of handling role changes got tossed out the door as of Liferay 7.2, and it has been replaced with a new service interface, the com.liferay.portal.kernel.security.permission.contributor.RoleContributor. This interface is pretty short, so I'll just throw it in here:

/**
 * Invoked during permission checking, this interface dynamically alters roles
 * calculated from persisted assignment and inheritance. Implementations must
 * maximize efficiency to avoid potentially dramatic performance degredation.
 *
 * @author Raymond Augé
 */
public interface RoleContributor {
	public void contribute(RoleCollection roleCollection);
}

Like I said, pretty short right? The RoleCollection object is a bit longer, but I'm going to include it here anyway:

/**
 * Represents a managed collection of role IDs, starting with the
 * initial set calculated from persisted role assignment and role
 * inheritance. The roles can be contributed via {@link
 * RoleContributor#contribute(RoleCollection)}.
 *
 * @author Carlos Sierra Andrés
 * @author Raymond Augé
 */
@ProviderType
public interface RoleCollection {

	/**
	 * Adds the role ID to the collection.
	 *
	 * @param  roleId the ID of the role
	 * @return true if the role ID was added to the collection
	 */
	public boolean addRoleId(long roleId);

	/**
	 * Returns the primary key of the company whose permissions are being
	 * checked.
	 *
	 * @return the primary key of the company whose permissions are being
	 *         checked
	 */
	public long getCompanyId();

	/**
	 * Returns the primary key of the group whose permissions are being checked.
	 *
	 * @return the groupId of the Group currently being permission checked
	 */
	public long getGroupId();

	/**
	 * Returns the IDs of the initial set of roles calculated from persisted
	 * assignment and inheritance.
	 *
	 * @return the IDs of the initial set of roles calculated from persisted
	 *         assignment and inheritance
	 */
	public long[] getInitialRoleIds();

	public User getUser();

	public UserBag getUserBag();

	/**
	 * Returns true if the collection has the role ID.
	 *
	 * @param  roleId the ID of the role
	 * @return true if the collection has the role ID;
	 *         false otherwise
	 */
	public boolean hasRoleId(long roleId);

	/**
	 * Returns true if the user is signed in.
	 *
	 * @return true if the user is signed in; false
	 *         otherwise
	 */
	public boolean isSignedIn();

	public boolean removeRoleId(long roleId);
}

So the RoleContributor is the service interface, and you can create a class that implements the RoleContributor interface and register it as an @Component, and Liferay will call it as part of the normal permission checking activities.

That is an extreme over-simplification of what is actually going on behind the scenes, but there are some important things to keep in mind...

The RoleContributors will only be called once per thread (request), regardless of how many actual permission checks are performed to service the request. Liferay will invoke all of the RoleContributors in each thread, so avoid doing crazy stuff lest you severely and negatively effect response time performance.

The only context details available are going to be the same details available normally to the PermissionCheckers; that means user, group, company plus you get the list of roles that come from the persisted or inherited sources (minus any adjustments made by other RoleContributor instances) and the current list of role ids (includes persisted and inherited roles as well as changes made by other RoleContributors). Other context details you might need may have to come from an external context source such as a ThreadLocal or some other static provider.

The operations available to you on the RoleCollection allow you to add a role or remove a role, plus you can test if the user has a role (includes the persisted roles, the inherited roles, plus any additions made by other RoleContributors). The operations seem simple, but they can have a significant impact if you're not careful. Imagine a RoleContributor that all it does is invoke the removeRoleId() for the Administrator role indiscriminately; no one would effectively have the Administrator role on the platform. Even worse, imagine a RoleContributor that adds the Administrator role to everybody!

Single RoleContributor implementations do not need to do everything; Liferay will invoke all registered implementations and they each individually can add/remove role ids as necessary (you can control ordering by using the service ranking property). So lean towards making separate, simple RoleContributor instances rather than One RoleContributor to Rule Them All...

How to Implement a RoleContributor

So to implement a RoleContributor, we just need to implement the interface and register as an @Component OSGi service.

Let's look at the solution we might implement for Mikayla's case, since that's one that couldn't be solved any other way. In order to solve her case, we will need to know the client IP address for the current request, and as covered earlier we don't have this context available within the RoleContributor itself. There is the AuditRequestThreadLocal that is used as part of the Audit framework (it does exist in CE, CE is just missing some of the plumbing that is part of the full Auditing framework provided in DXP) and it has the client IP address for the request, so as long as the AuditFilter is enabled, the AuditRequestThreadLocal will contain the contextual detail we need.

Our implementation would look like:

/**
 * class StripContentEditorRoleWhenOffsiteRoleContributor: Strips
 * the "Content Editor" role from the user when they are offsite.
 * 
 * @author dnebinger 
 */
@Component(
        immediate = true,
        service = RoleContributor.class
)
public class StripContentEditorRoleWhenOffsiteRoleContributor 
        implements RoleContributor {
    private static final String CONTENT_EDITOR_ROLE_NAME = "Content Editor";
    private Map<Long, Long> _contentEditorRoleIdMap = new HashMap<>();

    /**
     * contribute: This is the main entry point for the
     * RoleContributor interface.
     * @param roleCollection The current role collection.
     */
    @Override
    public void contribute(RoleCollection roleCollection) {
        // so we need to first determine if the current 
        // user is offsite or not
        String clientIPAddress = AuditRequestThreadLocal
                .getAuditThreadLocal().getClientIP();
        
        // Using this we will determine if the user is offsite
        boolean offsite = _isIPAddressOffsite(clientIPAddress);
        
        if (!offsite) {
            // we don't have to change anything
            return;
        }
        
        // the current user is offsite, so we should remove 
        // any "Content Editor" roles the user might have.
        // We first need to get the role id
        long contentEditorRoleId = 
                _getContentEditorRoleIdForCompany(
                        roleCollection.getCompanyId());
        if (contentEditorRoleId == 0) {
            // no role found for this company, nothing to do
            return;
        }
        
        // remove the role from the role collection instance
        roleCollection.removeRoleId(contentEditorRoleId);
        
        // that's it, we're done!
    }

    /**
     * _getContentEditorRoleIdForCompany: Roles can be duplicated across
     * different instances (companies) in the portal, so we'll look up
     * the role id for the current company.
     * @param companyId Company id to get the role for.
     * @return long The role id.
     */
    private long _getContentEditorRoleIdForCompany(final long companyId) {
        // did we cache the role id?
        long roleId = _contentEditorRoleIdMap.get(companyId);
        if (roleId > 0) {
            // we have already looked up the role id for this company, return it
            return roleId;
        }
        
        // we have not yet looked it up, do so now
        Role role = _roleLocalService
                .fetchRole(companyId, CONTENT_EDITOR_ROLE_NAME);
        
        if (role == null) {
            // there is no content editor role for this company
            return 0;
        }
        
        roleId = role.getRoleId();
        
        // put in the cache so we have it in the future
        _contentEditorRoleIdMap.put(companyId, roleId);
        
        return roleId;
    }
    
    /**
     * _isIPAddressOffsite: A utility method to evaluate an 
     * IP address and determine
     * if it originates from offsite or not.
     * @param ipAddress The IP address to check.
     * @return boolean true if the address is offsite.
     */
    private boolean _isIPAddressOffsite(final String ipAddress) {
        // do magical work to see if this is an internal IP address or not
        // we'll fake out here and just return true, but a normal implementation
        // would want to really evaluate this
        
        return true;
    }
    
    @Reference
    private RoleLocalService _roleLocalService;
}

So a couple of things to point out... I am using the AuditRequestThreadLocal to get the current client IP address for the request.

Additionally, I'm using a local cache via the HashMap to track the role IDs for each company, so if the role lookup takes some time to run, I'm only going to do it one time, the rest of the time I'm just going to use the cached value. Another option would have been to use an @Activate method to find all of the existing "Content Editor" roles to pre-populate the map, that would save me the runtime lookup when I need the info, but you get the general idea.

Conclusion

And that's basically it. For the other examples we started with...

For Sue/Tom, we could have a RoleContributor that checks first if the user is Sue or Tom (user is part of the RoleCollection interface), then we could check the day of the week, and if it is M-F we add the Administrator role to Sue, and for Sat/Sun we would add the Administrator role to Tom.

For the shift work, it's a similar deal. When it is a particular user or users, we check the current time of day and if it falls within their shift, we add the role.

For Phil and Venkat, I likely wouldn't use this technique. I know, a curve ball, right? Well here I wanted to demonstrate that this is not the solution to every problem. Because Phil is going to be out for a solid 6 weeks and Venkat will need the role for that entire time, if I were going to solve this programmatically I'd use two scheduled jobs to handle the role change at the 1st of the month and change back 6 weeks later, but even this would be overkill. This case is best handled by just having the Admin change the roles manually.

If however you wanted to stick with the RoleContributor, it's going to follow the model shared earlier... If the user is Phil or Venkat, we verify that the current date is within the 6 week timeframe and, if it is, give the role to Venkat and take it away from Phil, otherwise do nothing.

So some final thoughts...

Always always always consider performance as your number 1 concern in these implementations. Liferay will absolutely call this code for each and every request, and if you have slow code here, you will totally notice the performance hit and capacity impact of your portal.

Use the most liberal caching methods you can to reduce ongoing overhead. In my example I would do a role lookup only once per company, but no more. The rest of the code is going to be just executing the java within the class itself so performance impact in general will be negligible.

All RoleContributors will be invoked by Liferay (order can be imposed by using the service ranking component property), and it is considered better to have multiple simple RoleContributor implementations instead of one complex RoleContributor. But if using a single contributor offers better performance due to shared cached data, that should be considered as a valid reason not to separate into smaller implementations.

Care should be taken when implementing one of these. If you remove a role from someone who should have it or give a role to someone who shouldn't, you'll have a hard time diagnosing these things after the fact since the changes made by these implementations are not persisted, they're not audited, and basically they leave no trace.

That's all for this blog. If you do happen to build one of these, do me a favor and share your use case in the comments below. I'm interested to hear how this can be used to solve scenarios that maybe I haven't thought of yet.

2
Blogs

Hi,I have tested it , but It can not work.

I assign the Site Administrator to  the normal site member, use the code bellow in RoleContributor.

public void contribute(RoleCollection roleCollection)

{

...

roleCollection.addRoleId(siteAdministratorRoleId);

...

}

then the site member login the portal , but can not get the administrator ui (It does not get the permission).

Can you tell me why? Thank you.