⬆️ ⬇️

We authorize resources in the REST service

In the ASP.NET world, there are powerful and flexible authorization mechanisms. For example, ASP.NET Core 2.0 provides the developer with the ability to use authorization policies , handlers , etc.



But how to implement the GET method, which returns a list of resources? And if this method also has to return not all the resources, but only the specified page? Each user should see only those resources to which he has access. You can get a full list from the database each time and then filter it based on the rights of the current user, but this will be too inefficient - the amount of resources can be very large. It is preferable to resolve issues of authorization and pagination at the database query level.



This article describes an approach to solving authorization problems in a REST service based on ASP.NET Web API 2 using the Entity Framework.



Task



Suppose we are developing a website that allows you to host various resources, such as text documents. We have a REST service that performs CRUD operations on these documents. The task of authentication, that is, determining the authenticity of the user, has already been solved. Users in our system may have different roles. We assume that we have two types of users: administrators and ordinary users.

')

Now we are faced with the task of authorization - giving the user rights to perform certain actions on documents. We want each user who posted a document to then dynamically control the access of other users to this document.



Getting started



So, users are divided into two types: administrators and regular users. Administrators have maximum rights to access to any document, ordinary users have maximum rights to their documents and the rights granted to others. We assume that there are three powers: read, write (change) and delete a document: Read , Write and Delete . Each subsequent authority includes the previous one, i.e. Write includes Read , and Delete includes Write and Read .



First of all, we need to add a new table to the database for storing permissions.







Here, the ObjectId is the resource identifier, ObjectType is the type of the resource, UserId is the Id of the user, and finally Permission is the credentials.



Add the necessary definitions:



public enum ObjectType { // May grow in the future Document } public enum Permission { None = 0, Read = 1, Write = 2, Delete = 3 } public enum Role { Administrator, User } 


When adding a new resource, an entry with maximum permissions for the user who created the resource should appear in the Permissions table. The easiest way to do this is with a DB trigger. We assume that we have the columns Id (document ID) and CreatedBy (ID of the user who created the document) in the Documents table. Add a new trigger to the Documents table:



 CREATE TRIGGER [dbo].[TR_Documents_Insert] ON [dbo].[Documents] FOR INSERT AS BEGIN INSERT INTO Permissions(ObjectId, ObjectType, UserId, Permission) SELECT inserted.Id, 1, -- ObjectType.Document inserted.CreatedBy, 3 -- Permission.Delete FROM inserted END 


Thus, we will automatically have the Delete authority for the creator of the document.



You can also add a trigger to delete:



 CREATE TRIGGER [dbo].[TR_Documents_Delete] on [dbo].[Documents] FOR DELETE AS BEGIN DELETE FROM Permissions WHERE ObjectId IN (SELECT ID FROM deleted) AND ObjectType = 1 END 


At first glance, it seems that it is redundant to store administrator rights in the database, since the administrator already has full rights to any document. What happens if you remove admin privileges in the client-side rights editor? - for the administrator, nothing will change. There is a temptation to process administrator rights in a special way, say, not to add an entry to the database or not to show its rights in the editor.



Nevertheless, it is better to use the general approach. What happens if the administrator suddenly ceases to be an administrator and goes into the category of regular users?



Model



We use the Entity Framework. Classes and data model interfaces look like this:



 public class Document { [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public long Id { get; set; } public int CreatedBy { get; set; } public string Source { get; set; } } public class UserPermission { [Key] [Column(Order = 1)] public long ObjectId { get; set; } [Key] [Column(Order = 2)] public byte ObjectType { get; set; } [Key] [Column(Order = 3)] public int UserId { get; set; } public byte Permission { get; set; } } public interface IModel { IQueryable<Document> Documents { get; } IQueryable<UserPermission> Permissions { get; } } public class MyDbContext : DbContext, IModel { public MyDbContext() { } public MyDbContext(string connectString) : base(connectString) { #if DEBUG Database.Log = x => Trace.WriteLine(x); #endif } public DbSet<Document> Documents { get; set; } public DbSet<UserPermission> Permissions { get; set; } #region Explicit IModel interface implementations IQueryable<Document> IModel.Documents => Documents; IQueryable<UserPermission> IModel.Permissions => Permissions; #endregion } 


Introducing the IModel interface can be useful for unit testing if we need test data:



