February 9, 2013 by Alistair Deneys
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).
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.
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:
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
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
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
<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:
|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:
Now log in as an admin and add the SME user above to the
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.