前回はUnixパーミッションをClaude Codeと相談しながら実装してみました。
権限管理の1つであるUnixパーミッションをTypeScriptで実装してみた - メモ的な思考的な
今回もClaude Codeと相談しながら、ACL (Access Control List) をTypeScriptで実装してみます。
目次
環境
- mac
- Claude Code + Opus 4.0/4.1
- 学んでいる最中にOpusのバージョンが上がったため、両方使うことになったため
- TypeScript 5.7.3
- Bun 1.2.19
- Biome 2.1.2
UnixパーミッションとACLの違いについて
前回のUnixパーミッションでは、権限を設定できる対象は以下で固定されていました。
- 所有者(owner)
- グループ(group)
- その他(others)
そのため、「Aさんには読み書き権限を与えたいが、グループに所属していないBさんには読み込み権限だけ与えたい」という細かい制御はできませんでした。
一方、ACLでは以下のような設定が可能です。
- 任意の数のユーザーやグループに対して、個別の権限を設定可能
- 明示的な許可の他に、明示的な拒否も設定可能
- 例:Aさんには許可するが、Bさんは拒否する
今回実装するACLの仕様について
今回もACLの学習をメインとするため、そのまま本番運用できるものを作るのではなく、学習用途としての分かりやすさを優先した仕様としました。
- Unixパーミッション同様、題材はファイル管理システム
- 権限の判定は
readとwriteのみ - 権限判定を要求する対象は、ユーザーもしくはグループ
- 対象とリソースに対して
許可と拒否を設定・管理する- 許可と拒否の両方が設定されている場合は、
拒否と判定する
- 許可と拒否の両方が設定されている場合は、
- 判定結果は以下の3つ
- 許可
- 拒否
- マッチしない
- マッチしないときは、アプリケーションに判断を任せる
- 1つのリソースは、1つのインスタンスで管理する
rootなどの特権ユーザーは定義しない
実装
型
パーミッション
今回のパーミッションも読み込み・書き込みのみとするため、型 PermissionBits を用意します。
export type PermissionBits = { read: boolean write: boolean }
今回のACLでは 許可 と 拒否 の2種類を扱います。TypeScriptは構造的型付けを採用しているため、同じ構造を持つオブジェクトはすべて同じ型として扱われてしまうことから、以下のような問題が発生しそうでした。
- 許可用の権限データを、誤って拒否の処理で使ってしまう
- 拒否用の権限データを、誤って許可の処理で使ってしまう
そこで、TypeScriptの Branded Types を使い、型レベルで問題を発生させないようにします。
- TypeScript の型安全性を高める Branded Types
- TypeScriptと構造的型付け | TypeScript入門『サバイバルTypeScript』
- Branded Types | Learning TypeScript
export type AllowPermissionBits = PermissionBits & { readonly _brand: 'allow' } export type DenyPermissionBits = PermissionBits & { readonly _brand: 'deny' }
権限設定対象
Subject 型は権限設定の対象となるユーザーやグループを定義するときに使います。 type プロパティで、ユーザーとグループを判断できるようにします。
export type Subject = { type: 'user' | 'group' name: string }
ユーザー・グループと権限の紐づけ
Entry 型はユーザー・グループと権限を紐づけるときに使います。許可と拒否を明確にするため、TypeScriptの Tagged Union として type プロパティを使っています。
export type Entry = | { type: 'allow' subject: Subject permissions: AllowPermissionBits } | { type: 'deny' subject: Subject permissions: DenyPermissionBits }
リソースに対する権限管理
Resource 型は、リソースに対する権限を管理するときに使います。
export type Resource = { name: string entries: Entry[] }
権限判定メソッドの引数
次は、判定用メソッドまわりの型です。Unixパーミッションに比べ、判定に必要な引数の構造は複雑になります。そこで、引数の型として AccessRequest を用意します。
export type AccessRequest = { subject: { user: string // 要求者のユーザー名 groups: string[] // 要求者が所属する全グループ } action: PermissionAction // 'read' | 'write' }
権限の判定結果
また、今回のACLでは、権限有無の他に「マッチしない」も判定結果として返すことから、判定用メソッドの戻り値も型として用意します。
export type AccessDecision = | { type: 'granted'; allowEntries: Entry[] } // マッチしたAllowエントリー | { type: 'denied'; denyEntry: Entry; allowEntries: Entry[] } // Denyが優先 | { type: 'no-match' } // マッチするエントリーなし
クラス
リソースに対する権限をACLで管理するクラス
前回のUnixパーミッション同様、今回のACLもファイルごとに権限管理するため、 constructor で対象のリソースを設定します。
export class AccessControlList { private resource: Resource constructor(resource: Resource) { this.resource = resource } }
今回のACLでは、権限管理の定義順に関わらず、以下の条件で判定します。
- マッチするものがない
- マッチしないと判定
- マッチするものがある
- 拒否とマッチ
- 拒否と判定
- それ以外
- 許可と判定
- 拒否とマッチ
これを踏まえた判定メソッド resolveAccess は以下の通りです。Unixパーミッションに比べると、メソッドでの処理が複雑になっています。
export class AccessControlList { resolveAccess(request: AccessRequest): AccessDecision { const matchEntries = this.resource.entries.filter((entry) => { switch (entry.subject.type) { case 'user': return entry.subject.name === request.subject.user && entry.permissions[request.action] case 'group': return request.subject.groups.includes(entry.subject.name) && entry.permissions[request.action] default: return false } }) if (matchEntries.length === 0) { return {type: 'no-match'} } const denyEntry = matchEntries.find((entry) => entry.type === 'deny') if (denyEntry) { return { type: 'denied', denyEntry, allowEntries: matchEntries.filter((entry) => entry !== denyEntry && entry.type === 'allow') } } return {type: 'granted', allowEntries: matchEntries} } }
動作確認
次のような感じでテストコードを書き、いずれもパスすることを確認しました。
describe('ユーザーに許可、ユーザーが所属する複数グループのいずれかで拒否', () => { it('拒否が優先されること', () => { const userAllowEntry: Entry = { type: 'allow', subject: myUserSubject, permissions: ALLOW_PATTERNS.READ_ONLY } const groupDenyEntry: Entry = { type: 'deny', subject: myGroupSubject1, permissions: DENY_PATTERNS.READ } const resource: Resource = { name: 'test.txt', entries: [userAllowEntry, groupDenyEntry] } const acl = new AccessControlList(resource) const request: AccessRequest = { subject: { user: 'my_user', groups: ['my_group_1', 'my_group_2'] }, action: 'read' } const actual = acl.resolveAccess(request) expect(actual).toEqual({ type: 'denied', allowEntries: [userAllowEntry], denyEntry: groupDenyEntry }) }) })
テストコード全体は、後述のソースコードを参照してください。
ソースコード
GitHubに上げました。
https://github.com/thinkAmi-sandbox/authorization_practice_in_memory
今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/authorization_practice_in_memory/pull/2
なお、Claude Codeに任せた部分と自分で書いた部分は、それぞれ別のコミットにしています。また、Claude Codeに任せたコミットには、Claude Codeに対するプロンプトも記載してあります。