 internal class DbContextStub : IModel { public List<Document> Documents { get; } = new List<Document>(); public List<UserPermission> Permissions { get; } = new List<UserPermission>(); #region Explicit Interface Implementations IQueryable<Document> IModel.Documents => Documents.AsQueryable(); IQueryable<UserPermission> IModel.Permissions => Permissions.AsQueryable(); #endregion } 


Notice also the body of the MyDbContext constructor. The Database.Log = x => Trace.WriteLine (x) line allows you to see real SQL queries in the Visual Studio Output window when debugging.



Authorization Classes



Create the IAccessor interface:



 public interface IAccessor { IQueryable<T> GetQuery<T>() where T : class, IAuthorizedObject; Permission GetPermission<T>(long objectId) where T : class, IAuthorizedObject; bool HasPermission<T>(long objectId, Permission permission) where T : class, IAuthorizedObject; } 


The GetQuery method will return the IQueriable interface for selecting resources, in our case, documents available for reading to the current user. The GetPermission method will return the current user's authority to the specified resource. The HasPermission method has been added for convenience. It answers the question whether the current user has the specified right to the specified resource.



The IAuthorizedObject interface defines the resource that we are going to authorize. This interface is very simple and contains only a resource Id:



 public interface IAuthorizedObject { long Id { get; } } 


The Document class will need to inherit from the IAuthorizedObject interface:



 public class Document : IAuthorizedObject 


It's time to implement specific implementations of the IAccessor interface. We will have two implementations: Administrator and User . First, add the base class UserBase :



 public abstract class UserBase : IAccessor { protected readonly IModel Model; protected readonly int Id; private readonly Dictionary<Type, IQueryable> _typeToQuery = new Dictionary<Type, IQueryable>(); private readonly Dictionary<Type, ObjectType> _typeToEnum = new Dictionary<Type, ObjectType>(); protected UserBase(IModel model, int userId) { Model = model; Id = userId; AppendAuthorizedObject(Auth.ObjectType.Document, Model.Documents); // Append new authorized objects here... } private void AppendAuthorizedObject<T>(ObjectType type, IQueryable<T> source) where T : class, IAuthorizedObject { _typeToQuery.Add(typeof(T), source); _typeToEnum.Add(typeof(T), type); } protected IQueryable<T> Query<T>() where T : class, IAuthorizedObject { IQueryable query; if (!_typeToQuery.TryGetValue(typeof(T), out query)) throw new InvalidOperationException( $"Unsupported object type {typeof(T)}"); return query as IQueryable<T>; } protected byte ObjectType<T>() where T : class, IAuthorizedObject { ObjectType type; if (!_typeToEnum.TryGetValue(typeof(T), out type)) throw new InvalidOperationException( $"Unsupported object type {typeof(T)}"); return (byte)type; } protected Permission GetPermission<T>(int userId, long objectId) where T : class, IAuthorizedObject { var entities = Query<T>(); var objectType = ObjectType<T>(); var query = from obj in entities from p in Model.Permissions where p.ObjectType == objectType && p.ObjectId == objectId && obj.Id == p.ObjectId && p.UserId == userId select p.Permission; return (Permission) query.FirstOrDefault(); } public abstract IQueryable<T> GetQuery<T>() where T : class, IAuthorizedObject; public abstract Permission GetPermission<T>(long objectId) where T : class, IAuthorizedObject; public abstract bool HasPermission<T>(long objectId, Permission permission) where T : class, IAuthorizedObject; } 


UserBase will be useful to us when implementing the classes Administrator and User . In the constructor, he initializes his members in order to be able to implement generic methods. The Query method returns a dataset from a context DB for a given type, ObjectType returns a native enumeration value by type, and GetPermission returns an authority for a given user ID and an object for a generic type.



Now we can start creating the Administrator and User classes. As for the administrator, everything is simple, because the administrator has full rights to all documents:



