Сращивание expression tree

В предыдущем посте я показал как можно использовать обобщенное программирование при доступе к данным. С помощью пары простых методов можно заставить работать код вида

from e in context.Entity1Set.Visible()
select e;

Пример надуманный, так как предикат для определения видимости простой, и его несложно в каждом выражении записать явно. Но вместо него может быть предикат проверки прав доступа, сам по себе содержащий подзапросы, соединения и тому подобное.

Если написать что-то вроде

from e1 in context.Entity1Set.Visible()
from e2 in e1.Entity2.Visible()
select e2;

То такой код даже не скомпилируется, потому что e1.Entity2 не реализует интерфейс IQueryable<T>. Даже если бы такой ко компилировался, то наверняка отвалился бы в runtume, потому что Linq провайдер не знает что делать с методом Visible.

Проблему можно было бы решить если бы существовал простой способ поставить одно дерево выражений в другое. В языке F# есть возможность сращивания цитат (аналогов деревьев выражений для .NET), то есть подстановки одной цитаты в другую. Тогда вместо вызовов методов для коллекций можно было бы подставлять предикаты прямо в запрос. Что-то вроде такого:

from e1 in context.Entity1Set    
where Visible(e1)
from e2 in e1.Entity2
where Visible(e2)
select e2;

Но какой тип должен быть у предиката Visible? Чтобы код скомпилировался нужно чтобы Visible возвращал bool, а чтобы его можно было анализировать в runtime это должен быть тип Expression<Func<IVisible, bool>>

Идея в следующем, доработать FixupVisitor из предыдущего поста чтобы он находил в дереве выражений Expression<Func<…>> и выражение в само дерево. Чтобы такая подстановка компилировалась надо сделать метод, который будет преобразовывать типы, он же будет маркером, который скажет визитору, что надо подставить одно дерево в другое.

Нужен метод Splice (по англ. сращивание), который будет принимать Expression<Func<…,T>> и возвращать T.

public static T Splice<T,T1>
			(this Expression<Func<T1, T>> expr, T1 p1)
{
    throw new NotSupportedException();
}

Вызываться непосредственно этот метод не должен, только использоваться в expression tree.

Теперь добавим в FixupVisitor пару методов

protected override Expression VisitMethodCall
                   (MethodCallExpression node)
{
    if (!CheckSpliceMethod(node.Method))
    {
        return base.VisitMethodCall(node);
    }

    var args = node.Arguments;
    var expr = ExpressionExtensions.StripQuotes(args.First());

    //Если выражение не было подставлено непосредственно
    if (!(expr is LambdaExpression))
    {
        expr = (Expression)Expression.Lambda(expr)
                                     .Compile()
                                     .DynamicInvoke();
    }
    var lambda = expr as LambdaExpression;

    //Подстановка параметров в сращиваемое выражение
    return base.Visit(ExpressionExtensions.ReplaceAll
                        (lambda.Body, 
                         lambda.Parameters, 
                         args.Skip(1)));
}

//Проверяет что это нужный метод Splice
private bool CheckSpliceMethod(MethodInfo mi)
{
    if (mi.Name != "Splice" || mi.GetParameters().Length < 1)
        return false;
 
    var t = mi.GetParameters().First().ParameterType;

    return  t.IsGenericType &&
            t.GetGenericTypeDefinition() == typeof(Expression<>) &&
            t.GetGenericArguments()[0]
             .GetGenericArguments().Last() == mi.ReturnType;
}

Для замены формальных параметров лямбды на фактические параметры вызова метода Splice применяется  функция ReplaceAll, которая тоже реализована с помощью визитора.

class ExpressionReplacer : ExpressionVisitor
{
    Predicate<Expression> matcher;
    Func<Expression, Expression> replacer;

    public ExpressionReplacer
               (Predicate<Expression> matcher, 
                Func<Expression, Expression> replacer)
    {
        this.matcher = matcher;
        this.replacer = replacer;
    }

    public ExpressionReplacer(Expression searchFor, 
                              Expression replaceWith)
        : this(e => e == searchFor, _ => replaceWith)
    { }

