[Из песочницы] Авторизуем ресурсы в REST-сервисе

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

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

В этой статье описывается подход к решению проблемы авторизации в REST сервисе на базе ASP.NET Web API 2 с использованием Entity Framework.

Задача


Предположим, мы разрабатываем сайт, позволяющий размещать различные ресурсы, например, текстовые документы. У нас есть REST-сервис, осуществляющий CRUD-операции над этими документами. Задача аутентификации, то есть определения подлинности пользователя, у нас уже решена. Пользователи в нашей системе могут иметь различные роли. Будем считать, что у нас есть два типа пользователей: администраторы и простые пользователи.

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

Начинаем


Итак, пользователи делятся на два типа: администраторы и обычные пользователи. Администраторы имеют максимальные права на доступ к любому документу, обычные пользователи имеют максимальные права на свои документы и предоставленные права на чужие. Будем считать, что есть три полномочия: чтение, запись (изменение) и удаление документа: Read, Write и Delete. Каждое последующее полномочие включает в себя предыдущее, т.е. Write включает Read, а Delete включает в себя Write и Read.

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

opvfhq9plq_ylwspd_ls6w4fgtw.png

Здесь ObjectId — это идентификатор ресурса, ObjectType — тип ресурса, UserId — Id пользователя и, наконец, Permission — полномочия.

Добавим необходимые определения:

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
}


При добавлении нового ресурса в таблице Permissions должна появляться запись с максимальными правами для пользователя, создавшего ресурс. Проще всего это сделать с помощью DB триггера. Будем считать, что у нас в таблице Documents имеются столбцы Id (идентификатор документа) и CreatedBy (идентификатор пользователя, создавшего документ). Добавим в таблицу Documents новый триггер:

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


Таким образом, у нас будет автоматически появляться полномочие 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


На первый взгляд кажется, что хранить права администратора в базе избыточно, поскольку администратор и так имеет полные права на любой документ. Что произойдёт, если в редакторе прав на стороне клиента удалить полномочия администратора? — для администратора ничего не изменится. Возникает соблазн обрабатывать права администратора особым образом, скажем, не добавлять запись в базу или не показывать его права в редакторе.

Тем не менее, лучше всё-таки использовать общий подход. Что произойдёт, если администратор вдруг перестанет быть администратором и перейдёт в категорию обычных пользователей?

Модель


Мы используем Entity Framework. Классы и интерфейсы модели данных выглядят примерно так:

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 Documents { get; }
    IQueryable 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 Documents { get; set; }
    public DbSet Permissions { get; set; }

#region Explicit IModel interface implementations

    IQueryable IModel.Documents => Documents;
    IQueryable IModel.Permissions => Permissions;

#endregion
}


Введение интерфейса IModel может оказаться полезным для юнит-тестирования.
Обратите также внимание на тело конструктора MyDbContext. Строка Database.Log = x => Trace.WriteLine (x) позволяет увидеть реальные SQL запросы в Output-окне Visual Studio при отладке.

Классы для авторизации


Создадим интерфейс IAccessor:

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


Метод GetQuery будет возвращать интерфейс IQueriable для выборки ресурсов, в нашем случае документов, доступных для чтения текущему пользователю. Метод GetPermission вернёт полномочие текущего пользователя на специфицированный ресурс. Метод HasPermission добавлен для удобства. Он отвечает на вопрос, имеет ли текущий пользователь заданное право на специфицированный ресурс.

Интерфейс IAuthorizedObject определяет ресурс, который мы собираемся авторизовать. Этот интерфейс весьма прост и содержит только Id ресурса:

public interface IAuthorizedObject
{
    long Id { get; }
}


Класс Document нужно будет унаследовать от интерфейса IAuthorizedObject:

public class Document : IAuthorizedObject


Пришло время имплементировать конкретные реализации интерфейса IAccessor. У нас будет две реализации: Administrator и User. Вначале добавим базовый класс Accessor:

public class Accessor
{
    protected readonly IModel Model;
    protected int Id { get; }

    private readonly Dictionary _typeToQuery =
        new Dictionary();
    private readonly Dictionary _typeToEnum =
        new Dictionary();

    protected Accessor(IModel model, int userId)
    {
        Model = model;
        Id = userId;

        AppendAuthorizedObject(ObjectType.Document, Model.Documents);
        // Append new authorized objects here...
    }

    private void AppendAuthorizedObject(ObjectType type, IQueryable source)
        where T : class, IAuthorizedObject
    {
        _typeToQuery.Add(typeof(T), source);
        _typeToEnum.Add(typeof(T), type);
    }

    protected IQueryable Query() 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;
    }

    protected byte ObjectType() 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(int userId, long objectId)
        where T : class, IAuthorizedObject
    {
        var entities = Query();
        var objectType = ObjectType();
        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();
    }
}


Accessor — это инструментальный класс, который будет нам полезен при имплементации классов Administrator и User. В конструкторе он инициализирует свои члены, для того чтобы иметь возможность реализовать обобщенные методы. Метод Query возвращает набор данных из DB контекста по заданному типу, ObjectType возвращает нативное значение перечисления по типу и GetPermission возвращает полномочие по заданному идентификатору пользователя и объекта для обобщенного типа.

Теперь мы можем приступить к созданию классов Administrator и User. Что касается администратора, то здесь всё просто, поскольку администратор имеет полные права на все документы:

public class Administrator : Accessor, IAccessor
{
    public Administrator(IModel model, int userId)
        : base(model, userId)
    {
    }

    public IQueryable GetQuery()
        where T : class, IAuthorizedObject
    {
        return Query();
    }

    public bool HasPermission(long objectId, Permission permission)
        where T : class, IAuthorizedObject
    {
        return permission != Permission.None;
    }