 public class Administrator : UserBase { public Administrator(IModel model, int userId) : base(model, userId) { } public override IQueryable<T> GetQuery<T>() { return Query<T>(); } public override bool HasPermission<T>(long objectId, Permission permission) { return permission != Permission.None; } public override Permission GetPermission<T>(long objectId) { return Permission.Delete; } } 


With the User class, everything is much more interesting: the GetQuery method should return only those documents to which the user has access. Therefore, we must take into account the authority of this user. We implement this in a single query to the database, i.e. let's do something, because of what, in fact, everything was started.



 public class User : UserBase { public User(IModel model, int userId) : base(model, userId) { } public override IQueryable<T> GetQuery<T>() { var entities = Query<T>(); var objectType = ObjectType<T>(); return from obj in entities from p in Model.Permissions where p.ObjectType == objectType && p.UserId == Id && obj.Id == p.ObjectId select obj; } public override bool HasPermission<T>(long objectId, Permission permission) { return permission == Permission.None ? GetPermission<T>(objectId) == Permission.None : GetPermission<T>(objectId) >= permission; } public override Permission GetPermission<T>(long objectId) { return GetPermission<T>(Id, objectId); } } 


It is clear that in this way you can easily introduce new user roles. Suppose we need to add an “advanced user“ that has the right to read all documents created by other users. It is clear that to implement the corresponding class is a simple task.



I will give an example of this class:



 public class AdvancedUser : UserBase { public AdvancedUser(IModel model, int userId) : base(model, userId) { } public override IQueryable<T> GetQuery<T>() { // Advanced user can see all resources return Query<T>(); } public override bool HasPermission<T>(long objectId, Permission permission) { if (permission == Permission.None) return false; return GetPermission<T>(objectId) >= permission; } public override Permission GetPermission<T>(long objectId) { // Return own permission if exists or Permission.Read return Max(GetPermission<T>(Id, objectId), Permission.Read); } private static Permission Max(Permission perm1, Permission perm2) { return (Permission) Math.Max((int) perm1, (int) perm2); } } 


Finally, you need a class to create specific implementations of the IAccessor interface. It will look like this:



 public static class Factory { public static IAccessor CreateAccessor(IPrincipal principal, IModel model) { if( IsAdministrator(principal)) return new Administrator(model, GetUserId(principal)); else return new User(model, GetUserId(principal)); } private static bool IsAdministrator(IPrincipal principal) { return principal.IsInRole("SYSTEM_ADMINISTRATE"); } private static int GetUserId(IPrincipal principal) { var id = 0; // TODO: Obtain user id from Thread.CurrentPrincipal here... return id; } } 


DocumentController



Now that we have all the necessary infrastructure, we can easily implement a document controller:



 [RoutePrefix("documents")] public class DocumentsController : ApiController { private readonly MyDbContext _db = new MyDbContext(); private IAccessor Accessor => Factory.CreateAccessor(Thread.CurrentPrincipal, _db); [HttpGet] [Route("", Name = "GetDocuments")] [ResponseType(typeof(IQueryable<Document>))] public IHttpActionResult GetDocuments() { var query = Accessor.GetQuery<Document>(); return Ok(query); } [HttpGet] [Route("{id:long}", Name = "GetDocumentById")] [ResponseType(typeof(Document))] public IHttpActionResult GetDocumentById(long id) { if (!Accessor.HasPermission<Document>(id, Permission.Read)) return NotFound(); var document = _db.Documents.FirstOrDefault(e => e.Id == id); if (document == null) return NotFound(); return Ok(document); } [HttpPost] [Route("", Name = "CreateDocument")] [ResponseType(typeof(Document))] public IHttpActionResult CreateDocument(Document document) { if (!ModelState.IsValid) return BadRequest(ModelState); _db.Documents.Add(document); _db.SaveChanges(); return CreatedAtRoute("CreateDocument", new { id = document.Id }, document); } [HttpDelete] [Route("{id:long}", Name = "DeleteDocument")] [ResponseType(typeof(Document))] public IHttpActionResult DeleteDocument(long id) { if (Accessor.HasPermission<Document>(id, Permission.Delete)) return NotFound(); var document = _db.Documents.FirstOrDefault(e => e.Id == id); if (document == null) return NotFound(); _db.Documents.Remove(document); _db.SaveChanges(); return Ok(document); } protected override void Dispose(bool disposing) { if (disposing) _db.Dispose(); base.Dispose(disposing); } } 


DocumentPermissionController



Next, we need to add a controller for CRUD rights operations for a specific document. There is nothing special about it, except that each method will have to take into account the current user's rights on this document.



If we assume that we have a DocumentPermissionService class that takes over operations on permissions and unloads the controller, then the code will look like this:



