RESTサービスでリソースを承認する

ASP.NETの世界には強力で柔軟な承認メカニズムがあります。 たとえば、ASP.NET Core 2.0は、 承認ポリシーハンドラーなどを使用する機能を開発者に提供します



しかし、リソースのリストを返すGETメソッドを実装する方法は? そして、このメソッドもすべてのリソースではなく、指定されたページのみを返す必要がある場合はどうでしょうか? 各ユーザーには、自分がアクセスできるリソースのみが表示されます。 毎回データベースから完全なリストを取得し、現在のユーザーの権限に基づいてフィルタリングできますが、非効率的すぎます-リソースの量が非常に大きくなる可能性があります。 データベースクエリレベルで承認とページ付けの問題を解決することをお勧めします。



この記事では、Entity Frameworkを使用してASP.NET Web API 2ベースのRESTサービスの認証問題を解決する方法について説明します。



挑戦する



テキストドキュメントなど、さまざまなリソースを投稿できるサイトを開発しているとします。 これらのドキュメントに対してCRUD操作を実行するRESTサービスがあります。 認証のタスク、つまりユーザーの真正性の判断はすでに解決されています。 私たちのシステムのユーザーは異なる役割を持つことができます。 管理者と一般ユーザーの2種類のユーザーがいると仮定します。



今、私たちは承認のタスクに直面しています-ドキュメントに対して特定のアクションを実行する権利をユーザーに与えます。 ドキュメントを投稿した各ユーザーが、このドキュメントへの他のユーザーのアクセスを動的に制御できるようにします。



はじめに



そのため、ユーザーは管理者と一般ユーザーの2種類に分類されます。 管理者はドキュメントにアクセスするための最大の権限を持ち、一般ユーザーは自分のドキュメントと他のユーザーに与えられる権限に対して最大の権限を持ちます。 ドキュメントの読み取り、書き込み(変更)、および削除の3つのアクセス許可があることを前提としています: ReadWriteおよびDelete 。 後続の各権限には、前の権限、つまり 書き込みに読み取りが含まれ、 削除に書き込み読み取りが含まれます。



まず、データベースに新しいテーブルを追加してアクセス許可を保存する必要があります。







ここで、 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 }
      
      





新しいリソースを追加すると、リソースを作成したユーザーの最大権限を持つレコードが[ アクセス許可]テーブルに表示されます。 これを行う最も簡単な方法は、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
      
      





したがって、ドキュメントの作成者に削除権限が自動的に付与されます。



削除トリガーを追加することもできます。



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





IModelインターフェイスの導入は、テストデータが必要な場合のユニットテストに役立ちます。



 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 }
      
      





コンストラクターMyDbContextの本体にも注意してくださいDatabase.Log = x => Trace.WriteLine(x)行を使用すると、デバッグ中にVisual Studio出力ウィンドウで実際のSQLクエリを確認できます。



認可のクラス



IAccessorインターフェイスを作成します。



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





GetQueryメソッドは、リソース(この例では現在のユーザーが読み取り可能なドキュメント)を取得するためのIQueriableインターフェイスを返します。 GetPermissionメソッドは、指定されたリソースに対する現在のユーザーの権限を返します。 HasPermissionメソッドは、便宜上追加れました。 現在のユーザーが指定されたリソースに対して指定された権利を持っているかどうかの質問に答えます。



IAuthorizedObjectインターフェイスは、承認するリソースを定義します。 このインターフェイスは非常にシンプルで、リソースIDのみが含まれています。



 public interface IAuthorizedObject { long Id { get; } }
      
      





Documentクラスは、 IAuthorizedObjectインターフェイスから継承する必要があります。



 public class Document : IAuthorizedObject
      
      





IAccessorインターフェイスの特定の実装を実装するときが来ましたAdministratorUserの 2つの実装があります 。 まず、基本クラス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は、 AdministratorクラスとUserクラスを実装するときに役立ちます。 コンストラクターでは、一般化されたメソッドを実装できるように、メンバーを初期化します。 Queryメソッドは、指定されたタイプごとに DBコンテキストからデータセットを返し、 ObjectTypeはタイプごとに列挙のネイティブ値を返し、 GetPermissionは指定されたユーザーとジェネリックタイプのオブジェクト識別子ごとの特権を返します。



これで、 AdministratorクラスとUserクラスの作成を開始できます。 管理者はすべてのドキュメントに対する完全な権限を持っているため、ここではすべてが簡単です。



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





Userクラスを使用すると、すべてがはるかに興味深いものになります 。GetQueryメソッドは、ユーザーがアクセスできるドキュメントのみを返す必要があります。 したがって、このユーザーの権限を考慮する必要があります。 これを単一のデータベースクエリで実装します。 実際に、それがすべてを開始したものにすることを行います。



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





このようにして、新しいユーザーロールを簡単に導入できることが理解されます。 他のユーザーが作成したすべてのドキュメントを読む権利を持つ「上級ユーザー」を追加する必要があるとします。 対応するクラスを実装することは簡単なタスクであることは明らかです。



そのようなクラスの例を挙げます。



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





最後に、 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(); 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



次に、特定のドキュメントのCRUD権限操作用のコントローラーを追加する必要があります。 特別なものは何もありませんが、各メソッドは、このドキュメントの現在のユーザーの権限を考慮する必要があります。



パーミッションの操作を引き継ぎ、コントローラーをアンロードするDocumentPermissionServiceクラスがあると仮定すると、コードは次のようになります。



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





GetPermissionsメソッドには書き込み権限が必要です。 一見すると、ドキュメントを読む権利を持っているユーザーは、このドキュメントのすべての権限を取得できるはずです。 しかし、これはそうではありません。 最小限の特権原則に従って、彼に必要のないユーザー特権を与えるべきではありません。 読み取り権限を持つユーザーは、それぞれドキュメントに対するユーザー権限を変更することはできません。既存の権限に関するデータは必要ありません。



拡張性



すべてが変化しています。 新しい要件とビジネスルールがあります。 要件の変更に対する当社のアプローチはどの程度適応していますか? 将来変化する可能性のあるものを想像してみましょう。



最初に思い浮かぶのは、新しいタイプのリソースの追加です。 ここではすべてが適切に見えます:DBモデルに新しいエンティティ、たとえばImageを追加する場合、新しいObjectType列挙と1行のコードをUserBaseクラスのコンストラクターに追加するだけです。



  AppendAuthorizedObject(ObjectType.Image, Model.Image);
      
      





ユーザーには少し難しい。 ユーザーをグループ化し、グループに権限を割り当てる機能を追加する必要があるとします。 プロジェクトを比較的簡単に変更できますか?



最初に行うことは、新しいAccountType列をPermissionsテーブルに追加することです。 また、 UserIdの名前をAccountIdに変更すると便利です。この列には、 AccountTypeの値に応じてユーザーIDまたはグループIDが格納されるためです。



IAccessorインターフェイス実装の GetQueryメソッドを変更する必要があります。 次に、グループ内のユーザーのメンバーシップを考慮し、ユーザーの権限に加えてグループの権限を確認する必要があります。



しかし、全体として、このような機能の変更は重要ではありません。



All Articles