    public override Expression Visit(Expression node)
    {            
        if(matcher(node))
        {
            return replacer(node);
        }

        return base.Visit(node);
    }
}

public static class ExpressionExtensions
{
    public static Expression StripQuotes(Expression expression)
    {
        if (expression.NodeType == ExpressionType.Quote)
        {
            return (expression as UnaryExpression).Operand;
        }
        else return expression;
    }

    public static Expression Replace
		      (Expression expression, 
                       Predicate<Expression> matcher, 
                       Func<Expression, Expression> replacer)
    {
        return new ExpressionReplacer(matcher, replacer)
                       .Visit(expression);
    }

    public static Expression Replace
                      (Expression expression, 
                       Expression searchFor, 
                       Expression replaceWith)
    {
        return new ExpressionReplacer(searchFor, replaceWith)
                       .Visit(expression);
    }

    public static Expression ReplaceAll
                  (Expression expression, 
                   IEnumerable<Expression> searchFor, 
                   IEnumerable<Expression> replaceWith)
    {
        return searchFor.Zip(replaceWith, Tuple.Create)
                        .Aggregate(expression, (e, p) => 
                             Replace(expression, 
                                     p.Item1, p.Item2));
    }
}

Теперь тестовый пример

static void Main(string[] args)
{
    Expression<Func<IVisible, bool>> 
	visiblePredicate = e => e.Visible;

    var context = new Model1Container();
    var q = from e1 in context.Entity1Set                    
            where visiblePredicate.Splice(e1)
            from e2 in e1.Entity2
            where visiblePredicate.Splice(e2)
            select e2;
    Console.WriteLine((q.Fix() as ObjectQuery).ToTraceString());
}

Дает результат

SELECT
[Extent1].[Id] AS [Id],
[Extent2].[Id] AS [Id1],
[Extent2].[Visible] AS [Visible],
[Extent2].[Entity1_Id] AS [Entity1_Id]
FROM  [dbo].[Entity1Set] AS [Extent1]
INNER JOIN [dbo].[Entity2Set] AS [Extent2] 
ON [Extent1].[Id] = [Extent2].[Entity1_Id]
WHERE ([Extent1].[Visible] = 1) 
    AND ([Extent2].[Visible] = 1)


Про виды разграничения прав

Кросс-пост с RSDN

Классическая rbs (role-based security)

Есть Роли, Пользователи могут иметь несколько ролей, Роли имеют несколько Разрешений.
То есть между Пользователями и Ролями связь многие-ко-многим, и между Ролями и Разрешениями многие-ко-многим.
Такая модель эффективна для небольших приложений, где роли определены и зачастую совпадают с ролями в процессе, а разрешения выдаются на некоторые действия.
Фактически Роль определяет как группу пользователей, так и что они могут делать.

Настраиваемая RBS

Тут роли разделяются на группы пользователей и наборы разрешений. Пользователи связаны многие-ко-многим с группами, группы — многие-ко-многим с наборами разрешений, наборы разрешений — многие-ко-многим разрешениями.
Разрешения также выдаются на некоторые действия, но при большом количестве удобнее управлять согласованными наборами разрешений, а не отдельными разрешениями. Наборы разрешений можно изменять в процессе работы программы, как и группы пользователей.
По опыту такая система сложнее классической раз в пять. Поэтому без необходимости городить не стоит.

Claims-based security

Почти тоже самое что и предыдущий пункт, только вместо групп пользователей используются claim_ы которые могу иметь разный тип (имеется ввиду бизнес-тип). Типичные claims - это имя пользователя, группы (может быть несколько), email, в бизнес-приложениях это также может быть имя менеджера, отдел итп.
Таким образом пользователи имеет много claim_ов, каждый claim определяется парой (тип, значение), набор разрешений привязывается к набору claim_ов.

