認可のベストプラクティスとDDDでの実装パターン
最近、少々複雑な権限機能の開発を担当している中で、対応方針を悩んでいたことがありました。
権限機能というものは取り扱いが難しく、影響範囲が広いにも関わらず、対応漏れや考慮不足があると情報漏洩に繋がってしまいます。
また、機能拡張をしてく中でも対応漏れを起こさないようにする必要があるなど、考えることも多く頭を悩ませておりました。
そこで、認可処理の設計のベストプラクティスやDDDの実装パターンに認可処理を組み込む方法など、色々と調べていたのですが、その中でいくつか知見を得られたのでまとめようと思います!
権限と認可
権限と切っては切れない関係にあるのが認可です。
権限はある操作を実行できる権利を指します。
それに対して、認可は操作を実行する許可を出すため仕組みのことを指します。
例えば、ブログ投稿サービスで考えてみると、以下のような感じです。
- 権限: 投稿者はポストを編集できる。
- 認可: ユーザーがポストを編集する権限があれば許可し、なければ拒否する。
権限機能を作るためには認可処理を作る必要があります。
この認可処理は実装しようと思うと色々な手段が考えられると思いますが、どの方法がベストなのかを判断するのは難しいと思います。
そんな中、認可処理のベストプラクティスをまとめているのが、Authorization Academyというサイトです。
Osoというクラウドの認可サービスを提供している会社がまとめているのですが、アプリケーションに認可を組み込む際のガイドラインがまとまっており、今回の認可処理を設計する際にとても参考にさせてもらいました。
認可モデルのアーキテクチャやRBACなどのアクセス制御の方式についても具体例付きで詳細に書かれており、とてもわかりやすかったのでおすすめです。
認可処理の実装パターン
ここからはAuthorization Academyの2章で紹介されている認可処理の実装パターンについて触れていきます。
よくあるパターン
まずは、よくある認可ロジックの実装パターンです。
先程のブログ投稿サービスの例のポストの編集権限を実装してみましょう。
「投稿者はポストを編集できる」は裏を返すと「ポストは投稿者しか編集できない」ということになると思います。
単純に書くと、postの作成者が実行ユーザーと同じであることをチェックすれば良さそうです。
context(PostRepository)
fun updatePostUseCase(userId: String, postId: String, content: String) {
val post = findById(postId) ?: throw NotFoundException()
// ポストは投稿者しか編集できない
if (post.createdBy != userId) throw PermissionDeniedException()
val updated = post.update(content)
save(updated)
}
このやり方は非常に簡単で最も手が出しやすいパターンだと思います。
複雑な認可ロジックがない場合には、これでも十分なケースもあると思います。
しかし、ポストを編集する処理を書く場所が増えていくと、同じロジックを書く必要出てきそうです。
また、認可ロジックが変更された場合には、対応漏れを起こしたり、間違った認可処理を入れてしまう可能性だってありそうです。
プロダクトが小さいうちは問題ないかもしれませんが、プロダクトが成長してくとこの方法では限界を迎えそうです。
認可処理をまとめるパターン
上記のパターンでの問題を踏まえると、「認可ロジックはまとめましょう」となりそうです。
では、認可ロジックをまとめるにはどうしたら良いでしょうか?
Authorization Academyでは共通のデータモデルとインターフェースを提供することで、認可ロジックをまとめるアプローチを紹介しています。
まず、データモデルについてです。
認可で登場するモデルとしては以下の3つになります。
- Actor (誰が)
- Action (何の操作を)
- Resource (何に対して)
このモデルでは認可処理の「誰が、何に対して、何をしようとしているのか?」がシンプルに表現されています。
このモデルを使って認可処理を実行するインターフェースについても紹介されています。
fun <T> is_allowed(actor: Actor, action: String, resource: T): Boolean
先程の例をこのインターフェースで実装してみましょう。
context(PostRepository)
fun updatePostUseCase(userId: String, postId: String, content: String) {
val post = findById(postId) ?: throw NotFoundException()
val actor = Actor(userId)
if (!is_allowed(actor, "update", post)) throw PermissionDeniedException()
val updated = post.update(content)
save(updated)
}
fun is_allowed(actor: Actor, action: String, resource: Post): Boolean {
return when (action) {
"update" -> resource.createdBy == actor.userId
else -> false
}
}
よくあるパターンではUseCaseに「ポストは投稿者しか編集できない」というロジックが書かれていましたが、インターフェースを共通化することで、認可ロジックを隠蔽できています。
このインターフェースはどこから呼び出しても同じ結果を返すことが想像できます。
このインターフェースが必要な箇所で必ず呼び出されるようになっていれば、かなり安心できそうです。
(呼び出しを強制する方法は言語やフレームによって変わってくるため、この記事では触れません)
もう一つの注目ポイントは、例外はUseCase側で投げているところです。
これは認可の判断と、結果の適応を分ける実装パターンで、これもAuthorization Academyで紹介されています。
https://www.osohq.com/academy/what-is-authorization
「認可の判断」と「結果の適応」を分けておくことで、拒否された場合の処理も柔軟に対応することができます。
もう少し複雑なケース
認可処理をまとめるパターンは大体わかったと思いますが、例が簡単すぎましたね。
もう少し複雑なケースも考えてみましょう。
ブログ投稿サービスの例では扱うリソースがポストだけでした。
では、ブログ投稿サービスの中に"チーム"という概念を作ってみます。
「チームに投稿されたポストはチームの管理者と投稿者だけが削除可能」としましょう。
この認可処理を実装するにはどうしたら良いでしょうか?
まず、今回新しく"チーム"というリソースが追加されました。
また、 "チームの管理者"という新しい概念も追加されています。これはユーザーはチームに対してロールを持つということを意味していそうです。
では、このデータ構造を考えてみましょう。
ユーザーはチームに対してroleを一つ持つというのがわかると思います。
では、この情報をActorに反映させてみましょう。
data class Actor(
val userId: UserId,
val teamRoles: Map<TeamId, Role>,
)
ユーザーが所属するチームに対してどのようなロールであるかという情報をActorが持つようになりました。
この情報があれば、ユーザーが新しい仕様にも対応できそうです。
このActorを使った認可処理は以下のように書けます。
fun is_allowed(actor: Actor, action: String, resource: Post): Boolean {
return when (action) {
"delete" -> {
// 投稿者は自身のポストを削除できる
if (resource.createdBy == actor.userId) return true
// チームの管理者はチームのポストを削除できる
val teamRole = actor.teamRoles[resource.postedTeamId]
if (teamRole == Role.ADMIN) return true
return false
}
else -> false
}
}
この認可処理を使ってUseCaseを書いてみましょう。
context(PostRepository, ActorRepository)
fun deletePostUseCase(userId: UserId, postId: PostId) {
val post = findById(postId) ?: throw NotFoundException()
val actor = findById(userId) ?: throw NotFoundException()
if (!is_allowed(actor, "delete", post)) throw PermissionDeniedException()
delete(post.id)
}
actorを取得する処理にデータベースアクセスが必要になりましたが、先程と同じinterfaceで認可処理を実装できました。
実はこのアプローチはRBAC(Role Base Access Control)というアクセス制御方式だったりします。
RBACについてはAuthorization Academyの第3章に詳しく書かれています。
Osoでのアプローチ
シンプルな実例を元に認可処理の実装パターンを見ていきました。
では、Authorization AcademyをまとめているOsoではどんな実装パターンで作られているのでしょうか?
Osoはクラウドとは別にOSSのライブラリもあるので、そちらを参考にしてみます。
OsoではPolarという独自の構文で認可ロジックとポリシーを宣言し、そのPolarのファイルをロードすることでOsoのライブラリが裏側で認可処理を実行する仕組みになっているようです。
(詳しくはドキュメントをご参照ください)
Polarは以下のような構文です。
actorとresourceを定義し、認可ロジックを書いていきます。
actor User {}
resource Repository {
permissions = ["read", "push", "delete"];
roles = ["contributor", "maintainer", "admin"];
"read" if "contributor";
"push" if "maintainer";
"delete" if "admin";
"maintainer" if "admin";
"contributor" if "maintainer";
}
has_role(actor: User, role_name: String, repository: Repository) if
role in actor.roles and
role_name = role.name and
repository = role.repository;
allow(actor, action, resource) if
has_permission(actor, action, resource);
Pythonの例ですが、以下のようにPolarのファイルをロードしています。
ロードする前にPythonで定義しているモデルを登録しています。
こうすることで、PolarのリソースとPythonのオブジェクトが対応することになります。
from pathlib import Path
from oso import Oso
from .models import User, Repository
# Initialize the Oso object. This object is usually
# used globally throughout an application.
oso = Oso()
# Tell Oso about the data that you will authorize.
# These types can be referenced in the policy.
oso.register_class(User)
oso.register_class(Repository)
# Load your policy file.
oso.load_files([Path(__file__).parent / "main.polar"])
認可のインターフェースも見てみましょう。
Authorization Academyであったようなインターフェースと同じ構造をしていますね。
Osoは is_allowed
の他にも便利な認可インターフェースを備えており、様々な認可のユースケースに対応できるようになっています。
DDDにおける認可
さて、OsoのAuthorization Academyに基づいて認可処理の実装パターンを色々書いてきましたが、実際に自分たちのアプリケーションに実装することを考えるともう一つ考えることが出てきます。
DDDの実装パターンであるレイヤードアーキテクチャやオニオンアーキテクチャにおいて認可処理はどのレイヤーに書くべきなのか?というところです。
正直、これについては「ここ!」という決まりきったベストプラクティス的なものは得られなかったので、「ケースによる」という結論に着地しました。
Entityに対する認可
まずは、Entityに対する認可について考えてみます。
調べていると、アプリケーション層かドメイン層に書くという記事が多かったので、それぞれのパターンを書いてみます。
まずはアプリケーション層のパターンです。
context(PostRepository, ActorRepository)
fun deletePost(userId: UserId, postId: PostId, content: String) {
val post = findById(postId) ?: throw NotFoundException()
val actor = findById(userId) ?: throw NotFoundException()
if (!is_allowed(actor, "delete", post)) throw PermissionDeniedException()
remove(post.id)
}
Authorization Academyで書いていたサンプルと全く同じになりましたね。
アプリケーション層に書くパターンだと、ユースケースで必ず認可処理を呼び出す必要があります。
例えば、Postを一括で削除するユースケースを書いたときには対象の全ポストに対して認可処理を呼び出す必要があります。
次はドメイン層のパターンを書いてみます。
data class Post(
val id: PostId,
val createdBy: UserId,
val title: String,
val content: String
) {
fun updateContent(actor: Actor, content: String): Post {
if (!is_allowed(actor, "update", this)) throw PermissionDeniedException();
return copy(content = content)
}
}
ドメイン層に書く場合は、作成や更新のような操作に対してはEntity内で認可処理を呼び出すことができそうです。その場合、Actorを引き回す必要が出てきます。
一方で、削除や読み込みの場合だと、Entity内で認可処理を呼び出すことは難しそうです。
それに対する対処法としては、canExecute/Executeパターンで書くことです。
data class Post(
val id: PostId,
val createdBy: UserId,
val title: String,
val content: String
) {
fun canDelete(actor: Actor): Boolean = is_allowed(actor, "delete", this)
}
この場合だと、アプリケーション層と同じくユースケース側でcanDelete
を呼び出す必要があります。
認可ロジックをまとめて共通インターフェース化しているのであれば、canDelete
もただのWrapperにしかならないので、意味のあるものにはならなそうです。
しかし、認可ロジックを共通インターフェース化せずにEntityにまとめて書くという方針なのであれば、ドメイン層で認可し、UseCase側で呼び出すという方式にしても良いかもしれません。
QueryServiceのDTOに対する認可処理
また、もう一つ考えなければならないこととしてはCQRSパターンを利用している際のREAD権限についてです。
CQRSでQueryServiceを実装していると、Entityではなく専用のDTOを返すようになると思います。
このDTOに対して読み取りの認可を適応する場合はどのようにすれば良いでしょうか?
あるユーザーのポスト一覧を取得する際に、「一般公開されているポストと。自身が所属しているチームに公開されているポストのみ取得可能」という仕様を考えてみましょう。
CQRSで一覧取得用のDTOを返すようなQueryServiceのinterfaceを書いてみます。
interface PostQueryService {
fun listByUser(userId: UserId): List<PostListItemDto>
}
data class PostListItemDto(
val postId: PostId,
val title: String,
val content: String,
val createdBy: UserId,
val createdUserName: String,
val publishedTeamId: TeamId?,
val publishedTeamName: String?,
val totalComments: Int,
val totalGoods: Int,
)
これにもいくつかのパターンが考えられます。
1. DTOをリソースとした認可ロジックを構築する
1つ目はDTOをリソースとして認可ロジックを構築する方法です。
Entityと同じ考えで認可ロジックを構築することができるので、実装イメージは湧きやすいと思います。
しかし、Postと同じ認可ロジックを構築する必要があり、DTOの数だけ認可ロジックが増える可能性もあり、メンテナンス性は下がります。
また、DTOに認可ロジックで利用するプロパティが含まれている保証もありません。
context(PostQueryService, ActorRepository)
fun fetchUserPostUseCase(userId: UserId): List<PostListItemDto> {
val actor = findByUserId(userId) ?: throw NotFoundException()
val posts = listByUser(userId)
return posts.filter { is_allowed(actor, "read", it) }
}
fun is_allowed(actor: Actor, action: String, resource: PostListItemDto): Boolean {
return when (action) {
"read" -> {
// 一般公開されているポストであれば閲覧可能
if (post.publishedTeamId == null) true
// 所属チームのポストであれば閲覧可能
else if (post.publishedTeamId in actor.teamRoles.keys) true
// その他は閲覧不可
else false
}
else -> false
}
}
2. DTOにEntityを含め、Entityに対して認可処理を実行
DTOにEntityを含めてしまえば、呼び出し側でEntityを使って認可処理を実行できます。
この場合、そのためにSQLを整形したり、不要なデータを取得する必要があるかもしれません。
また、単純なるリスト形式であれば問題ありませんが、ページングされたデータを取得する場合には、取得後の値にフィルターをかけてしまうと、トータルカウントと実際の値が異なってしまうなど、不整合な状態になってしまう可能性もあります (これは 1.
のパターンでも同様です。)。
data class PostListItemDto(
val post: Post,
val createdUser: User,
val publishedTeam: Team,
val totalComments: Int,
val totalGoods: Int,
)
context(PostQueryService, ActorRepository)
fun fetchUserPostUseCase(userId: UserId): List<PostListItemDto> {
val actor = findByUserId(userId) ?: throw NotFoundException()
val listItems = listByUser(userId)
return listItems.filter { item -> is_allowed(actor, "read", item.post) }
}
3. QueryServiceの内部で認可を実行
3つ目はQueryServiceの内部で認可を実行するパターンです。
新たに authorizedQuery
というインターフェースを追加して、認可されたアクションに絞り込まれたテーブルを返すようにしています。
QueryServiceの実装では authorizedQuery
を利用することで、認可ロジックは知らずとも読み取り可能なPostだけを取得できます。
class PostQuerySerivceImpl(val query: DSLContext): PostQuerySerivce {
override fun listByUser(actor: Actor, userId: UserId): List<PostListItemDto> {
val authorizedPostTable = authorizedQuery(query, actor, "read", Post::class)
return authorizedPostTable
.join(USER).on(USER.ID.eq(POST.CREATED_BY))
.leftJoin(TEAM).on(TEAM.ID.eq(POST.PUBLISHED_AT))
.and(POST.CREATED_BY.eq(userId))
}
}
fun authorizedQuery(
query: DSLContext,
actor: Actor,
action: String,
resource: KClass<Post>,
): Table<PostRecord> {
return when (action) {
// 読み取り可能なPOSTに絞り込んだテーブルを返す
"read" -> query.selectFrom(POST)
.where(POST.PUBLISHED_AT.`in`(actor.teamRoles.keys))
.or(POST.PUBLISHED_AT.isNull)
.asTable()
else -> query.selectFrom(POST).where(falseCondition())
}
}
この方法のデメリットとしては、ORMに合わせて認可ロジックを書く必要があり、柔軟性はあまり高くありません。
また、アプリケーションでもPostの認可ロジックを書いている場合には、同じ認可処理をORM用に書く必要があります
また、ケースによってはこの絞り込みが入る事によってパフォーマンスを劣化される可能性もあります。
実はこの方法はOsoのライブラリでも実装されています。
OsoではアダプターとしてORMを登録することで、Polarの定義にしたがって authorizedQuery
を実行してくれるようです。
from oso import Oso
from polar.data.adapter.sqlalchemy_adapter import SqlAlchemyAdapter
oso = Oso()
oso.set_data_filtering_adapter(SqlAlchemyAdapter(session))
結論
ということで、冒頭の結論の「ケースによる」という結論に着地しました。
DDDの実装パターンに当てはめようと思ったときには、考慮事項は沢山あり、これまで書いた実装パターンでも一長一短ありました。
調べた限りではこの他にもパターンは存在しており、「認可処理はこう書くんだ!」というものは見つけることが出来ませんでした (ご存知の方教えてください🙇♂️)
そのため、最終的にはケースに合わせて適切な方法を取るのが良さそうという結論に至った次第です。
まとめ
複雑な権限実装を作るに当たってAuthorization AcademyやDDDでの実装パターンを調べていきました。
Authorization Academyには認可におけるベストプラクティスがまとまっており、認可ロジックを分けて管理するインターフェースや、認可の判断と結果の適応を分けるという実装パターンはかなり参考になりました。
DDDにおける認可処理では色々な実装パターンが考えられるのでケースに合わせて適切なものを選択していく事になりそうです。
これについては、引き続き模索していこうと思います。
おわりに
次の株式会社ログラス Productチーム Advent Calendar 2023は受身気質なリーダーことしおりんさんのBeer Bashという取り組みについての記事です!
乞うご期待!
Discussion