1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ASP.NET CoreとSource Generatorでポリシーベースの権限管理を楽に実装できるようにしてみた

1
Posted at

こんにちは。

テックリードのTerukiです。

ASP.NET Coreにはポリシーベースの認可という仕組みがあって、Controllerやそのアクションに [Authorize(Policy = "article:read")] のように必要な権限を宣言できます。

これ便利ですよね。

ただ、ポリシーは AddPolicy で1個ずつ登録しないといけません。

コントローラーが増えるたびに登録を足して、権限の一覧画面用にも同じ一覧をもう1回書いて、という運用をしていると、そのうち登録漏れやtypoが出てきます。

今回はSource Generatorを使ってこの辺を楽にしてみようと思います。

課題

手書きだとこんな感じになります。

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("article:read", p => p.RequireClaim("article", "read"))
    .AddPolicy("article:write", p => p.RequireClaim("article", "write"))
    .AddPolicy("comment:read", p => p.RequireClaim("comment", "read"))
    // コントローラーの数だけ増え続ける

1個や2個ならまだしも、コントローラーが増えるとなかなか大変です。

  • 新しいコントローラーを足した時にポリシーの登録を忘れる(しかも実行時に初めて気づく)
  • "article:read" という文字列があちこちに散らばってtypoに気づけない
  • 権限設定画面のために、全権限の一覧を別の場所にも持つことになる

実際Oh my teethの開発でもAddPolicyだらけになって大変になった時期がありました:sob:

方針

権限の定義をコントローラーの属性に集約して、残りはコードから生成してしまいます。

コントローラーには [Permission("article", "read", "write")] と属性を書くだけにして、次の3つをSource Generatorで生成します。

  • ポリシーの一括登録コード
  • [Authorize(Policy = ...)] に指定できる権限名の定数
  • 全権限の一覧と、ログインユーザーが持っている権限を返すクラス

完成イメージはこんな感じです。

[ApiController]
[Authorize]
[Permission("article", "read", "write")]
[Route("api/[controller]")]
public class ArticlesController : ControllerBase {
    [HttpGet]
    [Authorize(Policy = Permissions.Article.Read)]
    public IActionResult List() => Ok(new[] { "article-1", "article-2" });

    [HttpPost]
    [Authorize(Policy = Permissions.Article.Write)]
    public IActionResult Create() => StatusCode(StatusCodes.Status201Created);
}

Program.cs(またはStartup.cs) では、生成された拡張メソッドを呼ぶだけです。

builder.Services.AddAuthorizationBuilder().AddPermissionPolicies();

Permissions.Article.Read は "article:read" の定数なので、typoはコンパイルエラーになります。

実装

動くサンプルは下記のリポジトリに置いています。.NET 10で動作確認しています。

記事では要点だけ抜粋しているので、全体を見たい場合はこちらをどうぞ。

Source Generatorは IIncrementalGenerator を実装します。

属性そのものを生成する

[Permission] 属性は、ジェネレーター側から RegisterPostInitializationOutput で出力します。

context.RegisterPostInitializationOutput(static ctx =>
    ctx.AddSource("PermissionAttribute.g.cs", AttributeSource));

こうしておくと、利用側のプロジェクトに属性を定義しなくて済みます。

属性は internal で生成しているので、複数のプロジェクトでこのジェネレーターを参照しても型が衝突しません。

属性が付いたクラスを拾う

属性が付いた宣言を拾うのは ForAttributeWithMetadataName です。

var permissions = context.SyntaxProvider
    .ForAttributeWithMetadataName(
        "PermissionSample.PermissionAttribute",
        static (node, _) => node is ClassDeclarationSyntax,
        static (ctx, _) => ToPermission(ctx))
    .Where(static p => p.Resource.Length > 0);

変換結果は (string Resource, string Actions) というタプルにしています。

タプルは値の等価性を持つので、属性の中身が変わらなければ後続の処理がキャッシュされます。

ここで ISymbol や AttributeData をそのまま後ろに渡してしまうと、キャッシュが効かずに編集のたびに再生成されてしまうので注意です。

まとめて出力する

Collect() で全クラスの情報を1つにまとめて、1回の出力でポリシー登録・定数・一覧を生成します。

context.RegisterSourceOutput(permissions.Collect(), static (ctx, items) => Emit(ctx, items));

出力が安定するように、リソース名とアクション名は SortedDictionary / SortedSet で並べています。

不正な定義はビルドエラーにする

次の2つは、診断(Diagnostic)としてビルドエラーにしています。

  • PERM001: 同じリソース名が複数のクラスに宣言されている
  • PERM002: 権限名に使えない文字が含まれている(小文字英字で始まり、小文字英数字とアンダースコアのみ)

「実行したら権限が効いていなかった」というセキュリティ事故を、ビルドの時点で止められるのが嬉しいですね。

生成されるコード

article(read, write)と comment(read, write, moderate)を定義すると、こんなコードが生成されます。

読みやすいように、名前空間の修飾やコメントは省略しています。

// <auto-generated />
namespace PermissionSample;

public static class Permissions {
    public static class Article {
        public const string Read = "article:read";
        public const string Write = "article:write";
    }
    public static class Comment {
        public const string Moderate = "comment:moderate";
        public const string Read = "comment:read";
        public const string Write = "comment:write";
    }
}

public static class PermissionCatalog {
    public static IReadOnlyList<string> All { get; } = new string[] {
        "article:read",
        "article:write",
        "comment:moderate",
        "comment:read",
        "comment:write",
    };
    // GetGranted(ClaimsPrincipal) は省略
}

public static class PermissionPolicyExtensions {
    public static AuthorizationBuilder AddPermissionPolicies(this AuthorizationBuilder builder) {
        builder.AddPolicy("article:read", policy => policy.RequireClaim("article", "read"));
        // 以下同様
        return builder;
    }
}

これでAddPolicyをゴリゴリ書いていく作業から解放されます。


実はこの記事はAIがOh my teethのコードを見て抜粋して執筆したものなのですが、もともとあったものよりも改良されていてこの記事を元に私も業務コードに反映していこうかなと思っているところです:upside_down:

キャッシュの話とか知らなかったですしビルドエラーにするのは知ってましたが面倒でやっていなかったのでこの際対応しようかなと。

今後AIも使って記事を書いていこうとは思っていますが100%AI執筆の記事を上げるつもりはないですし、クオリティの高いものを継続して出していければなと思っているのでそういう意味ではご安心ください(?)。

ではでは。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?