Control access to Sitecore page design per item

Control access to Sitecore page design per item

Some time ago a client needed to be able to control access to the page layout on a per item basis. They wanted some users to only be able to change the presentation details in specific parts of the content tree.

Sitecore currently isn’t really made to support controlling access to page layout/presentation details on a per item basis. The functionality is currently tied to a policy item in the core database and you normally assign the sitecore/Designer role, which gives access to this policy, to users who should be able to change the page layout. As such it’s an all or nothing approach. You cannot deny users the ability to change the presentation on specific pages.

Can Design policy

While this doesn’t make it impossible, it does make the solution a bit more “manual” and not totally future proof – but overall it works pretty well!

You can see the code on GitHub, install the NuGet package (Krusen.Siteore.DesignRights) or download the Sitecore package from the marketplace. Let me know on GitHub if you have any issues or suggestions.

Implementation

The implementation consists of a few different parts – most of them are pretty simple though. In short there are 3 parts:

  • Defining a custom access right
  • Overriding the authorization provider and helpers
  • Overriding relevant Content and Experience Editor buttons to respect the new access right

Defining the custom access right

It’s quite easy to create a new custom access right. You start out by adding a config file defining the access right and the implementing type.

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/">
  <sitecore>

    <accessRights>
      <rights>
        <add name="item:customdesign"
             title="Design"
             comment="Design right for items."
             type="Krusen.Sitecore.DesignRights.Security.DesignAccessRight, Krusen.Sitecore.DesignRights"
             patch:after="*[@name='item:admin']"/>
      </rights>
    </accessRights>

  </sitecore>
</configuration>

Then you just need to create a class extending Sitecore.Security.AccessControl.AccessRight. It does not require any special logic. All it requires is the name of the access right defined in the config.

using System;
using Sitecore.Configuration;
using Sitecore.Data.Items;
using Sitecore.Security.AccessControl;
using Sitecore.Security.Accounts;

namespace Krusen.Sitecore.DesignRights.Security
{
    public class DesignAccessRight : AccessRight
    {
        public const string DefaultAccessRightName = "item:customdesign";

        public static string AccessRightName { get; } = Settings.GetSetting("DesignRights.AccessRightName", DefaultAccessRightName);

        private static readonly Lazy<AccessRight> _accessRight = new Lazy<AccessRight>(() => FromName(AccessRightName));
        public static AccessRight AccessRight => _accessRight.Value;

        public DesignAccessRight(string name) : base(name)
        {
        }

        public static bool IsAllowed(Item item, Account account)
        {
            return AuthorizationManager.IsAllowed(item, AccessRight, account);
        }

        public static bool IsDenied(Item item, Account account)
        {
            return AuthorizationManager.IsDenied(item, AccessRight, account);
        }

        public static AccessResult GetAccess(Item item, Account account)
        {
            return AuthorizationManager.GetAccess(item, account, AccessRight);
        }
    }
}

That’s it for the access right itself.

Authorization provider and helpers

The authorization provider uses an ItemAuthorizationHelper to check if someone has access or not to a specific item.

For this whole thing to work we need to create our own version, extending the existing one, which denies access to the Can Design policy item in the core database – if our new access right is not set to Allow on the current context item (or inherited).

using Sitecore;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Security.AccessControl;
using Sitecore.Security.Accounts;

namespace Krusen.Sitecore.DesignRights.Security
{
    public class DesignRightsItemAuthorizationHelper : ItemAuthorizationHelper
    {
        private static readonly ID PolicyCanDesignID = ID.Parse("{5A524BAD-2257-4330-9CAD-A2DCB1111A66}");

        protected override AccessResult GetItemAccess(Item item, Account account, AccessRight accessRight,
            PropagationType propagationType)
        {
            var result = base.GetItemAccess(item, account, accessRight, propagationType);

            // We don't want to overrule other permissions not allowing access (write access etc.)
            if (result?.Permission != AccessPermission.Allow)
                return result;

            // Only check if the item is the "CanDesign" policy item or if context item is not null
            // Context item can be null for some requests in the Content Editor
            if (!ShouldCheckDesignRights(item))
                return result;

            var designAccessResult = DesignAccessRight.GetAccess(item, account);
            if (designAccessResult.Permission != AccessPermission.Allow)
                return designAccessResult;

            return result;
        }

