ASP.NETの世界には強力で柔軟な承認メカニズムがあります。 たとえば、ASP.NET Core 2.0は、
承認ポリシー 、
ハンドラーなどを使用する機能を開発者に提供し
ます 。
しかし、リソースのリストを返すGETメソッドを実装する方法は? そして、このメソッドもすべてのリソースではなく、指定されたページのみを返す必要がある場合はどうでしょうか? 各ユーザーには、自分がアクセスできるリソースのみが表示されます。 毎回データベースから完全なリストを取得し、現在のユーザーの権限に基づいてフィルタリングできますが、非効率的すぎます-リソースの量が非常に大きくなる可能性があります。 データベースクエリレベルで承認とページ付けの問題を解決することをお勧めします。
この記事では、Entity Frameworkを使用してASP.NET Web API 2ベースのRESTサービスの認証問題を解決する方法について説明します。
挑戦する
テキストドキュメントなど、さまざまなリソースを投稿できるサイトを開発しているとします。 これらのドキュメントに対してCRUD操作を実行するRESTサービスがあります。 認証のタスク、つまりユーザーの真正性の判断はすでに解決されています。 私たちのシステムのユーザーは異なる役割を持つことができます。 管理者と一般ユーザーの2種類のユーザーがいると仮定します。
今、私たちは承認のタスクに直面しています-ドキュメントに対して特定のアクションを実行する権利をユーザーに与えます。 ドキュメントを投稿した各ユーザーが、このドキュメントへの他のユーザーのアクセスを動的に制御できるようにします。
はじめに
そのため、ユーザーは管理者と一般ユーザーの2種類に分類されます。 管理者はドキュメントにアクセスするための最大の権限を持ち、一般ユーザーは自分のドキュメントと他のユーザーに与えられる権限に対して最大の権限を持ちます。 ドキュメントの読み取り、書き込み(変更)、および削除の3つのアクセス許可があることを前提としています:
Read 、
Writeおよび
Delete 。 後続の各権限には、前の権限、つまり
書き込みには
読み取りが含まれ、
削除には
書き込みと
読み取りが含まれます。
まず、データベースに新しいテーブルを追加してアクセス許可を保存する必要があります。
ここで、
ObjectIdはリソースの識別子、
ObjectTypeはリソースのタイプ、
UserIdはユーザーのID、最後に
Permissionはアクセス許可です。
必要な定義を追加します。
public enum ObjectType {
新しいリソースを追加すると、リソースを作成したユーザーの最大権限を持つレコードが[
アクセス許可]テーブルに表示されます。 これを行う最も簡単な方法は、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,
したがって、ドキュメントの作成者に
削除権限が自動的に付与されます。
削除トリガーを追加することもできます。
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インターフェイスの特定の実装を実装するときが
来ました 。
Administratorと
Userの 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);
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>() {
最後に、
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;
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メソッドを変更する
必要があります。 次に、グループ内のユーザーのメンバーシップを考慮し、ユーザーの権限に加えてグループの権限を確認する必要があります。
しかし、全体として、このような機能の変更は重要ではありません。