認可
認可は、認証済みのユーザーにどの操作を許すかを決める仕組みです。Guren の認可は、Laravel を参考にしたポリシーベースの設計になっています。
認可
認可は、認証済みのユーザーにどの操作を許すかを決める仕組みです。Guren の認可は、Laravel を参考にしたポリシーベースの設計になっています。
認可ゲートはアプリの起動時に自動で作られ、コンテナに gate として束縛されます。サービスプロバイダからは this.container.make('gate') で取り出して、ability を定義したりポリシーを登録したりします。手動でセットアップする必要はありません。
ゲート
ゲートは、ユーザーが特定のアクションを実行してよいかを判定する小さなクロージャです。
ゲートの定義
ゲートはサービスプロバイダの boot() で定義します。ゲート自体はフレームワーク側のプロバイダが登録処理の中で作るので、それより前に make('gate') を呼ぶと例外になります。
import { ServiceProvider } from '@guren/core'
export default class AuthorizationProvider extends ServiceProvider {
boot(): void {
const gate = this.container.make('gate')
// シンプルなゲート
gate.define('view-dashboard', (user) => {
return user?.isAdmin === true
})
// リソースを伴うゲート
gate.define('update-post', (user, post) => {
return user?.id === post.userId
})
// データベースチェックを伴う非同期ゲート
gate.define('delete-comment', async (user, comment) => {
const post = await Post.find(comment.postId)
return user?.id === post?.userId
})
}
}
作ったプロバイダは createApp({ providers }) に登録してください。
ゲートの使用
コントローラでは this.authorize() と this.can() が使えます。どちらも現在のユーザーの束縛まで済ませてくれます。
// 拒否時は AuthorizationException (403) をスロー
await this.authorize('update-post', post)
// 例外を投げずにチェック
const canView = await this.can('view-dashboard')
コントローラ以外では、呼び出し元が持っているコンテナからゲートを取り出します。ジョブやコマンドなら this.make('gate')、プロバイダなら this.container.make('gate')、ミドルウェアなら getRequestContainer(ctx).make('gate') です。取り出したら、forUser() でユーザーを束縛します。
const gate = this.make('gate').forUser(user)
// 許可されているか
const canView = await gate.allows('view-dashboard')
// 拒否されているか
const cannotView = await gate.denies('view-dashboard')
// リソースを伴うチェック
const canUpdate = await gate.allows('update-post', post)
// 認可するか例外を投げる
await gate.authorize('update-post', post)
// 拒否時は AuthorizationException (403) をスロー
Beforeコールバック
すべてのゲートチェックの前に実行するコールバックは、同じ boot() の中で登録します。
gate.before((user, ability) => {
// スーパー管理者はすべての操作が可能
if (user?.isSuperAdmin) {
return true
}
// undefined を返すとゲートのチェックに進む
})
Afterコールバック
すべてのゲートチェックの後に実行するコールバックも登録できます。
gate.after((user, ability, result) => {
// 認可の試行をログに記録
logger.info(`User ${user?.id} ${result ? 'allowed' : 'denied'} for ${ability}`)
})
ポリシー
ポリシーを使うと、認可のロジックを特定のモデルやリソースごとにまとめられます。
ポリシーの作成
ポリシーの雛形は CLI で生成できます。
bunx guren make:policy Post
手で書く場合は次のようになります。
import { Policy, type AuthUser } from '@guren/core'
import type { PostRecord } from '../Models/Post'
export class PostPolicy extends Policy {
/**
* すべての投稿を閲覧できるか
*/
viewAny(user: AuthUser | null): boolean {
return true
}
/**
* この投稿を閲覧できるか
*/
view(user: AuthUser | null, post: PostRecord): boolean {
return post.published || user?.id === post.userId
}
/**
* 投稿を作成できるか
*/
create(user: AuthUser | null): boolean {
return user !== null
}
/**
* この投稿を更新できるか
*/
update(user: AuthUser | null, post: PostRecord): boolean {
return user?.id === post.userId
}
/**
* この投稿を削除できるか
*/
delete(user: AuthUser | null, post: PostRecord): boolean {
return user?.id === post.userId
}
}
ポリシーの登録
ポリシーも同じ boot() の中で、ゲートに登録します。
// モデルクラスで登録
gate.policy(Post, PostPolicy)
// 文字列キーでも登録可能
gate.policy('post', PostPolicy)
ポリシーの使用
ORM のクエリが返すのは、コンストラクタの情報を持たないプレーンなオブジェクトです。そのため、ポリシーを解決させるには、レコードと一緒にモデルクラスも渡します。
const gate = this.make('gate').forUser(user)
const post = await Post.findOrFail(id)
// ORM レコードには [モデルクラス, レコード] を渡す
const canUpdate = await gate.allows('update', [Post, post])
// レコードを伴わないアビリティはクラス単体を渡す
const canCreate = await gate.allows('create', Post)
// 文字列キーも同様に使える
const canDelete = await gate.allows('delete', ['post', post])
// 認可するか例外を投げる(AuthorizationException, 403)
await gate.authorize('update', [Post, post])
クラスのインスタンス(new で作ったオブジェクト)であれば、タプルにしなくてもポリシーが自動で解決されます。
const canView = await gate.allows('view', somePostInstance)
プレーンなレコードをタプルにせずに渡すと、ポリシーは見つかりません。その ability のゲートも定義されていなければ、チェックは拒否を返さずに Error を投げます。エラーメッセージには ability 名と、[Model, record] に直すよう促す内容が入ります。レコードの持ち主本人が理由のわからない 403 を受け取るのではなく、誤りが 500 として表に出るようにするためです。
before() コールバックと、その ability に定義したゲートには、プレーンなレコードがそのまま渡ります。ポリシーのないクラスのインスタンスや、ポリシーのないモデルのタプルを渡した場合は拒否になります。
gate.any() と、配列を渡した authorizeMiddleware() は、ability を先頭から順に確認します。プレーンなレコードを渡すと、何も解決できなかった最初の ability で例外になり、後ろの ability が許可する場合でもそこで止まります。こちらでもタプルを渡してください。authorizeMiddleware() や authorizeResourceMiddleware() に渡す modelResolver も、レコードではなく [Model, record] を返すようにします。
ポリシーメソッド
ポリシーで使える標準のメソッドは次のとおりです。
| メソッド | 説明 |
|---|---|
viewAny |
すべてのリソースを閲覧できるか |
view |
特定のリソースを閲覧できるか |
create |
新しいリソースを作成できるか |
update |
リソースを更新できるか |
delete |
リソースを削除できるか |
restore |
ソフトデリートされたリソースを復元できるか |
forceDelete |
リソースを完全に削除できるか |
Beforeメソッド
before メソッドを定義すると、そのポリシーのすべてのチェックより先に判定を差し込めます。
export class PostPolicy extends Policy {
before(user: AuthUser | null, ability: string): boolean | undefined {
// 管理者は投稿に対してあらゆる操作が可能
if (user !== null && (user as { isAdmin?: boolean }).isAdmin) {
return true
}
// undefined を返すと個別メソッドのチェックに進む
}
}
コントローラー統合
コントローラーには authorize() と can() のヘルパーが組み込まれています。現在のユーザーは認証コンテキストから自動で解決され、ゲストの場合は null になります。
import { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'
import { Post } from '@/app/Models/Post'
import { PostResource } from '@/app/Http/Resources/PostResource'
export default class PostController extends Controller {
async show() {
const { id } = this.validateParams(PostIdParamSchema)
const post = await Post.findOrFail(id)
// 拒否時は AuthorizationException (403) をスロー
await this.authorize('view', [Post, post])
return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}
async update() {
const { id } = this.validateParams(PostIdParamSchema)
const post = await Post.findOrFail(id)
// 例外を投げずにチェック
if (!(await this.can('update', [Post, post]))) {
return this.json({ error: 'Unauthorized' }, 403)
}
// 更新処理...
}
}
Tip:
bunx guren make:feature Post --policyを実行すると、ポリシーの生成に加えて、store/update/destroyへのauthorize()の呼び出しまで自動で組み込まれます。
ミドルウェア
ルート単位でチェックしたい場合は、認可用のミドルウェアを作ります。
import { type Router, getRequestContainer, AuthorizationException, defineMiddleware } from '@guren/core'
export function authorizeAbility(ability: string) {
return defineMiddleware(async (ctx, next) => {
const gate = getRequestContainer(ctx).make('gate')
const user = await gate.resolveUser(ctx)
if (await gate.forUser(user).denies(ability)) {
throw new AuthorizationException()
}
await next()
})
}
// ルートでの使用
export function registerWebRoutes(router: Router): void {
router.get('/admin', [AdminController, 'index'], authorizeAbility('access-admin'))
}
gate.resolveUser(ctx) は、リクエストの認証コンテキストからログイン中のユーザーを取り出します。createGate() に userResolver を渡している場合は、そちらが優先されます。組み込みの authorizeMiddleware('access-admin') も、同じ方法でユーザーを解決します。
ベストプラクティス
- モデル固有のロジックにはポリシーを使う。 認可をモデルごとに整理できます。
- ゲートは小さく保つ。 ゲートは、特定のモデルに結び付かない ability に使います。
- 重いチェックはキャッシュする。 認可にデータベースのクエリが必要なら、キャッシュを検討してください。
- before コールバックは控えめに使う。 多用するとデバッグが難しくなります。
- 認可をテストする。 ゲートとポリシーのテストを書いてください。
認可のテスト
グローバルなインスタンスに頼らず、テストごとに新しい Gate を作ります。
import { describe, it, expect, beforeEach } from 'bun:test'
import { Gate } from '@guren/core'
import { PostPolicy } from '../app/Policies/PostPolicy'
describe('PostPolicy', () => {
let gate: Gate
beforeEach(() => {
gate = new Gate()
gate.policy('post', PostPolicy)
})
it('allows owner to update post', async () => {
const user = { id: 1 }
const post = { id: 1, userId: 1 }
expect(await gate.forUser(user).allows('update', ['post', post])).toBe(true)
})
it('denies non-owner from updating post', async () => {
const user = { id: 2 }
const post = { id: 1, userId: 1 }
expect(await gate.forUser(user).denies('update', ['post', post])).toBe(true)
})
})