        private static bool ShouldCheckDesignRights(Item item)
        {
            return item.ID == PolicyCanDesignID && Context.Item != null;
        }
    }
}

We also need to deny write access to the layout fields accordingly by replacing the FieldAuthorizationHelper.

using System.Collections.Generic;
using Sitecore;
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Security.AccessControl;
using Sitecore.Security.Accounts;

namespace Krusen.Sitecore.DesignRights.Security
{
    class DesignRightsFieldAuthorizationHelper : FieldAuthorizationHelper
    {
        private static readonly HashSet<ID> LayoutFieldIDs = new HashSet<ID>
        {
            FieldIDs.LayoutField,
            FieldIDs.FinalLayoutField
        };

        public override AccessResult GetAccess(Field field, Account account, AccessRight accessRight)
        {
            AccessResult result = null;

            if (ShouldCheckDesignRights(field, accessRight))
                result = GetLayoutFieldAccess(field, account);

            return result ?? base.GetAccess(field, account, accessRight);
        }

        private static bool ShouldCheckDesignRights(Field field, AccessRight accessRight)
        {
            return accessRight == AccessRight.FieldWrite && LayoutFieldIDs.Contains(field.ID);
        }

        private static AccessResult GetLayoutFieldAccess(Field field, Account account)
        {
            var accessResult = DesignAccessRight.GetAccess(field.Item, account);
            if (accessResult.Permission == AccessPermission.Allow)
                return null;

            return new AccessResult(AccessPermission.Deny, accessResult.Explanation); ;
        }
    }
}

Now, to actually use these new helpers we need to replace the authorization provider.

using Sitecore.Buckets.Security;
using Sitecore.Security.AccessControl;

namespace Krusen.Sitecore.DesignRights.Security
{
    public class DesignRightsAuthorizationProvider : BucketAuthorizationProvider
    {
        private ItemAuthorizationHelper _itemHelper;
        private FieldAuthorizationHelper _fieldHelper;

        public DesignRightsAuthorizationProvider()
        {
            _itemHelper = new DesignRightsItemAuthorizationHelper();
            _fieldHelper = new DesignRightsFieldAuthorizationHelper();
        }

        protected override ItemAuthorizationHelper ItemHelper
        {
            get { return _itemHelper; }
            set { _itemHelper = value; }
        }

        protected override FieldAuthorizationHelper FieldHelper
        {
            get { return _fieldHelper; }
            set { _fieldHelper = value; }
        }
    }
}

Here we are just setting it to use our new DesignRightsItemAuthorizationHelper and DesignRightsFieldAuthorizationHelper instead of the default ones. At last we need to add another config file (or add to the previous).

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/">
  <sitecore>

    <!-- NOTE: This file needs to be placed after Sitecore.Buckets.config otherwise it wil overwrite the default authorization provider -->

    <authorization set:defaultProvider="designrights">
      <providers>
        <add name="designrights" type="Krusen.Sitecore.DesignRights.Security.DesignRightsAuthorizationProvider, Krusen.Sitecore.DesignRights" connectionStringName="core" embedAclInItems="true" />
      </providers>
    </authorization>

  </sitecore>
</configuration>

We add our new authorization provider and sets the default provider to use that.

*NOTE: This part needs to be after Sitecore.Buckets.config otherwise that file will overwrite our changes to the default provider.*

Editor buttons

Last but not least we need to handle all the buttons connected to this functionality. This includes buttons with regards to presentation details and layout in both the Content Editor and the Experience Editor. Also the Experience Editor has buttons for adding components to the page and the “Designing” checkbox.