 [RoutePrefix("documents")] public class DocumentPermissionsController : ApiController { private readonly MyDbContext _db = new MyDbContext(); private readonly DocumentPermissionService _service = new DocumentPermissionService(); private IAccessor Accessor => Factory.CreateAccessor(Thread.CurrentPrincipal, _db); [HttpGet] [Route("{id:long}/permissions", Name = "GetPermissions")] [ResponseType(typeof(IQueryable<UserPermission>))] public IHttpActionResult GetPermissions(long id) { if (!Accessor.HasPermission<Document>(id, Permission.Write)) return NotFound(); var permissions = _service.GetPermissions(id); return Ok(permissions); } [HttpPatch] [Route("{id:long}/permissions", Name = "SetPermissions")] public HttpResponseMessage SetPermissions( long id, IList<PermissionDto> permissions) { if (!Accessor.HasPermission<Document>(id, Permission.Write)) return Request.CreateResponse(HttpStatusCode.NotFound); string err; var validationCode = _service.ValidatePermissions(permissions, out err); if (validationCode != HttpStatusCode.OK) return Request.CreateResponse(validationCode, err); _service.SetPermissions(id, permissions); return Request.CreateResponse(HttpStatusCode.OK); } [Route("{id:long}/permissions/{userId:int}", Name = "DeletePermission")] [HttpDelete] public IHttpActionResult DeletePermission(long id, int userId) { if (!Accessor.HasPermission<Document>(id, Permission.Write)) return NotFound(); var isDeleted = _service.DeletePermission(id, userId); return isDeleted ? (IHttpActionResult) Ok() : NotFound(); } protected override void Dispose(bool disposing) { if (disposing) _db.Dispose(); base.Dispose(disposing); } } 


Note that the GetPermissions method requires the Write permission. At first glance it seems that a user who has the right to read a document should be able to obtain all the permissions for this document. However, it is not. In accordance with the principle of minimum privileges, we should not give the user privileges that are not necessary for him. The user with the Read authority does not have the ability to change the user rights to the document, respectively, he does not need data on the existing rights.



Extensibility



Everything is changing. We may have new requirements and business rules. How adaptive is our approach to changing requirements? Let's try to imagine what might change in the future.



The first thing that comes to mind is the addition of new types of resources. Everything looks good here: if we add a new entity to the DB model, say, Image , we just need to add the new value of the ObjectType enum and one line of code to the constructor of the UserBase class:



  AppendAuthorizedObject(ObjectType.Image, Model.Image); 


Slightly more complicated with users. Suppose we need to add the ability to group users and assign permissions to groups. Will we be able to make changes to the project relatively painlessly?



The first thing to do is add a new column, AccountType, to the Permissions table. It would be nice to also rename UserId to AccountId , since now this column will store either the user Id or group Id depending on the AccountType value.



We'll have to change the GetQuery methods in the IAccessor interface implementations . Now it will be necessary to take into account the belonging of the user to the group and check the authority of the group in addition to the authority of the user himself.



But in general, such a change in functionality does not look critical.

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



All Articles