codeflood logo

SME Review with More Dynamic Roles

When considering content ownership in Sitecore, you realise Sitecore is a bit of a hippy. It's not so much about who owns the content, it's about everyone working together in collaboration and harmony. This is evident in the default manner in which workflow works, and how authors push the content from workflow state to workflow state without needing to explicitly state who the content goes to. The workflow works without the authors having to pass the content along the line manually from person to person.

On the whole I think this approach is very good. Don't get bogged down with individuals. An individual is a bottleneck. For example, people have a tendency to get sick, or go on holidays, or get distracted by more important things (more important to them anyway). I prefer Sitecore's approach of role based workflow actors. Chances are there will be multiple users in the same role, all able to perform those defined actions in workflow. So if Johnny is unavailable, someone else can pick up the slack. But sometimes we do need to be a bit more strict and rope individuals into the workflow process for various reasons.

Take for example a review process of a piece of content where a subject matter expert (SME) must review the content for correctness and SMEs are so varied in their areas of expertise that it's not appropriate to create roles for each or to create separate workflow states (or complete workflows) for each. Well, Sitecore has broken out of the 60's and added features to allow strict assignment of content to a user.

This appears through the inclusion of the system field __owner. Being a Sitecore standard field (inherited through the standard template) you should interact with this field using the content editor buttons. To view the existing item owner, look in the Quick Info section (top of the field list).

item owner

To change the owner, click the change button in the ownership chunk of the security tab. Clicking this button opens the user select dialog which allows you to search for and select a single user to assign ownership to. In addition to this field, Sitecore contains a dynamic role to refer to the current item's owner, the Creator-Owner role. This role will reference the user in the __owner field or if that field is empty it will fallback to the user who created the item (user referred to in the __created by field). We can assign security in workflow to the Creator-Owner role so only the item owner (or creator (or admin)) can act on the item.

Now, this is all sounding pretty good right. But it's not quite what we're after. Firstly, assignment of ownership of the item is not semantically what I want for the above scenario. The SME shouldn't own the content, the content owner should remain as whomever wrote the article initially. I just want the SME to review the content for accuracy. What if we need several SMEs to review the content? The item owner field only allows selection of a single user account. But what I really need is review from a panel of SMEs.

The second issue with using the Creator-Owner role is in how this dynamic role is evaluated. Think about how you might implement the above scenario. You'd probably allow the worflowstate:write permission to the Creator-Owner role for the appropriate workflow state definition item right? Well, the dynamic role is evaluated against the item on which the security right is defined, in this case, the workflow state definition item. But what we require for our scenario is the item owner of the item in workflow, not the owner of the workflow state definition item, so we can't easily implement this scenario using out of the box components. Instead, we'll need to follow the ideas I explored in another recent post of mine (which I also mentioned above), Dynamic Roles in Sitecore.

Firstly, I need to add a field to the data template for my items to allow me to select the user accounts that will be the reviewers of that piece of content. To allow specifying multiple accounts for the review I'll make use of the Account Selector Field from the Sitecore Marketplace. This field allows the user to select multiple accounts using the familiar account selection dialog. Using the Account Selector Field I'll add a field to my base template called "reviewers". Then using the techniques from my previous post I'll implement a dynamic role that will read membership from this field. This will allow me to leverage the Sitecore security tools to implement this scenario (and make workflow tools work properly).

Firstly, adding the "reviewers" field.

reviewers field

The account selector field has various options to control how it behaves. In the source field I set the following options to allow multiple selection of user accounts only:

AllowMultipleSelection=true&OnlyUsers=true

Now onto the custom roles provider to expose a dynamic role called 'item-reviewer'. And just to be different, today I'll be writing my sample code in Boo.

    namespace sc66sb
    
    import System
    import Sitecore.Security.Accounts
    
    class ReviewerRoleProvider(SqlServerRolesInRolesProvider):
    """Role provider to expose 'item-reviewer' role"""
    
        override def GetAllRoles(includeSystemRoles as bool):
            # return normal roles
            for r in super.GetAllRoles(includeSystemRoles):
                yield r
    
            # return custom role
            yield Role.FromName("item-reviewers")

The class above extends the SqlServerRolesInRolesProvider and overrides the GetAllRoles() method to return our new item-reviewers role in conjunction with the roles provided by the base class. In my previous post regarding dynamic roles I was also able to override the IsUserInRole() method to implement the logic of resolving the members of the dynamic role. This worked because role membership was statically determined; the users membership within a role didn't change from item to item. In the SME scenario above membership to the dynamic item-reviewers role will change from item to item. The role provider doesn't support returning different results per item. So we need to identify another appropriate class to override and insert our custom logic.

Sitecore uses the standard ASP.NET membership model. So to alter the evaluation of permissions a user has in the system I can simply extend the Sitecore.Security.AccessControl.SqlServerAuthorizationProvider class to alter this behavior and update configuration to use my new class.

namespace sc66sb

import System
import Sitecore.Data.Items
import Sitecore.Security.Accounts
import Sitecore.Security.AccessControl