There’s quite a few and they are all more or less the same so I won’t show them all here, but you should get the general idea and otherwise have a look at the GitHub repository.

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/">
  <sitecore>

    <!-- Override relevant buttons in Content Editor and old Page Editor -->
    <commands>
      <command name="webedit:selectlayoutpreset" set:type="Krusen.Sitecore.DesignRights.Commands.SelectLayoutPreset, Krusen.Sitecore.DesignRights" />
      <command name="webedit:hidecontrol"        set:type="Krusen.Sitecore.DesignRights.Commands.HideControl, Krusen.Sitecore.DesignRights" />
      <command name="item:setlayoutdetails"      set:type="Krusen.Sitecore.DesignRights.Commands.SetLayoutDetails, Krusen.Sitecore.DesignRights" />
      <command name="pagedesigner:reset"         set:type="Krusen.Sitecore.DesignRights.Commands.Reset, Krusen.Sitecore.DesignRights" />
    </commands>

    <!-- Override relevant buttons in Experience Editor -->
    <sitecore.experienceeditor.speak.requests>
      <request name="ExperienceEditor.LayoutDetails.CanEdit"     set:type="Krusen.Sitecore.DesignRights.ExperienceEditor.Requests.LayoutDetails.CanEditLayoutDetailsRequest, Krusen.Sitecore.DesignRights" />
      <request name="ExperienceEditor.CanAddComponent"           set:type="Krusen.Sitecore.DesignRights.ExperienceEditor.Requests.AddRendering.CanAddRendering, Krusen.Sitecore.DesignRights" />
      <request name="ExperienceEditor.ResetLayout.IsEnabled"     set:type="Krusen.Sitecore.DesignRights.ExperienceEditor.Requests.ResetLayout.IsEnabled, Krusen.Sitecore.DesignRights" />
      <request name="ExperienceEditor.EnableDesigning.CanDesign" set:type="Krusen.Sitecore.DesignRights.ExperienceEditor.Requests.EnableDesigning.CanDesign, Krusen.Sitecore.DesignRights" />
      <request name="ExperienceEditor.LayoutPresets.CanOpen"     set:type="Krusen.Sitecore.DesignRights.ExperienceEditor.Requests.LayoutPresets.CanOpenRequest, Krusen.Sitecore.DesignRights" />
      <request name="ExperienceEditor.Versions.GetStatus"        set:type="Krusen.Sitecore.DesignRights.ExperienceEditor.Requests.EditAllVersions.GetStatus, Krusen.Sitecore.DesignRights" />
    </sitecore.experienceeditor.speak.requests>

  </sitecore>
</configuration>

Below is an example of one of the commands and one of the Experience Editor requests, respectively.

using Sitecore.Shell.Framework.Commands;

namespace Krusen.Sitecore.DesignRights.Commands
{
    public class SetLayoutDetails : global::Sitecore.Shell.Framework.Commands.SetLayoutDetails
    {
        public override CommandState QueryState(CommandContext context)
        {
            return CommandUtil.GetDesignRightState(base.QueryState(context), context);
        }
    }
}
using Sitecore;

namespace Krusen.Sitecore.DesignRights.ExperienceEditor.Requests.AddRendering
{
    public class CanAddRendering : global::Sitecore.ExperienceEditor.Speak.Ribbon.Requests.AddRendering.CanAddRendering
    {
        public override bool GetControlState()
        {
            return base.GetControlState() && DesignAccessRight.IsAllowed(RequestContext.Item, Context.User);
        }
    }
}

To avoid code duplication I created this helper class used in the commands:

using Sitecore;
using Sitecore.Shell.Framework.Commands;

namespace Krusen.Sitecore.DesignRights.Commands
{
    internal static class CommandUtil
    {
        public static CommandState GetDesignRightState(CommandState originalState, CommandContext commandContext)
        {
            if (commandContext.Items[0] == null) return originalState;

            // We don't want to enable it if it's disabled
            if (originalState != CommandState.Enabled) return originalState;

            return DesignAccessRight.IsAllowed(commandContext.Items[0], Context.User)
                ? originalState
                : CommandState.Hidden;
        }
    }
}

Conclusion

With all this implemented we now have a new access right in the Security Editor where you can allow/deny access to the page design.

Security Editor

*NOTE: You now have to explicitly allow access to designing for users/roles per item (supports inheritance of course).*