А теперь самое интересное. Система разграничения прав в приложениях с CBS часто оказывается проще, чем RBS в настраиваемом вида. RBS заставляет плодить много групп, в том числе автоматически. При использовании CBS от этого можно совсем отказаться, а сам механизм получения claim_ов вынести отдельно, что уменьшит сложность самого приложения (например Windows Identity Foundation так и работает, и позволяет сделать claims-provider, работающий с AD).
Кроме того возможен fallback к RBS — достаточно один из типов claim_ов объявить группой\ролью.


Это неполный список, всегда можно придумать вариации.

Все выше касалось ограничения на выполнение функций системы, теперь мы хотим защищать доступ к объектам в системе.

Поначалу все просто : связь роль-разрешения, группа-группа разрешений, набор claim_ов — группа разрешений становится тренарной, то есть к каждой связи добавляет атрибут — ссылка на объект. Тогда сама связь, указывающая что для данного объекта данная роль имеет указанные разрешения становится неким ACE (Access Control Entry из терминологии Windows), множество ACE, сгруппированных по ссылке на объект, называются ACL (Access Control List).


Вроде все просто, но при построении такой системы надо решить несколько принципиальных вопросов:

  1. Вычисление "эффективных разрешений". При наличии иерархии защищаемых объектов надо уметь однозначно получать разрешения которые действуют для данного объекта, с учетом разрешений вышестоящих объектов. Прием если связи между объектами имеют циклы, то однозначность становится проблемой.
  2. Владение объектом. Зачастую при вычислении возможности доступа нужно учитывать кто создал этот объект и как авторство объекта влияет на эффективные разрешения.
  3. Как с учетом вышесказанного сделать чтобы это быстро работало. Проверка разрешений и так усложняет выборки данных, а если еще требуется вычислять эффективные разрешения с учетом иерархии, то это может очень сильно сказаться на производительности.

Часто такую систему называют Row-Level Securty, потому что проверки реализуются на уровне строк БД, иначе очень медленно будет.



IQueryable и Generics

Довольно часто различные сущности данных обладают сходными свойствами и обрабатываются одинаково. При обработке объектов в программе общность  выражается интерфейсами, а обработка производится обобщенными методами (generics).

При использовании Linq можно написать аналогично

/// <summary>
/// Интерфейс для всех сущностей,
/// которые могут не показываться позьзователю
/// </summary>
public interface IVisible
{
    bool Visible { get; set; }
}

/// <summary>
/// Выбор только видимых сущностей
/// </summary>
public static class IVisibleExtensions
{
    public static IQueryable<T> Visible<T>(this IQueryable<T> q) 
        where T : IVisible
    {
        return q.Where(o => o.Visible);
    }
}

Потом можно создать модель данных (я использую EF), сделать сущности с полем Visible и с помощью partial-класса «прицепить» интерфейс к сущности

/// <summary>
/// Какая-то сущность
/// </summary>
public partial class Entity1 : IVisible
{
}

Теперь тестовый код…

static void Main(string[] args)
{
    var context = new Model1Container();
    foreach (var item in context.Entity1Set.Visible())
    {
        Console.WriteLine(item);
    }
}

Этот код отваливается с ошибкой Unable to cast the type 'Entity1' to type 'IVisible'. LINQ to Entities only supports casting Entity Data Model primitive types.

Проблема заключается в том что выражения o.Visible внутри generic метода Visible<T> преобразуется в expression tree вида
((IVisible)o).Visible. Linq2EF (как и другие Linq-провайдеры) не понимают что делать с типом IVisible и генерация SQL-выражения падает.

Можно конечно собирать expression нужного вида руками для каждого обобщенного метода обработки запроса, но это не наш путь.

Лучше написать метод, который устраняет очевидно лишние приведения типов в expression. Для этого сделаем extension-метод Fix.

public static IQueryable<T> Fix<T>(this IQueryable<T> q)
{
    var visitor = new FixupVisitor();
    return q.Provider.CreateQuery<T>(visitor.Visit(q.Expression));
}

Теперь осталось написать FixupVisitor. В .NET 4 включен класс ExpressionVisitor в пространстве имен System.Linq.Expressions, который поддерживает в том числе расширенные деревья выражений. Для .NET 3.5 можно взять IQToolkit.