class ReviewerAuthorizationProvider(SqlServerAuthorizationProvider):
"""Authorization provider to evaluate membership in 'item-reviewer' role"""

    _reviewerRole = Role.FromName('item-reviewers')

    override def GetItemAccess(item as Item, account as Account, \
        accessRight as AccessRight):
        res = super.GetItemAccess(item, account, accessRight)
        if res.Permission == AccessPermission.Allow:
            return res

        # if access denied for user, check item reviewers role
        if item["Reviewers"].Contains(account.Name) \
            and account.Name != _reviewerRole.Name:
            res = super.GetItemAccess(item, _reviewerRole, \
                accessRight)

        return res

In the above code I've overridden the GetItemAccess() method which evaluates the permissions for a specified account for a specified access right to a specified item. Initially the base class method is called to check if the user has access. If not, then I check if the user is included in the reviewers field and if so, return the permissions for the item-reviewers role.

The above customisations now allow an administrator to assign security permissions to our new dynamic role and also resolve access permissions for that dynamic role if the individual user doesn't have access and the user is included in the reviewers field. This covers off item security. But workflow security is a little different. When the item:write access right is resolved, Sitecore internally first checks that the user has item:write access on the item and then also checks to ensure the same account has workflowstate:write access to the current workflow state the item is in. So why is the above authorization provider not adequate? This is similar to the Creator-Owner issue above. In the above authorization provider the item being checked for the access right holds the reviewers field. But we're checking the workflowstate:write access right of the workflow state definition item itself, and the reviewers field is on the item in workflow, not the state definition item. So we'll need to tweak the workflow engine to work the way we want.

Unfortunately the default Sitecore workflow class Sitecore.Workflows.Simple.Workflow cannot be overridden for the case above. The class does contain many virtual methods, but I need to override the GetAccess() method which is not virtual. So instead of overriding the workflow, I'm going to wrap it as I did in another previous post on Get Your Workflow in Order. In that article I showed how the workflow can be wrapped by another class which simply passes the call onto the wrapped workflow, but the methods I want to tweak can be manually written. Here is my wrapping "reviewer" workflow.

namespace sc66sb

import System
import Sitecore
import Sitecore.Data
import Sitecore.Data.Items
import Sitecore.Data.Managers
import Sitecore.Diagnostics
import Sitecore.Globalization
import Sitecore.Security.Accounts
import Sitecore.Security.AccessControl
import Sitecore.SecurityModel
import Sitecore.Workflows

class ReviewerWorkflow(IWorkflow):
"""Workflow implementation which allows use of the special 'item-reviewer' role"""

    _reviewerRole = Role.FromName('item-reviewers')
    _innerWorkflow as IWorkflow
    _database as Database

    Appearance as Appearance:
        get:
            return _innerWorkflow.Appearance

    WorkflowID as string:
        get:
            return _innerWorkflow.WorkflowID

    def constructor(innerWorkflow as IWorkflow, database as Database):
        _innerWorkflow = innerWorkflow
        _database = database

    def GetAccess(item as Item, account as Account, \
        accessRight as AccessRight) as AccessResult:
        res = _innerWorkflow.GetAccess(item, account, accessRight)
        if res.Permission == AccessPermission.Allow:
            return res

        if item["Reviewers"].Contains(account.Name) \
            and account.Name != _reviewerRole.Name:
            res = _innerWorkflow.GetAccess(item, _reviewerRole, \
                accessRight)

        return res

    def GetCommands(item as Item):
        Assert.ArgumentNotNull(item, "item");
        stateID = GetStateID(item)
        if stateID.Length > 0:
            return GetCommands(stateID, item)

        return array(WorkflowCommand, 0)

    private def GetStateID(item as Item):
        Assert.ArgumentNotNull(item, "item")
        workflowInfo = item.Database.DataManager.GetWorkflowInfo(item)
        if  workflowInfo != null:
            return workflowInfo.StateID

        return string.Empty

    def GetCommands(stateID as string):
        return GetCommands(stateID, null)

    def GetCommands(stateID as string, item as Item):
        Assert.ArgumentNotNullOrEmpty(stateID, "stateID")
        cmds = List[of WorkflowCommand]()
        stateItem = GetStateItem(stateID) as Item

        if stateItem != null:
            for cmd in stateItem.Children.ToArray():
                template = cmd.Database.Engines.TemplateEngine.GetTemplate(\
                    cmd.TemplateID)
                userAllowed = AuthorizationManager.IsAllowed(cmd, \
                    AccessRight.WorkflowCommandExecute, Context.User)

                reviewerAllowed = true
                if item != null \
                    and item["Reviewers"].Contains(Context.User.Name) \
                    and Context.User.Name != _reviewerRole.Name:
                    reviewerAllowed = AuthorizationManager.IsAllowed(cmd, \
                        AccessRight.WorkflowCommandExecute, _reviewerRole)

                if template != null \
                    and template.DescendsFromOrEquals(TemplateIDs.WorkflowCommand) \
                    and (userAllowed or reviewerAllowed):
                    cmds.Add(WorkflowCommand(cmd.ID.ToString(), \
                        cmd.DisplayName, cmd.Appearance.Icon, false, \
                        cmd["suppress comment"] == "1"))

        return cmds.ToArray()

    private def GetStateItem(stateId as string):
        iD = MainUtil.GetID(stateId, null)
        if iD == null:
            return null

        return GetStateItem(iD)

    private def GetStateItem(stateId as ID):
        return ItemManager.GetItem(stateId, Language.Current, \
            Sitecore.Data.Version.Latest, _database, SecurityCheck.Disable)

    def Start(item as Item):
        _innerWorkflow.Start(item)

    def Execute(commandID as string, item as Item, comments as string, \
        allowUI as bool, *parameters):
        return _innerWorkflow.Execute(commandID, item, comments, allowUI, \
            parameters)

    def GetHistory(item as Item):
        return _innerWorkflow.GetHistory(item)

    def GetItemCount(stateID as string):
        return _innerWorkflow.GetItemCount(stateID)

    def GetItems(stateID as string):
        return _innerWorkflow.GetItems(stateID)

    def GetState(item as Item):
        return _innerWorkflow.GetState(item)

    def GetState(stateID as string):
        return _innerWorkflow.GetState(stateID)

    def GetStates():
        return _innerWorkflow.GetStates()

    def IsApproved(item as Item):
        return _innerWorkflow.IsApproved(item)

