Вопрос ограничения доступа к данным встает при разработке многопользовательских систем почти всегда. Основные сценарии следующие:
Сценарии 1-3 хорошо описаны и обычно решаются с помощью встроенных средств фреймворков, например role-based или claim-based авторизации. А вот ситуации, когда авторизованный пользователь может по прямому url получить доступ к данным «соседа» или совершить действие в его аккаунте случаются сплошь и рядом. Происходит это чаще всего из-за того что программист забывает добавить необходимую проверку. Можно понадеяться на код-ревью, а можно предотвратить такие ситуации применив глобальные правила фильтрации данных. О них и пойдет речь в статье.
Типовой контроллер для получения данных в ASP.NET MVC может выглядеть как-то так:
В данном случае вся ответственность за фильтрацию данных ложится только на программиста. Вспомнит ли он о том, что необходимо добавить условие в
Можно решить проблему с помощю глобальных фильтров. Однако, для ограничения доступа нам потребуется информация о текущем пользователе, а значит конструирование
Если правил много, то реализации
Проблемы с доступом к данным и копипастой возникли, потому что в примере мы проигнорировали разделение на слои и из контроллеров сразу потянулись к слою доступа к данным, минуя слой бизнес-логики. Такой подход даже окрестили «толстыми тупыми уродливыми контроллерами». В этой статье я не хочу касаться вопросов, связанных с репозиториями, сервисами и структурированием бизнес-логики. Глобальные фильтры хорошо справляются с этой задачей, нужно только применить их к абстракции из другого слоя.
В .NET для доступа к данным уже есть
А для доступа к данным сделаем вот такой фильтр:
Реализуем провайдер таким образом, чтобы он искал все объявленные фильтры и автоматически применял их:
Код получения и создания фильтров в примере не оптимален. Вместо
Реализация фильтра может выглядеть, например, так:
Изменений совсем не много. Осталось запретить прямой доступ к
- ограничение доступа к данным для пользователей не прошедших аутентификацию
- ограничение доступа к данным для аутентифицированных, но не обладающих необходимыми привелегиями пользователей
- предотвращение несанкционированного доступа с помощью прямых обращений к API
- фильтрация данных в поисковых запросах и списковых элементах UI (таблицы, списки)
- предотвращение изменения данных, принадлежащих одному пользователю другими пользователями
Сценарии 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
из контроллеров и если фильтры правильно написаны, то вопрос доступа к данным можно считать закрытым. Фильтры достаточно маленькие, поэтому покрыть их тестами не составит труда. Кроме того эти-же самые фильтры можно использовать, чтобы написать код авторизации, предотвращающий несанкционированный доступ к «чужим» данным. Этот вопрос я оставлю для следующей статьи.
Dansoid
А можно спросить, почему так все сложно?
Кто вам мешает понаделывать экстеншинов к контексту которые возвращают IQueryable?
Все аккуратненько, в одном месте без дополнительных абстракций, глобальности, сайд эффектов и легко трекается решарпером.
marshinov Автор
Предложенный вами подход имеет преимущество: он более явный. С другой стороны вам придется теперь везде писать
Context.GetPermittedUsers()
. Как проконтролировать, что другой разработчик по ошибке не вызоветContext.Users
? В варианте с дополнительным интерфейсом можно вообще не подключатьEF
к web-проекту и работать только через слой бизнес-логики. Еще на extension'ы не повесить декораторы.Sybe
Но ведь веб-проект является composition root, и подключить слой доступа для регистрации всего в DI контейнере всё равно придётся?
marshinov Автор
Если ну очень хочется, то регистрацию можно вынести в отдельную сборку и подключить к хосту уже ее. Бывает полезно, если хостов много. В общем случае вы правы.
Dansoid
Но ведь тепереь вам всюду придется вызвыавать QueryableProvider? Как вы этот контракт будете контролировать? Я бы во время code review просто глянул кто напрямую использует DbSet и почесал бы себя по затылку, а потом взял бы чесалку чтобы кого-то почесать.
Не усложняйте себе и другим жизнь. Все должно быть явно и легко трекаться средствами разработки.
Global filters, еще та палка о двух концах. Мило, но бесполезно. Отрубить фильтр может каждый, а вот оттрекать это нереально.
marshinov Автор
Что вы имеете в виду, когда говорите «оттрекать»?
Dansoid
Найти в каком месте фильтр был отключен для специфического DbSet.
marshinov Автор
Использование разных интерфейсов эту проблему решает на 100%: используете
DbSet
напрямую — фильтров нет. Используете абстракцию — фильтры есть. Если используете глобальные фильтрыDbContext.Set<T>()
— с фильтрами,DbContext.Set<T>().IgnoreQueryFilters()
— без.Dansoid
Я не специалист в EF, но насколько я знаю, прочитав их спецификацию, использование IgnoreQueryFilters отрубает фильтры во всем запросе. Поправьте меня если я не прав.
Для меня это выглядит как: мы вам даем сомнительную возможность отфильтровать гарантировано, но оставили лазейку. И кто-то таки выстрелит себе в ногу.
Как насчет нарезать доступ контролировано, я про свой сампл Context.GetPermittedOrders(BusinessRole.Manager)? Ваше же решение режет энтити на корню.
Имея большой опыт разработки, дам простой совет: чем проще, тем лучше. И в поддержке и в выявлении багов. Как раз разбираюсь с одним багом, который неявно вытекает из-за использования сомнительного решения по трансформациии дерева выражений перед отправкой его Query Provider. До сих пор теряюсь в догадках — зачем! Чем меньше динамики, тем приложение стабильнее.