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

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

    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 из контроллеров и если фильтры правильно написаны, то вопрос доступа к данным можно считать закрытым. Фильтры достаточно маленькие, поэтому покрыть их тестами не составит труда. Кроме того эти-же самые фильтры можно использовать, чтобы написать код авторизации, предотвращающий несанкционированный доступ к «чужим» данным. Этот вопрос я оставлю для следующей статьи.

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    А как вы управляете доступом к данным?
    Поделиться публикацией
    Похожие публикации
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 16
      +2
      А можно спросить, почему так все сложно?
      Кто вам мешает понаделывать экстеншинов к контексту которые возвращают IQueryable?
      Context.GetPermittedUsers();
      Context.GetPermittedOrders(BusinessRole.Manager);

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

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

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

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

                0
                Найти в каком месте фильтр был отключен для специфического DbSet.
                  0
                  Использование разных интерфейсов эту проблему решает на 100%: используете DbSet напрямую — фильтров нет. Используете абстракцию — фильтры есть. Если используете глобальные фильтры DbContext.Set<T>() — с фильтрами, DbContext.Set<T>().IgnoreQueryFilters() — без.
                    +1
                    Я не специалист в EF, но насколько я знаю, прочитав их спецификацию, использование IgnoreQueryFilters отрубает фильтры во всем запросе. Поправьте меня если я не прав.
                    Для меня это выглядит как: мы вам даем сомнительную возможность отфильтровать гарантировано, но оставили лазейку. И кто-то таки выстрелит себе в ногу.

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

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

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

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

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


              А что бы не забывать применить спецификации, давайте им понятные названия и объединяйте их в большие. Например, в вашем случае, можно создать спецификации ClientOrganizationsSpec и StaffingAgencyOrganizationsSpec и объединить в общую UserOrganizationsSpec.
              Остальные правила которые вы описываете в Where, так же можно описать через спецификации и в результате в контроллере вы будете передавать 1-3 спецификации.


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

                0
                Вообще, ограничения доступа которые вы описали в статье, это больше похоже на бизнесовые правила и им место скорее в спецификациях, а не на глобальном уровне. Применяя их на глобальном уровне вы можете столкнутся с ситуацией когда вам нужно получить данные не применяя эти правила.
                Да и тогда нужно будет обращаться к другому контексту, который возвращает не «отфильтрованный» IQueryable. Кроме того этот случай решается «супер-пользователем», который видит все и может логиниться от имени кого угодно. Де-факто такой пользователь почти всегда появляется:)

                На самом деле ничего не мешает использовать спецификации внутри таких фильтров. Я просто не стал усложнять код в примере.
                На мой взгляд, проще ориентироваться в наборе спецификаций имеющих четкое название и четкое назначение чем в куче условий в Where. Тем более что со спецификациями вы уже имели дело.
                Да, но если эти данные вообще не предназначены для данного пользователя зачем ему вообще иметь возможность их видеть? Неявность имеет не только плохую, но и хорошую сторону. В некоторых случаях хорошо, что прикладной разработчик не заботится о доступе. Снижает нагрузку на мозг.

                Кстати говоря, как ваше впечатление от них спустя год?
                Короткий ответ — отлично. Если хотите длинный ответ, то <a href=«www.youtube.com/watch?v=DD3w66Ff8Ms&t=948s.
                  +1
                  Кроме того этот случай решается «супер-пользователем»

                  На мой взгляд, "супер-пользователь" это серьезный удар по безопасности.
                  В своей практике я не припомню случаев необходимости в таких пользователях.


                  • Персональный пользователь с правами администратора – Да
                  • Временный пользователь для функциональных тестов – Да
                  • Пользователь с ограниченными правами для ботов – Да

                  Но не супер-пользователь.


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

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


                  Конечно можно применять фильтры и на глобальном уровне в неявном виде. Иногда я тоже так делаю, но лично я все равно придерживаюсь мнения, что явное лучше неявного.


                  Короткий ответ — отлично. Если хотите длинный ответ, то вот он.

                  Спасибо за доклад. Было интересно послушать.

                    +1
                    В своей практике я не припомню случаев необходимости в таких пользователях.

                    Техподдержка Azure или YouTrack, например, чудесным образом имеет доступ к моим данным в облаке и помогает решать проблемы, когда что-то не работает;)
                      0

                      То есть, доступ к данным имеет их владелец или пользователь техподдержки. Получается это тоже бизнес правило, которое также можно закладывать в условия.

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

          Самое читаемое