Most of the methods just pass through to the wrapped workflow, but I override some of the methods. Firstly, I've overridden the GetAccess() method so if the user isn't granted the access right desired and the user exists in the reviewer field, then return the permissions of the reviewer role. That handles access to the item when it's in workflow. The remaining methods are to do with showing the correct workflow commands to the user. Overriding the command methods was more involved as the OOTB workflow uses many private methods to find the commands, which I also had to implement myself. This all starts with the GetCommands() method and it's overloads. In particular, I needed to pass the current item in workflow to the GetCommands() method so I had access to the reviewers field. The last piece of the puzzle is to override the workflow provider to return our custom reviewer workflow instead of the OOTB workflow.

namespace sc66sb

import System
import Sitecore.Data.Items
import Sitecore.Workflows
import Sitecore.Workflows.Simple

class ReviewerWorkflowProvider(WorkflowProvider):
"""Description of ReviewerWorkflowProvider"""
    public def constructor(databaseName as string, \
        historyStore as HistoryStore):
        super(databaseName, historyStore)

    override def GetWorkflow(item as Item):
        workflow = super.GetWorkflow(item);
        return (ReviewerWorkflow(workflow, Database) \
            if workflow != null else null)

    override def GetWorkflow(id as string):
        workflow = super.GetWorkflow(id);
        return (ReviewerWorkflow(workflow, Database) \
            if workflow != null else null)

Note how I am simply calling the base class implementation of the overridden methods to get the OOTB workflow, but then I wrap that with the reviewer workflow before returning. To tie all these customisations into workflow I've added this config patch file to the App_Config\Include folder.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> 
    <sitecore> 
    <rolesInRolesManager> 
        <providers> 
        <add name="sql"> 
            <patch:attribute name="type">sc66sb.ReviewerRoleProvider, 
sc66sb</patch:attribute> 
        </add> 
        </providers> 
    </rolesInRolesManager>
    <authorization>
        <providers>
        <add name="sql">
            <patch:attribute name="type">sc66sb.ReviewerAuthorizationProvider, 
sc66sb</patch:attribute>
        </add>
        </providers>
    </authorization>
    <databases>
        <database id="master">
            <workflowProvider hint="defer" type="Sitecore.Workflows.Simple.WorkflowProvider, 
Sitecore.Kernel">
                <patch:attribute name="type">sc66sb.ReviewerWorkflowProvider, 
sc66sb
                </patch:attribute> 
            </workflowProvider>
        </database>
    </databases>
    </sitecore> 
</configuration>

This config patch file changes the role provider to our custom role provider, changes the authorization provider to our custom provider and changes the workflow provider of the master database to use our custom workflow provider. Now to see it all in action! I have duplicated the sample workflow and adjusted security settings as follows:

item account access right permission
review state item item-reviewer role workflowstate:write allow
review state item item-reviewer role workflowstate:delete allow
approve command item item-reviewer role workflowstate:execute allow
reject command item item-reviewer role workflowstate:execute allow

In addition to the above workflow security I've also granted write, rename, create and delete access rights to the item-reviewers role to the home item and all descendants. Now when I log in as an SME user, I do not currently have access to the item that is in this workflow in the review state:

sme no write access

Now log in as an admin and add the SME user above to the reviewers field:

add sme reviewer

Then refresh the SME users content editor and voila! The SME user who is included in the reviewers field, but has no direct access of their own, can now edit and review the item.

sme write access

Comments

Leave a comment

All fields are required.