internal class FixupVisitor: ExpressionVisitor
{
    protected override Expression VisitUnary(UnaryExpression u)
    {
        if (u.NodeType != ExpressionType.Convert)
        {
            return base.VisitUnary(u);
        }

        var operandType = u.Operand.Type;
        var expressionType = u.Type;
        if (expressionType.IsInterface 
            && operandType.GetInterfaces()
                          .Contains(expressionType))
        {
            return base.Visit(u.Operand);
        }
        else
        {
            return base.VisitUnary(u);
        }
    }
}

Этот визитор просто выкидывает избыточное приведение типа к интерфейсу.

Еще один тестовый код

static void Main(string[] args)
{
    var context = new Model1Container();
    foreach (var item in context.Entity1Set.Visible().Fix())
    {
        Console.WriteLine(item);
    }
}

Добавился только вызов Fix в конце и все работает.

Продолжение следует…



Аудит изменений с учетом контекста операций

В предыдущем посте я описал способ, который позволяет проводить аудит изменений данных в Entity Framework, не затрагивая сами классы сущностей.

Аудит изменений данных вполне возможно делать в самой БД с помощью триггеров, но в базу не попадают сведения о том какой пользователь системы произвел изменения (в случае приложения, построенного по принципу trusted subsystem) и с какой целью были проведены эти изменения.

Для каждой операции работы изменения данных существует некоторый контекст, который включает в себя как некоторые явные параметры, как имя пользователя или URL, так и неявные, например намерения, с которыми были выполнены изменения данных (частично их можно восстановить по стеку вызовов).

Для задач логирования было бы удобно иметь доступ к некоторым параметрам контекста, особенно неявным. Эти параметры должны прозрачно передаваться по цепочке вызовов. Для решения таких задач можно применить монады, но тогда придется переписать весь код под использование монад, что очень проблематично. Можно воспользоваться возможностями AOP и IoC чтобы обеспечить неявную передачу явного контекста.

Для начала нам понадобится класс для хранения текущего контекста. Как и в случае со стеком вызовов, контексты тоже могут составлять некоторый стек.

public interface IContextManager<T>
{
    void Push(T value);
    void Revert();
    IEnumerable<T> GetValues();
}

Реализация такого интерфейса тривиальна:

public class ContextManager<T>: IContextManager<T>
{
    Stack<T> _stack = new Stack<T>();

    public void Push(T value)
    {
        _stack.Push(value);
    }

    public void Revert()
    {
        _stack.Pop();
    }

    public IEnumerable<T> GetValues()
    {
        foreach (var item in _stack)
        {
            yield return item;
        }
    }
}

 

Далее с помощью IoC контейнера создается один экземпляр этого класса, который живет в течение обработки одного запроса. Этот экземпляр инжектится в каждый класс, заинтересованный в установке или получении текущего контекста.

try
{
    _contextManager.Push(contextValue);
    //do actions
}
finally
{
    _contextManager.Revert();
}

Естественно каждый раз писать кучу try\finally не хочется, а учитывая что контекст в большинстве случаев статический, то хотелось бы задавать его декларативно и в одном месте.

Тут на помощь приходит AoP. Можно написать небольшой обработчик вызовов и с помощью Unity Interception устанавливать текущий контекст в зависимости от атрибута.

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class ContextAttribute : HandlerAttribute
{
    public ContextAttribute(object value)
    {
        this.Value = value;
    }

    public object Value { get; private set; }

    public override ICallHandler CreateHandler(IUnityContainer container)
    {
        var method = this.GetType().GetMethod("CreateHandlerInternal", BindingFlags.Instance | BindingFlags.NonPublic);
        return (ICallHandler)method.MakeGenericMethod(Value.GetType()).Invoke(this, new object[] { container });
    }

    private ContextCallHandler<T> CreateHandlerInternal<T>(IUnityContainer container)
    {
        return new ContextCallHandler<T>(container.Resolve<IContextManager<T>>(), (T)this.Value);
    }

}

public class ContextCallHandler<T>: ICallHandler
{
    IContextManager<T> _manager;
    T _value;