    public Permission GetPermission(long objectId)
        where T : class, IAuthorizedObject
    {
        return Permission.Delete;
    }
}


С классом User всё гораздо интереснее: метод GetQuery должен возвращать только те документы, к которым у пользователя есть доступ. Поэтому мы должны учитывать полномочия данного пользователя. Мы реализуем это в одном запросе к БД, т.е. проделаем то, из-за чего, собственно, всё и затевалось.

public class User : Accessor, IAccessor
{
    public User(IModel model, int userId)
        : base(model, userId)
    {
    }

    public IQueryable GetQuery()
        where T : class, IAuthorizedObject
    {
        var entities = Query();
        var objectType = ObjectType();

        return
            from obj in entities
            from p in Model.Permissions
            where
                p.ObjectType == objectType && p.UserId == Id &&
                obj.Id == p.ObjectId
            select obj;
    }

    public bool HasPermission(long objectId, Permission permission)
        where T : class, IAuthorizedObject
    {
        return permission == Permission.None
            ? GetPermission(objectId) == Permission.None
            : GetPermission(objectId) >= permission;
    }

    public Permission GetPermission(long objectId)
        where T : class, IAuthorizedObject
    {
        return GetPermission(Id, objectId);
    }
}


Понятно, что таким образом можно легко вводить новые роли пользователей. Допустим, нам надо добавить «продвинутого пользователя», который имеет право на чтение всех документов, созданных другими пользователями. Ясно, что реализовать соответствующий класс — задача несложная.

Приведу пример такого класса:

public class AdvancedUser : Accessor, IAccessor
{
    public AdvancedUser(IModel model, int userId)
        : base(model, userId)
    {
    }

    public IQueryable GetQuery() where T : class, IAuthorizedObject
    {
        // Advanced user can see all resources
        return Query();
    }

    public bool HasPermission(long objectId, Permission permission)
        where T : class, IAuthorizedObject
    {
        if (permission == Permission.None)
            return false;

        return GetPermission(objectId) >= permission;
    }

    public Permission GetPermission(long objectId)
        where T : class, IAuthorizedObject
    {
        // Return own permission if exists or Permission.Read
        return Max(GetPermission(Id, objectId), Permission.Read);
    }

    private static Permission Max(Permission perm1, Permission perm2)
    {
        return (Permission) Math.Max((int) perm1, (int) perm2);
    }
}


Наконец, понадобится класс для создания конкретных реализаций интерфейса IAccessor. Выглядеть он будет примерно так:

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


Теперь, когда у нас есть вся необходимая инфраструктура, мы можем легко реализовать котроллер документов:

[RoutePrefix("documents")]
public class DocumentsController : ApiController
{
    private readonly MyDbContext _db = new MyDbContext();

    [HttpGet]
    [Route("", Name = "GetDocuments")]
    [ResponseType(typeof(IQueryable))]
    public IHttpActionResult GetDocuments()
    {
        var query = Factory.CreateAccessor(Thread.CurrentPrincipal, _db)
            .GetQuery();
        return Ok(query);
    }

    [HttpGet]
    [Route("{id:long}", Name = "GetDocumentById")]
    [ResponseType(typeof(Document))]
    public IHttpActionResult GetDocumentById(long id)
    {
        if (!Factory.CreateAccessor(Thread.CurrentPrincipal, _db)
            .HasPermission(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)
    {
        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


Далее нам необходимо добавить контроллер для CRUD операций над правами для определённого документа. Ничего особенного в нём не будет, за исключением того, что каждый метод должен будет принимать во внимание права текущего пользователя на данном документе.

Если считать, что у нас есть класс DocumentPermissionService, который берёт на себя операции над полномочиями и разгружает котроллер, то код будет выглядеть следующим образом:

[RoutePrefix("documents")]
public class DocumentPermissionsController : ApiController
{
    private readonly MyDbContext _db = new MyDbContext();
    private readonly DocumentPermissionService _service =
        new DocumentPermissionService();

    [HttpGet]
    [Route("{id:long}/permissions", Name = "GetPermissions")]
    [ResponseType(typeof(IQueryable))]
    public IHttpActionResult GetPermissions(long id)
    {
        if (!Factory.CreateAccessor(Thread.CurrentPrincipal, _db)
            .HasPermission(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 permissions)
    {
        if (!Factory.CreateAccessor(Thread.CurrentPrincipal, _db)
            .HasPermission(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 (!Factory.CreateAccessor(Thread.CurrentPrincipal, _db)
            .HasPermission(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);
    }
}


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

Расширяемость


Всё меняется. У нас могут появиться новые требования и бизнес-правила. Насколько наш подход адаптивен к изменяющимся требованиям? Попытаемся представить, что может измениться в будущем.

Первое, что приходит в голову — это добавление новых типов ресурсов. Тут всё выглядит неплохо: если мы добавляем в DB модель новую сущность, скажем, Image, нам достаточно добавить новое значение перечисления ObjectType и одну строку кода в конструктор класса Accessor:

    AppendAuthorizedObject(ObjectType.Image, Model.Image);


Чуть сложнее с пользователями. Предположим, нам нужно добавить возможность группировать пользователей и назначать права на группы. Сможем ли мы относительно безболезненно внести изменения в проект?

Первое, что нужно сделать, — это добавить новый столбец AccountType в таблицу Permissions. Было бы неплохо также переименовать UserId в AccountId, поскольку теперь этот столбец будет хранить либо Id пользователя, либо Id группы в зависимости от значения AccountType.

Придётся поменять методы GetQuery в реализациях интерфейса IAccessor. Теперь нужно будет учитывать принадлежность пользователя к группе и проверять полномочия группы помимо полномочий самого пользователя.

Но в целом, подобное изменение функциональности не выглядит критическим.

© Habrahabr.ru