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. restriction of access to data 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 whether it is necessary to add a condition in Whereor 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 construction DbContextwill have to be complicated in order to initialize specific fields.

    If there are many rules, then the implementation DbContextwill 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 for data access is already there IQueryable. Let's replace direct access to DbContextaccess to such provider here:

    publicinterfaceIQueryableProvider        
        {
            IQueryable<T> Query<T>() where T: class;
            IQueryable Query(Type type);
        }

    And for data access, we will do this filter:

    publicinterfaceIPermissionFilter<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:

    publicclassQueryableProvider: IQueryableProvider
         {
            // ищем фильтры и запоминаем их типыprivatestatic Type[] Filters = typeof(PermissionFilter<>)
                .Assembly
                .GetTypes()
                .Where(x => x.GetInterfaces().Any(y =>
                    y.IsGenericType && y.GetGenericTypeDefinition() 
                        == typeof(IPermissionFilter<>)))
                .ToArray();
            privatereadonly DbContext _dbContext;
            privatereadonly IIdentity _identity;
            publicQueryableProvider(DbContext dbContext, IIdentity identity)
            {
                _dbContext = dbContext;
                _identity = identity;
            }
            privatestatic 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, newobject[]{});
        }

    The code for obtaining and creating filters in the example is not optimal. Instead, Activator.CreateInstanceit 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:

    publicclassEntityPermissionFilter: PermissionFilter<Entity>
         {
            publicEntityPermissionFilter(DbContext dbContext, IIdentity identity)
                : base(dbContext, identity)
            {
            }
            publicoverride 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]
            publicvirtual 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 DbContextthe 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.

    Only registered users can participate in the survey. Sign in , please.

    How do you manage data access?


    Also popular now: