Filtering index searches by item security with correct paging and total results count

Filtering index searches by item security with correct paging and total results count

Sitecore ContentSearch indexes does not check item security by default, but it can be enabled with a configuration setting by changing the value of /sitecore/contentSearch/indexConfigurations/defaultSearchSecurityOption from DisableSecurityCheck to EnableSecurityCheck. However, it does not really work with paging of the results (i.e skip/take) or the total search result count.

<sitecore>
  <contentSearch>
    <indexConfigurations>
      <!-- DEFAULT SEARCH SECURITY OPTION
            This setting is the default search security option that will be used if
            the search security option is not specified during the creation of search context.
            The accepted values are DisableSecurityCheck and EnableSecurityCheck.
      -->
      <defaultSearchSecurityOption>DisableSecurityCheck</defaultSearchSecurityOption>
    </indexConfigurations>
  </contentSearch>
</sitecore>

The check is performed when enumerating the results (after the search has already been done) by fetching each item from Sitecore and then checking the item security and skipping those items that are not accessible to the current user.

That means if there are 10 results in total and you want to get the first 5, but none of those 5 are readable to the current user, then you get an empty list - even though there are actually more results. The TotalSearchResults will also return 10 even though those first 5 are not readable. Not optimal.

My Solution

I've come up with a better solution. It's somewhat limited and specifically tailored to the needs we had at the time as it is not really practical to store all access rights for all users and roles for all items in the index. It's all a compromise between complexity, search/indexing speed and the size of the index itself.

Supported features

  • Direct user security
  • Direct role security
  • Inherited role security

Unsupported features

  • Inherited user security

Overview

The solution consists of a few different elements.

  1. A computed field that stores the access rights we need
  2. An interface that has a property for the computed field
  3. [optional] Extension methods to easily filter by security when querying an index
  4. [optional] An attribute to automatically filter by security when using the attributed SearchResultItem in queries

Step 1: Computed field

Most of the logic happens in this computed field that we need to create. To be able to filter the results at query time we need to store some information in the index about which users and roles that have access to each item.

Overview of what we are doing in the computed field:

  1. Check if the Everyone role has access and if so just store "everyone"
  2. Get all roles in the sitecore domain that matches RoleRegex
  3. Create a list of all those matching roles that are allowed to read the item
  4. Get a list of all user accounts that have access rights specified directly on the item and are allowed to read it
  5. Return a combined list of those roles and users that have access to the item

You might need to modify this a bit to match your needs, e.g. if your roles are not in the sitecore domain.

*Note: The more roles that match the regex, the longer time the indexing will take as it has to check security for each role and this is done for each indexed item.*

using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Sitecore.Configuration;
using Sitecore.ContentSearch;
using Sitecore.ContentSearch.ComputedFields;
using Sitecore.Data.Items;
using Sitecore.Security.Accounts;
using Sitecore.Security.Domains;
using Sitecore.SecurityModel;

namespace Sandbox.Search.ComputedFields
{
    public class ReadableTo : IComputedIndexField
    {
        // TODO: Modify to match your needs!
        // The more matching roles the longer indexing will take.
        private const string RoleDomain = "sitecore";
        private static readonly Regex RoleRegex = new Regex(".*");

        public string FieldName { get; set; }

        public string ReturnType { get; set; }

        public object ComputeFieldValue(IIndexable indexable)
        {
            Item item = indexable as SitecoreIndexableItem;

            if (item == null) return null;

            using (new SecurityStateSwitcher(SecurityState.Enabled))
            {
                if (item.Security.CanRead(Account.FromName("Everyone", AccountType.Role)))
                    return "everyone";
            }

            // Get roles in 'sitecore' domain matching the regex and check security (including inheritance)
            // More roles equals longer indexing time as security is checked for each role
            var roles = Domain.GetDomain(RoleDomain)
                .GetRoles()
                .Where(x => RoleRegex.IsMatch(x.Name));

            var allowedRoles = new List<string>();
            using (new SecurityStateSwitcher(SecurityState.Enabled))
            {
                foreach (var role in roles)
                {
                    if (item.Security.CanRead(role))
                        allowedRoles.Add(role.Name);
                }
            }

            // Get accounts (users/roles) with directly specified security
            var allowedAccounts =
                item.Security.GetAccessRules().Helper
                    .GetAccounts()
                    .Where(x => item.Security.CanRead(x))
                    .Select(x => x.Name);

            return allowedRoles.Concat(allowedAccounts);
        }
    }
}

Index configuration

We then need to add this new computed field to the index configuration for each index we want to use this with, so that we can use it when querying the index.

