Доступ к данным в многопользовательских приложениях +14

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

  1. ограничение доступа к данным для пользователей не прошедших аутентификацию
  2. ограничение доступа к данным для аутентифицированных, но не обладающих необходимыми привелегиями пользователей
  3. предотвращение несанкционированного доступа с помощью прямых обращений к API
  4. фильтрация данных в поисковых запросах и списковых элементах UI (таблицы, списки)
  5. предотвращение изменения данных, принадлежащих одному пользователю другими пользователями

Сценарии 1-3 хорошо описаны и обычно решаются с помощью встроенных средств фреймворков, например role-based или claim-based авторизации. А вот ситуации, когда авторизованный пользователь может по прямому url получить доступ к данным «соседа» или совершить действие в его аккаунте случаются сплошь и рядом. Происходит это чаще всего из-за того что программист забывает добавить необходимую проверку. Можно понадеяться на код-ревью, а можно предотвратить такие ситуации применив глобальные правила фильтрации данных. О них и пойдет речь в статье.

Списки и таблицы


Типовой контроллер для получения данных в ASP.NET MVC может выглядеть как-то так:

        [HttpGet]
        public virtual IActionResult Get([FromQuery]T parameter)
        {
            var total =  _dbContext
                .Set<TEntity>()
                .Where(/* some business rules */)
                .Count();

            var items=  _dbContext
                .Set<TEntity>()
                .Where(/* some business rules */)        
                .ProjectTo<TDto>()
                .Skip(parameter.Skip)
                .Take(parameter.Take)
                .ToList();
            
            return Ok(new {items, total});
        }

В данном случае вся ответственность за фильтрацию данных ложится только на программиста. Вспомнит ли он о том, что необходимо добавить условие в Where или нет?

Можно решить проблему с помощю глобальных фильтров. Однако, для ограничения доступа нам потребуется информация о текущем пользователе, а значит конструирование DbContext придется усложнить, чтобы проинициализировать конкретные поля.

Если правил много, то реализации DbContext неизбежно придется узнать «слишком много», что приведет к нарушению принципа единственной ответственности.

Слоеная архитектура


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

Добавляем абстракцию


В .NET для доступа к данным уже есть IQueryable. Заменим прямой доступ к DbContext на доступ вот к такому провайдеру:

    public interface IQueryableProvider        
    {
        IQueryable<T> Query<T>() where T: class;
        
        IQueryable Query(Type type);
    }

А для доступа к данным сделаем вот такой фильтр:

    public interface IPermissionFilter<T>
    {
        IQueryable<T> GetPermitted(IQueryable<T> queryable);
    }

Реализуем провайдер таким образом, чтобы он искал все объявленные фильтры и автоматически применял их:

     public class QueryableProvider: IQueryableProvider
     {
        // ищем фильтры и запоминаем их типы
        private static Type[] Filters = typeof(PermissionFilter<>)
            .Assembly
            .GetTypes()
            .Where(x => x.GetInterfaces().Any(y =>
                y.IsGenericType && y.GetGenericTypeDefinition() 
                    == typeof(IPermissionFilter<>)))
            .ToArray();
                
        private readonly DbContext _dbContext;
        private readonly IIdentity _identity;

        public QueryableProvider(DbContext dbContext, IIdentity identity)
        {
            _dbContext = dbContext;
            _identity = identity;
        }
        
        private static MethodInfo QueryMethod = typeof(QueryableProvider)
            .GetMethods()
            .First(x => x.Name == "Query" && x.IsGenericMethod);

        private IQueryable<T> Filter<T>(IQueryable<T> queryable)
           => Filters
                // ищем фильтры необходимого типа 
                .Where(x => x.GetGenericArguments().First() == typeof(T))
                // создаем все фильтры подходящего типа и применяем к Queryable<T> 
                .Aggregate(queryable, 
                   (c, n) => ((dynamic)Activator.CreateInstance(n, 
                       _dbContext, _identity)).GetPermitted(queryable));
        
        public IQueryable<T> Query<T>() where T : class 
            => Filter(_dbContext.Set<T>());

        // из EF Core убрали Set(Type type), приходится писать самому :(
        public IQueryable Query(Type type)
            => (IQueryable)QueryMethod
                .MakeGenericMethod(type)
                .Invoke(_dbContext, new object[]{});
    }

Код получения и создания фильтров в примере не оптимален. Вместо Activator.CreateInstance а лучше использовать скомпилированные Expression Trees. В некоторых IOC-контейнерах реализованна поддержка регистрации открытых generic'ов. Я оставлю вопросы оптимизации за рамками этой статьи.

Реализуем фильтры