    public ContextCallHandler(IContextManager<T> manager, T value)
    {
        _manager = manager;
        _value = value;
    }

    public IMethodReturn Invoke(IMethodInvocation input, GetNextHandlerDelegate getNext)
    {
        _manager.Push(_value);
        var result = getNext()(input, getNext);
        _manager.Revert();
        return result;
    }
    public int Order { get; set; }
}

 

Теперь навешивая атрибут [Context(value)] можно задавать контекст метода. А заинжектив в ObjectContext экземпляр типа IContextManager<T> можно при логировании получать текущее значение контекста, в случае если SaveChanges вызывается в самом конце цепочки вызовов.

Для того чтобы все работало надо правильно сконфигурировать контейнер как описано в этом посте.



Аудит изменений в Entity Framework

В Entity Framework v1 есть возможность подписаться на событие SavingChanges. Это событие вызывается перед записью в базу когда вызывается метод SaveChanges. Чтобы получить все измененные записи в контексте необходимо обратиться к свойству ObjectStateManager контекста и вызвать его метод GetObjectStateEntries.

Ниже приведен код partial класса контекста, который сохраняет изменения сущностей.

 

   1: partial void OnContextCreated()
   2: {
   3:     this.SavingChanges += new EventHandler(AuditLogHandler);
   4: }
   5:  
   6: void AuditLogHandler(object sender, EventArgs _)
   7: {
   8:     var entries = this.ObjectStateManager
   9:                       .GetObjectStateEntries(EntityState.Added 
  10:                                            | EntityState.Deleted 
  11:                                            | EntityState.Modified)
  12:                       .Where(e => !e.IsRelationship)
  13:                       .ToList()
  14:                       ;
  15:  
  16:     var tranId = Guid.NewGuid();
  17:     var now = DateTime.Now;
  18:  
  19:     var logEntries = from e in entries
  20:                      select new AuditLogEntry
  21:                      {
  22:                          TableName = e.EntitySet.Name,
  23:                          Action = (byte)e.State,
  24:                          Time = now,
  25:                          TransactionId = tranId,   
  26:                          Key = KeyToString(e.EntityKey),
  27:                          Values = StateEntryToXml(e)
  28:                      };
  29:     SaveLog(logEntries);
  30: }
  31:  
  32: string KeyToString(EntityKey key)
  33: {
  34:     if (key == null || key.IsTemporary)
  35:     {
  36:         return null;
  37:     }
  38:     if (key.EntityKeyValues.Length == 1)
  39:     {
  40:         return key.EntityKeyValues[0].Value.ToString();
  41:     }
  42:     else
  43:     {
  44:         return string.Join("; ",
  45:                    key.EntityKeyValues
  46:                       .Select(k => k.Key + "=" + k.Value)
  47:                       .ToArray());
  48:     }
  49: }
  50:  
  51: private string StateEntryToXml(ObjectStateEntry e)
  52: {
  53:     switch (e.State)
  54:     {
  55:         case EntityState.Added:
  56:             var count = e.CurrentValues.FieldCount;
  57:             return new XElement("Values",
  58:                        from i in Enumerable.Range(0, count)
  59:                        select new XElement("Value",
  60:                            new XAttribute("Name",
  61:                                e.CurrentValues.GetName(i)),
  62:                            e.CurrentValues.GetValue(i)))
  63:                    .ToString();
  64:         case EntityState.Deleted:
  65:             return null;
  66:         case EntityState.Modified:
  67:             return new XElement("Values",
  68:                        from v in e.GetModifiedProperties()
  69:                        let ord = e.OriginalValues.GetOrdinal(v)
  70:                        select new XElement("Value",
  71:                            new XAttribute("Name", v),
  72:                            new XAttribute("Old",
  73:                                e.OriginalValues.GetValue(ord)),
  74:                            e.CurrentValues.GetValue(ord)))
  75:                    .ToString();
  76:         default:
  77:             throw new InvalidOperationException();
  78:     }
  79: }

Таким же способом можно поддерживать Row-Level Security при обновлении данных с помощью EF.