<!-- Add under /fieldMap/fieldNames in your index configuration -->
<field fieldName="ReadableTo" storageType="NO" indexType="TOKENIZED" vectorType="NO" boost="1f" type="System.String"     settingType="Sitecore.ContentSearch.LuceneProvider.LuceneSearchFieldConfiguration, Sitecore.ContentSearch.LuceneProvider">
  <analyzer type="Sitecore.ContentSearch.LuceneProvider.Analyzers.LowerCaseKeywordAnalyzer, Sitecore.ContentSearch.LuceneProvider" />
</field>

<!-- Add under computed fields (/fields[@hint='raw:AddComputedIndexField']) in your index configuration -->
<field fieldName="ReadableTo" type="Sandbox.Search.ComputedFields.ReadableTo, Sandbox.Search" />

Step 2: Interface

Next up we need an interface with a property matching our new computed field. This is not strictly necessary, but it enables us to create an attribute and some extension methods to more easily use the new feature. If you don't need those extra features then you can just add a property named ReadableTo (or whatever you named it in the index configuration) to your SearchResultItem.

using System.Collections.Generic;

namespace Sandbox.Search
{
    public interface IIndexSecuritySupport
    {
        IEnumerable<string> ReadableTo { get; }
    }
}

Step 3: Extension methods (optional)

To make life easier for ourselves we can add two extension methods - one for the current user and one for a specific user.

IQueryable<TItem> FilterReadable<TItem>(this IQueryable<TItem> queryable) where TItem : IIndexSecuritySupport
IQueryable<TItem> FilterReadable<TItem>(this IQueryable<TItem> queryable, User user) where TItem : IIndexSecuritySupport

These will filter our IQueryable<TItem> to only items readable by either the current or the specified user respectively.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Text.RegularExpressions;
using Sitecore.Configuration;
using Sitecore.ContentSearch.Linq;
using Sitecore.ContentSearch.Linq.Utilities;
using Sitecore.Diagnostics;
using Sitecore.Security.Accounts;

namespace Sandbox.Search
{
    public static class QueryableSecurityExtensions
    {
        // This should match the regex in you computed field
        private static readonly Regex RoleRegex = new Regex($".*");

        private static readonly Type IndexSecurityType = typeof(IIndexSecuritySupport);

        private static readonly MethodInfo EnumerableContainsMethod;
        private static readonly PropertyInfo ReadableToProperty;

        static QueryableSecurityExtensions()
        {
            // Enumerable.Contains<string>(IEnumerable<string>, string)
            EnumerableContainsMethod =
                typeof(Enumerable).GetMethods()
                    .Single(m => m.Name == "Contains" && m.GetParameters().Length == 2)
                    .MakeGenericMethod(typeof(string));

            // IIndexSecuritySupport.ReadableTo
            ReadableToProperty = typeof(IIndexSecuritySupport).GetProperty(nameof(IIndexSecuritySupport.ReadableTo));
        }

        public static IQueryable<TItem> FilterReadable<TItem>(this IQueryable<TItem> queryable) where TItem : IIndexSecuritySupport
        {
            return FilterReadable(queryable, User.Current);
        }

        public static IQueryable<TItem> FilterReadable<TItem>(this IQueryable<TItem> queryable, User user) where TItem : IIndexSecuritySupport
        {
            Assert.ArgumentNotNull(queryable, nameof(queryable));
            Assert.ArgumentNotNull(user, nameof(user));
            Assert.IsTrue(IndexSecurityType.IsAssignableFrom(typeof(TItem)), $"Queryable type must implement the '{IndexSecurityType.Name}' interface.");

            var roles =
                user.Roles.Where(x => RoleRegex.IsMatch(x.Name))
                    .Select(x => x.Name)
                    .ToList();

            roles.Add(user.Name);

            var parameterExpression = Expression.Parameter(typeof(TItem), "param");
            var memberExpression = Expression.MakeMemberAccess(parameterExpression, ReadableToProperty);

            var everyone = GetContainsConstantPredicate<TItem>("everyone", parameterExpression, memberExpression);
            var containsAny = GetContainsAnyPredicate<TItem>(roles, parameterExpression, memberExpression);

            return queryable.Filter(everyone.Or(containsAny));
        }