Реализация фильтра может выглядеть, например, так:

     public class EntityPermissionFilter: PermissionFilter<Entity>
     {
        public EntityPermissionFilter(DbContext dbContext, IIdentity identity)
            : base(dbContext, identity)
        {
        }

        public override IQueryable<Practice> GetPermitted(
            IQueryable<Practice> queryable)
        {
            return DbContext
                .Set<Practice>()
                .WhereIf(User.OrganizationType == OrganizationType.Client,
                    x => x.Manager.OrganizationId == User.OrganizationId)
                .WhereIf(User.OrganizationType == OrganizationType.StaffingAgency,
                    x => x.Partners
                        .Select(y => y.OrganizationId)
                        .Contains(User.OrganizationId));
        }
    }

Исправляем код контроллера


        [HttpGet]
        public virtual IActionResult Get([FromQuery]T parameter)
        {
            var total = QueryableProvider
                .Query<TEntity>()
                .Where(/* some business rules */)
                .Count();

            var items = QueryableProvider
                .Query<TEntity>()
                .Where(/* some business rules */)        
                .ProjectTo<TDto>()
                .Skip(parameter.Skip)
                .Take(parameter.Take)
                .ToList();
            
            return Ok(new {items, total});
        }

Изменений совсем не много. Осталось запретить прямой доступ к DbContext из контроллеров и если фильтры правильно написаны, то вопрос доступа к данным можно считать закрытым. Фильтры достаточно маленькие, поэтому покрыть их тестами не составит труда. Кроме того эти-же самые фильтры можно использовать, чтобы написать код авторизации, предотвращающий несанкционированный доступ к «чужим» данным. Этот вопрос я оставлю для следующей статьи.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (11):

  1. Dansoid
    /#18806575 / +2

    А можно спросить, почему так все сложно?
    Кто вам мешает понаделывать экстеншинов к контексту которые возвращают IQueryable?

    Context.GetPermittedUsers();
    Context.GetPermittedOrders(BusinessRole.Manager);

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

    • marshinov
      /#18806639

      Предложенный вами подход имеет преимущество: он более явный. С другой стороны вам придется теперь везде писать Context.GetPermittedUsers(). Как проконтролировать, что другой разработчик по ошибке не вызовет Context.Users? В варианте с дополнительным интерфейсом можно вообще не подключать EF к web-проекту и работать только через слой бизнес-логики. Еще на extension'ы не повесить декораторы.

      • Sybe
        /#18807129 / +1

        Но ведь веб-проект является composition root, и подключить слой доступа для регистрации всего в DI контейнере всё равно придётся?

        • marshinov
          /#18807431

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

      • Dansoid
        /#18807201

        Но ведь тепереь вам всюду придется вызвыавать QueryableProvider? Как вы этот контракт будете контролировать? Я бы во время code review просто глянул кто напрямую использует DbSet и почесал бы себя по затылку, а потом взял бы чесалку чтобы кого-то почесать.
        Не усложняйте себе и другим жизнь. Все должно быть явно и легко трекаться средствами разработки.
        Global filters, еще та палка о двух концах. Мило, но бесполезно. Отрубить фильтр может каждый, а вот оттрекать это нереально.

        • marshinov
          /#18807435

          Что вы имеете в виду, когда говорите «оттрекать»?

          • Dansoid
            /#18807495

            Найти в каком месте фильтр был отключен для специфического DbSet.

            • marshinov
              /#18807693

              Использование разных интерфейсов эту проблему решает на 100%: используете DbSet напрямую — фильтров нет. Используете абстракцию — фильтры есть. Если используете глобальные фильтры DbContext.Set<T>() — с фильтрами, DbContext.Set<T>().IgnoreQueryFilters() — без.

              • Dansoid
                /#18807815 / +1

                Я не специалист в EF, но насколько я знаю, прочитав их спецификацию, использование IgnoreQueryFilters отрубает фильтры во всем запросе. Поправьте меня если я не прав.
                Для меня это выглядит как: мы вам даем сомнительную возможность отфильтровать гарантировано, но оставили лазейку. И кто-то таки выстрелит себе в ногу.

                Как насчет нарезать доступ контролировано, я про свой сампл Context.GetPermittedOrders(BusinessRole.Manager)? Ваше же решение режет энтити на корню.

                Имея большой опыт разработки, дам простой совет: чем проще, тем лучше. И в поддержке и в выявлении багов. Как раз разбираюсь с одним багом, который неявно вытекает из-за использования сомнительного решения по трансформациии дерева выражений перед отправкой его Query Provider. До сих пор теряюсь в догадках — зачем! Чем меньше динамики, тем приложение стабильнее.

  2. ghost404
    /#18811009 / +1

    Я в подобных случаях использую спецификации

    • marshinov
      /#18811373 / -1

      Да, можно решать спецификациями, но это не гарантирует, что разработчик не забудет дописать queryable.Where(spec).