はじめに
お久しぶりです(?)
Binary numberです。
去年のアドベントカレンダーぶりになりますね。
(アドベントカレンダーを書く妖怪かもしれません)
そんなことはどうでもよくて、今回はASP.NET Coreを使ってControllerのメソッドやクラスに[Authorize(Policy)]属性を付けるだけでpermissionを作成できる仕組みの紹介です。
何ができるのか!
この記事でできるようになるのは
- メソッドやクラスに属性を付けるだけでアクセス制御
- メソッドについた属性からアクセス権限(permission)の自動取得
- Roleが持っている権限に応じてメソッド・クラスごとの権限設定
前提
- ランタイム
- dotnet 10.0
- 言語
- C#(もちろん)
- バックエンドフレームワーク
- ASP.NET core
- ユーザ管理
- ASP.NET Core Identiy
- O/R Mapper
- Entity Framework Core
ランタイムはdotnet 10を使ってますが、dotnet 6とかでも全然できるとは思います。
仕組みの全体像
今回の仕組みはシンプルで、
- Controller / Action の [Authorize(Policy = "...")] を リフレクションでスキャン
- 見つかった Policy 名を 起動時に全部登録
- 実行時は Roleが持っているPermission を見て判定
という流れです。
よくわかんなそうな場合先に使い方を見てください
データ構造
ER図
ER図は以下のような構成になります。
Permissionは実際のところ正規系の考え方的にはPermissionRoleを作るべきな気もしますが、気にせず行きます。
Models
Modelsフォルダーの配下にあるそれぞれのファイルは以下のようになります
個人的に
public class ApplicationUser:IdentityUser<Guid>
{
public ApplicationUser():base()
{
Id = Guid.CreateVersion7();
}
public ApplicationUser(string userName) : this()
{
UserName = userName;
}
}
public class ApplicationRole:IdentityRole<Guid>
{
public ApplicationRole():base()
{
Id = Guid.CreateVersion7();
}
public ApplicationRole(string roleName) : this()
{
Name = roleName;
}
public ICollection<Permission> Permissions { get; set; } = new List<Permission>();
}
public class Permission
{
public Permission()
{
Id = Guid.CreateVersion7();
}
[Key]
public Guid Id { get; set; }
public string Name { get; set; }
[ForeignKey(nameof(Role))]
public Guid RoleId { get; set; }
public ApplicationRole Role { get; set; }
}
勝手な好みでUUIDv7を採用していますが、好きな型を使用してください!
Permissionの自動取得(Scan)
[Authorize(Policy = "...")] を付けたところから、Permission名を自動で集めます。
実装は以下の通りです。
public class PermissionScanService : IPermissionScanService
{
public HashSet<string> Permissions { get; private set; } = new HashSet<string>();
// 現在実行中のプログラムのデータを取得
Assembly assembly = Assembly.GetExecutingAssembly();
public PermissionScanService()
{
Scan();
}
public void Scan()
{
// すべての型を取得
var types = assembly.GetTypes();
foreach (var type in types)
{
// [Authorize]属性を取得
var auth = type.GetCustomAttribute<AuthorizeAttribute>();
if (auth?.Policy != null) // Authorizeの()内で指定したpolicyを取得しnullチェック
{
// Policyが指定されている場合はその内容をPermissionsに追加
Permissions.Add(auth.Policy);
}
// メソッドの一覧を取得
var methods = type.GetMethods();
foreach (var method in methods)
{
// メソッドの属性を取得
var authMethods = method.GetCustomAttributes<AuthorizeAttribute>();
foreach (var authMethod in authMethods)
{
if (authMethod.Policy != null)
{
// policyの中身からpermissionに追加
Permissions.Add(authMethod.Policy);
}
}
}
}
}
}
ポイントは「クラスとメソッド両方を見る」ことです。
これで Controller 単位の権限も Action 単位の権限も拾えます。
認可ハンドラ
Policy = "****"で指定されたの中身をpermissionとみなし「そのユーザーが、権限を持っているロールに属してるか」をチェックしています。
public class PermissionRoleHandler(ApplicationDbContext dbContext): AuthorizationHandler<PermissionRoleRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRoleRequirement requirement)
{
var permissions = dbContext.Permissions
.Where(x => x.Name == requirement.Permission)
.Include(a => a.Role)
.ToList();
foreach (var permission in permissions)
{
if (context.User.IsInRole(permission.Role.Name))
{
context.Succeed(requirement);
break;
}
}
return Task.CompletedTask;
}
}
public class PermissionRoleRequirement : IAuthorizationRequirement
{
public string Permission { get; }
public PermissionRoleRequirement(string permission)
=> Permission = permission;
}
サービスの登録
Program.csなどのエントリーポイントでは以下のような設定を行います
// 1) Permission一覧を取得するサービスを登録
builder.Services.AddSingleton<IPermissionScanService, PermissionScanService>();
// 2) 取得したPermissionを全部Policyとして登録
builder.Services.AddAuthorization(options =>
{
PermissionScanService permissionScanService = new PermissionScanService();
foreach (var permission in permissionScanService.Permissions)
{
options.AddPolicy(permission, policy =>
policy.Requirements.Add(new PermissionRoleRequirement(permission)));
}
});
使い方
例えば以下のようなコントローラがあった場合Authorize(Policy = "*****")で指定した内容をpermissionとして認識します。
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ProgressManagementSystem.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize(Policy = "ReadMaterials")]
public class LessonController : ControllerBase
{
[HttpGet]
public IActionResult List()
=> Ok(new[] { "Lesson 1", "Lesson 2" });
[HttpGet("{id}")]
public IActionResult Get(string id)
=> Ok(new { id, title = $"Lesson {id}" });
[Authorize(Policy = "WriteCurriculum")]
[HttpPost]
public IActionResult Create([FromBody] LessonRequest request)
=> Ok(new { id = Guid.NewGuid(), request.Title });
[Authorize(Policy = "WriteCurriculum")]
[HttpPut("{id}")]
public IActionResult Update(string id, [FromBody] LessonRequest request)
=> Ok(new { id, request.Title });
[Authorize(Policy = "ManageSectionCompletions")]
[HttpDelete("{id}")]
public IActionResult Delete(string id)
=> NoContent();
}
public record LessonRequest(string Title);
}
権限を取得したい場合はPermissionScanService.Permissionsから取得することができます。
先ほどのPermissionScanService.Permissionsは以下のようになります。
- ReadMaterials
- WriteCurriculum
- ManageSectionCompletions
となります。
例えばLesson/Listにアクセスしたい場合ReadMaterialsのpermissionを持っているRoleに属している必要があります。
Lesson/Createの場合はReadMaterialsとWriteCurriculumのpermissionを持っているRoleに属している必要があります。
問題点についてと対応
- 実行中のAssemblyしかAuthorize属性を取得できない
- 読み込んでいるDLLなどの情報をリストとしすべて読み込むようにする
- 毎アクセスごとにDBを確認している
- permission情報をclaimに追加する
