ここまで、権限管理について以下の実装を試してきました。
- 権限管理の1つであるUnixパーミッションをTypeScriptで実装してみた - メモ的な思考的な
- 権限管理の1つであるACL (Access Control List) をTypeScriptで実装してみた - メモ的な思考的な
今回は、RBAC (Role-Based Access Control) について、実装してみます。
目次
環境
- mac
- Claude Code + Opus 4.0/4.1
- 学んでいる最中にOpusのバージョンが上がったため、両方使っています
- TypeScript 5.7.3
- Bun 1.2.19
- Biome 2.1.2
ACLとRBACの違いについて
ACLでは、直接ユーザーやグループに対して、リソースへの権限設定をしていました。
そのため、例えば組織変更がある場合は、リソースごとに権限を修正する必要があります。
一方、RBACでは、
- ユーザーやグループをロールに割り当てる
- ロールに対して、リソースへの権限設定を行う
を行う、間接的な権限設定となります。
例えば、営業部から開発部へ異動となったAさんがいる場合、異動時の作業の違いは以下となります。
- ACL
- 営業部の全リソースに対し、Aさんの権限を削除
- 開発部の全リソースに対し、Aさんの権限を付与
- RBAC
- 営業部のロールに対し、Aさんを削除
- 開発部のロールに対し、Aさんを追加
また、「このユーザーは何ができるか?」を確認するときも以下の違いがあります。
- ACL
- 全リソースの権限を確認する
- RBAC
- ユーザーに割り当てられているロールに対する権限を確認する
通常、ロール数の方がリソース数よりも少ないため、RBACの方が確認しやすくなります。
他にも、管理対象数にも違いがあります。
- ACL
- ユーザー数 * リソース数
- RBAC
- ロール数 + ユーザー割り当て数
そのため、ユーザー数やリソース数が多ければ多いほど、RBACの方が管理しやすくなります。
今回実装するRBACの仕様について
今回もRBACの学習をメインとするため、そのまま本番運用できるものを目指すのではなく、学習用途の方に仕様を寄せました。
- 今まで同様、題材はファイル管理システム
- パーミッションは
readとwriteのみ - ユーザーは複数ロールを保有可能とする
- いずれかのロールで許可されていれば、その操作を行うことができる
- 例:
editorロール (write可) とauditorロール (write不可) を持つユーザーは、 write可能になる
- 例:
- いずれかのロールで許可されていれば、その操作を行うことができる
- ロール判定パターンは、以下のどちらかを指定可能
- ユーザーがいずれかのロールを所有している
- ユーザーがすべてのロールを所有している
- 実装しないもの
- ロール定義をプログラムで追加・編集・削除すること
- 複雑になるため実装しない
- これにより、今回は「事前に定義したロールのみ、ユーザーに割り当てられる」となる
- ロールに階層構造をもたせること
- これも複雑になるため実装しない
- 現実の会社だと、「部→課」のような階層構造がある
- 権限として設定するのは「許可」のみとし、「拒否」は設定できない
- 例:「
一般グループの場合は書き込みを拒否する」は設定不可 - 基本的なRBACでは「ロールを持っていなければ拒否」という考え方
- もしRBACで「拒否」を明示的にしたい場合は、RBACの拡張的な感じで実装する必要がある
- 例:「
- ロール定義をプログラムで追加・編集・削除すること
実装
型
パーミッション
今回のパーミッションも読み込み・書き込みのみとするため、型 PermissionBits と PermissonAction を用意します。
export type PermissionBits = { read: boolean write: boolean } export type PermissionAction = keyof PermissionBits // 'read' | 'write'
ロールの定義
ロールの定義も型として用意します。今回は追加しないため、事前に各ロールを定義します。
事前定義とするからには const assertion や type of を使ってより厳密な型定義にします。
export const ROLES = { viewer: { name: 'viewer' as const, permissions: {read: true, write: false}, description: 'ドキュメントの閲覧のみ可能' }, editor: { name: 'editor' as const, permissions: {read: true, write: true}, description: 'ドキュメントの閲覧と編集が可能' }, admin: { name: 'admin' as const, permissions: {read: true, write: true}, description: '全権限を持つ管理者' }, auditor: { name: 'auditor' as const, permissions: {read: true, write: false}, description: '監査員' } } as const export type RoleName = keyof typeof ROLES // 'viewer' | 'editor' | 'admin' | 'auditor' export type Role = typeof ROLES[RoleName]
ロール判定パターン
今回のロール判定パターンは2つで固定のため、こちらも RoleRequirement 型として用意します。 roles プロパティの型は同じなので、 Tagged Union として定義します。
export type RoleRequirement = | { type: 'any'; roles: RoleName[] } // いずれかのロールがあればOK | { type: 'all'; roles: RoleName[] } // 全てのロールが必要
ユーザートロールの割り当て関係
今回、ユーザーとロールの割り当て関係を保持しておく必要があるため、これも UserRoleAssignment 型として用意します。 UserName に対して、複数の RoleName を保持できる仕様です。
export type UserRoleAssignment = Map<UserName, Set<RoleName>>
権限判定の結果
権限判定の結果については、今回は以下の4パターンあります。
- 許可
- 拒否
- ユーザーがロールを持っていない
- ユーザーはロールを持っているが、読み込み・書き込み権限がない
- ユーザーはロールを持っているが、すべてのロールを持つなどの要件を満たさない
これも型として定義します。
export type AuthzDecision = | { type: 'granted' matchedRoles: RoleName[] } | { type: 'denied' reason: 'no-roles' // リソースがロールを持っていない } | { type: 'denied' reason: 'insufficient-permissions' // ロールはあるが権限不足 userRoles: RoleName[] } | { type: 'denied' reason: 'requirement-not-met' // リソースの要件を満たさない userRoles: RoleName[] }
クラス
UnixパーミッションやACLでは1クラスで権限判定をしてきました。
今回は、ロールを管理する必要があるので、RBACを実現する機能の責務を2つのクラスに分けました。
RoleManager- ユーザーに対するロールを管理するクラス
- 「誰がどのロールを持つか」を管理する
RbacProtectedResource- RBACでリソースを保護するクラス
- 「このリソースへのアクセスを許可するか」を判断する
ユーザーに対するロールを管理するクラス
あるユーザーに対してどのロールが割り当てられているかを管理しているクラスです。
今回は仕様を容易にするため、
- このクラスの1インスタンスで、すべてのユーザーに対するロールを管理
- ユーザーからロールを削除できない
としています。
export class RoleManager { private readonly roles: typeof ROLES private userRoleAssignments: UserRoleAssignment constructor(predefinedRoles: typeof ROLES) { this.roles = predefinedRoles this.userRoleAssignments = new Map() } // ユーザーにロールを割り当て assignRole(userName: UserName, roleName: RoleName): void { const existingRoles = this.getUserRoles(userName); this.userRoleAssignments.set(userName, new Set([...existingRoles, roleName])) } // ユーザーのロール一覧を取得 getUserRoles(userName: UserName): Set<RoleName> { return this.userRoleAssignments.get(userName) || new Set(); } // ロール定義を取得 getRole(roleName: RoleName): Role { return this.roles[roleName] } }
リソースに対する権限をRBACで管理するクラス
このクラスの1インスタンスで、1リソース(1ファイル)の権限を管理します。
また、権限を管理するのに必要な以下のプロパティを定義しています。
- ユーザーに対するロールを管理
- roleManager
- ロールをすべて持っている or いずれかを持っているのどちらで判断するか
- requirements
なお、リソースに対する権限を定義していない場合は上記のプロパティが不要になるため、プロパティは任意としています。
export class RbacProtectedResource { private resourceId: string private readonly roleManager?: RoleManager private readonly requirements?: RoleRequirement constructor( resourceId: string, roleManager?: RoleManager, requirements?: RoleRequirement, ) { this.resourceId = resourceId this.roleManager = roleManager this.requirements = requirements } }
権限の有無を判定するのは authorize メソッドです。
ユーザーが保有しているロールを取得後、権限のあるロールを持っているかどうかを判定します。
export class RbacProtectedResource { // 権限をチェック authorize(userName: UserName, action: PermissionAction): AuthzDecision { // プロパティからローカル変数へ取り出してガードすることで、型をナローイングする const roleManager = this.roleManager const requirements = this.requirements if (!roleManager || !requirements) { return {type: 'denied', reason: 'no-roles'} } const userRoles = roleManager.getUserRoles(userName); const matchedRoles = requirements.roles.filter((roleName) => { return userRoles.has(roleName) && roleManager.getRole(roleName).permissions[action]; }) switch (requirements.type) { case 'any': if (matchedRoles.length > 0) { return {type: 'granted', matchedRoles} } return {type: 'denied', reason: 'insufficient-permissions', userRoles: Array.from(userRoles)} case 'all': const allMatch = requirements.roles.every(roleName => matchedRoles.includes(roleName)) if (allMatch) { return {type: 'granted', matchedRoles} } return {type: 'denied', reason: 'requirement-not-met', userRoles: Array.from(userRoles)} default: // 他のパターンはないので、もしここに来たら例外とする throw new Error('Not implemented') } } }
動作確認
今回もテストコードで動作確認を行います。
以下のような感じでテストコードを書き、いずれもパスすることを確認しました。テストコード全体は、後述のソースコードを参照してください。
describe('RoleRequirementのtype違いの動作', () => { const roleManager = new RoleManager(ROLES); roleManager.assignRole('user1', 'editor'); // editorロールのみ持つ describe('type: "any" - いずれかのロールがあればOK', () => { it('editor or adminのいずれかを持っていれば許可', () => { const requirements = { type: 'any' as const, roles: ['editor' as const, 'admin' as const] }; const resource = new RbacProtectedResource('doc1', roleManager, requirements); const result = resource.authorize('user1', 'write'); expect(result).toEqual({ type: 'granted', matchedRoles: ['editor'], }); }) }) describe('type: "all" - すべてのロールが必要', () => { it('editor and adminの両方が必要なので拒否', () => { const requirements = { type: 'all' as const, roles: ['editor' as const, 'admin' as const] }; const resource = new RbacProtectedResource('doc1', roleManager, requirements); const result = resource.authorize('user1', 'write'); expect(result).toEqual({ type: 'denied', reason: 'requirement-not-met', userRoles: ['editor'] }); }) }) })
ソースコード
GitHubに上げました。
https://github.com/thinkAmi-sandbox/authorization_practice_in_memory
今回のプルリクはこちら
https://github.com/thinkAmi-sandbox/authorization_practice_in_memory/pull/3
なお、Claude Codeに任せた部分と自分で書いた部分は、それぞれ別のコミットにしています。また、Claude Codeに任せたコミットには、Claude Codeに対するプロンプトも記載してあります。