Data Access in Multi-User Applications

The issue of restricting access to data arises when developing multi-user systems almost always. The main scenarios are as follows:

  1. data access restriction for non-authenticated users
  2. data access restriction for authenticated but not having the necessary user privileges
  3. prevent unauthorized access with direct API calls
  4. filtering data in search queries and list elements UI (tables, lists)
  5. Prevent other users from changing data owned by one user.

Scenarios 1-3 are well described and are usually solved using built-in framework tools, such as role-based or claim-based authorizations. But situations where an authorized user can access the data of the “neighbor” via a direct url or take an action in his account all the time. This happens most often due to the fact that the programmer forgets to add the necessary checks. You can rely on code review, or you can prevent such situations by applying global data filtering rules. About them will be discussed in the article.

Lists and tables


A typical controller for receiving data in ASP.NET MVC may look something like this :

[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}); } 

In this case, all responsibility for filtering data lies only with the programmer. Will he remember that it is necessary to add a condition in Where or not?

You can solve the problem with the help of global filters . However, to restrict access, we will need information about the current user, which means that the construction of DbContext will have to be complicated in order to initialize specific fields.

If there are many rules, then the implementation of DbContext will inevitably have to learn "too much", which will lead to a violation of the principle of sole responsibility .

Puff architecture


Problems with data access and copy-paste arose, because in the example we ignored the separation into layers and from the controllers immediately reached for the data access layer, bypassing the business logic layer. Such an approach was even dubbed " thick, stupid, ugly controllers ." In this article, I don’t want to touch on issues related to repositories, services, and structuring business logic. Global filters do a good job with this task; you just need to apply them to an abstraction from another layer.

Add an abstraction


In .NET, data access already has IQueryable . Let's replace direct access to DbContext to access such provider here:

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

And for data access, we will do this filter:

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

We implement the provider in such a way that it searches for all the declared filters and automatically applies them:

  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[]{}); } 

The code for obtaining and creating filters in the example is not optimal. Instead of Activator.CreateInstance it is better to use compiled Expression Trees . Some IOC containers have implemented support for registering open generics . I will leave optimization issues beyond the scope of this article.

Implement filters


A filter implementation might look like this:

  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)); } } 

We correct the controller code


  [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}); } 

There are not many changes at all. It remains to prohibit direct access to DbContext from the controllers and if the filters are correctly written, then the issue of data access can be considered closed. The filters are quite small, so it’s easy to cover them with tests. In addition, these same filters can be used to write an authorization code that prevents unauthorized access to "alien" data. I will leave this question for the next article.

Source: https://habr.com/ru/post/414897/


All Articles