        private static Expression<Func<TItem, bool>> GetContainsAnyPredicate<TItem>(IEnumerable<string> values, ParameterExpression parameterExpression, MemberExpression memberExpression)
        {
            var containsAnyPredicate = PredicateBuilder.False<TItem>();
            foreach (var value in values)
            {
                var containsExpression =
                    Expression.Lambda<Func<TItem, bool>>(
                        Expression.Call(EnumerableContainsMethod, memberExpression, Expression.Constant(value)),
                        parameterExpression);
                containsAnyPredicate = containsAnyPredicate.Or(containsExpression);
            }
            return containsAnyPredicate;
        }

        private static Expression<Func<TItem, bool>> GetContainsConstantPredicate<TItem>(string value, ParameterExpression parameter, MemberExpression member)
        {
            return
                Expression.Lambda<Func<TItem, bool>>(
                    Expression.Call(EnumerableContainsMethod, member, Expression.Constant(value)), parameter);
        }
    }
}

Without the extension methods we would have to do this everytime:

var user = Sitecore.Context.User;

var names =
    user.Roles.Where(x => RoleRegex.IsMatch(x.Name))
        .Select(x => x.Name)
        .ToList();

names.Add(user.Name);

var containsAnyFilter = PredicateBuilder.False<ArticleSearchResultItem>();
foreach (var name in names)
{
    containsAnyFilter = containsAnyFilter.Or(x => x.ReadableTo.Contains(name));
}

query.Where(x => x.Something == "abcdef")
     .Filter(x => x.ReadableTo.Contains("everyone") || containsAnyFilter)

Now we can just do this:

// Returns only indexed items readable to the current user
query.Where(x => x.Something == "abcdef").FilterReadable();

// Returns only indexed items readable to 'extranet\SomeUser'
var user = User.FromName("extranet\\SomeUser", false);
query.Where(x => x.Something == "abcdef").FilterReadable(user);

Step 4: Predefined query attribute for SearchResultItem (optional)

Sitecore allows us to use the [PredefinedQuery] attribute on classes to apply predefined queries when querying an index using that type. This attribute allows us to add constant filters. An example could be to always filter by a specific template ID:

[PredefinedQuery("TemplateID", ComparisonType.Equal, "{7FAFEDF6-9438-4CAD-9E04-3FCD89206D2F}", typeof(ID)]
public class ArticleSearchResultItem : SearchResultItem
{
    // ...
}

// Now you don't have to add this every time you want to search for articles:
// .Where(x => x.TemplateID == new ID("{7FAFEDF6-9438-4CAD-9E04-3FCD89206D2F}"))

It is also possible to make our own with our own logic by creating a class inheriting the IPredefinedQueryAttribute interface. This allows us to create an attribute that makes sure that only items readable to the current user is returned when querying the index using types marked with that attribute.

using System;
using System.Linq;
using Sitecore.ContentSearch;
using Sitecore.ContentSearch.Diagnostics;
using Sitecore.ContentSearch.Linq.Parsing;

namespace Sandbox.Search
{
    /// <summary>
    /// Automatically applies filtering on security to queries using the attributed class.
    /// </summary>
    [AttributeUsage(AttributeTargets.Class, Inherited = false)]
    public class ApplySecurityAttribute : Attribute, IPredefinedQueryAttribute
    {
        private static readonly Type IndexSecurityType = typeof (IIndexSecuritySupport);

        public IQueryable<TItem> ApplyFilter<TItem>(IQueryable<TItem> queryable, IIndexValueFormatter valueFormatter)
        {
            if (!IndexSecurityType.IsAssignableFrom(typeof (TItem)))
            {
                SearchLog.Log.Warn($"The type '{typeof (TItem).FullName}' does not implement the '{IndexSecurityType.Name}' interface so security filtering is not being applied.");
                return queryable;
            }

            // The casts looks hacky but it works
            return queryable
                .Cast<IIndexSecuritySupport>()
                .FilterReadable() // Our extension method from step 3
                .Cast<TItem>();
        }
    }
}

Then we just apply this to our custom SearchResultItem class:

// Your search result item also needs to inherit the IIndexSecuritySupport interface
[ApplySecurity]
public class ArticleSearchResultItem : SearchResultItem, IIndexSecuritySupport
{
}

Now we don't need to remember to add the FilterReadable() call to queries using the ArticleSearchResultItem type.

// This is now the same as 'query.Where(x => x.Something == "abcdef").FilterReadable()'
query.Where(x => x.Something == "abcdef");

Conclusion

So that's it. It looks a lot more complex than it really is.

There are two reasons why I'm not handling inherited user security. Well, first off - we didn't need it. But besides that, as far as I know it's not possible to check for inherited access rights without checking if each individual user has any inherited rights - and that would have to be done for every user on your site.

Let me know if you have any issues with this or comments in general.