ここまで、権限管理について以下の実装を試してきました。
- 権限管理の1つであるUnixパーミッションをTypeScriptで実装してみた - メモ的な思考的な
- 権限管理の1つであるACL (Access Control List) をTypeScriptで実装してみた - メモ的な思考的な
- 権限管理の1つであるRBAC (Role-Based Access Control) をTypeScriptで実装してみた - メモ的な思考的な
今回は、ABAC (Attribute-Based Access Control) について実装してみます。
なお、今回は標準的なABACを調べつつClaude Codeに聞きつつ実装しました。とはいえ抜けがあるかもしれないため、「標準的なABACではこのような仕様もある」などがありましたら、ツッコミをお願いします。
目次
環境
- mac
- Claude Code + Opus 4.0/4.1
- 学んでいる最中にOpusのバージョンが上がったため、両方使っています
- TypeScript 5.7.3
- Bun 1.2.19
- Biome 2.1.2
RBACとの違いについて
RBACでは ロール を中心に権限判定をしました。例えば、「書き込み権限」のある「編集者」というロールをユーザーに付与することで、ユーザーが書き込み権限を得られます。
しかし、ロールは静的な定義のため、「業務時間内のみ許可する」などの動的な制約を加えるのが難しいです。
一方、ABACは、Attribute-Based Access Controlという名前の通り、属性を 評価 し、その結果として許可・拒否の 判定 を下します。属性を評価する条件は ルール や ポリシー などと呼ばれます(今回は、以降「ルール」と呼びます)。ルールでは「どの属性がどんな時に、許可や拒否をするか」を定義しています。
ここでの 属性 とは、「ユーザー」「リソース」「環境」などが持つ特性のことを指します。
- 例
- ユーザーに関するもの
- 「誰が」を示す
- 所属部門
- 職位に応じたアクセス可能な機密レベル
- 「誰が」を示す
- リソースに関するもの(何に)
- 「何に」を示す
- リソースの管理部門
- 機密レベル
- 「何に」を示す
- 環境に関するもの(いつ、どこで)
- 「いつ」「どこで」を示す
- アクセス時間
- アクセス場所
- 「いつ」「どこで」を示す
- ユーザーに関するもの
ルールの中でこれらの属性を組み合わせて評価することで、 「ユーザーの所属部門とリソースの管理部門が同じ場合、かつ、営業時間内の場合、許可する」といった動的・関係的な制約を付けることができます。
まとめると、違いは以下となります。
- RBACは「誰がどのロールを持っているか」で判定する
- ABACは「誰が・何に・いつ・どこで」という複数の属性を評価して判定する
今回実装するABACの仕様について
今回も学習用途なので、基本方針は以下とします。
- 学習用のシンプルな実装とする
- RBAC同様、題材は社内ドキュメント管理システムとする
- パーミッションは「読み取り」「書き込み」の2種類
基本方針を元にした設計は以下の通りです。
- 評価に利用する属性は以下の4つ
- 誰が
- 何に
- いつ、どこで
- 何をする
- 判定結果について
- 判定結果は「許可」「拒否」「ルールにマッチしない」の3つ
- すべてのルールにマッチしない場合は、「拒否」ではなく、「ルールにマッチしない」と判定する
- 呼び出し元に「ルールにマッチしない」状況の処理を委任
- すべてのルールにマッチしない場合は、「拒否」ではなく、「ルールにマッチしない」と判定する
- 判定結果は「許可」「拒否」「ルールにマッチしない」の3つ
- 複数のルールにマッチした場合について
Deny-Overrideで考える- 現実でも、「拒否」というセキュリティ的に安全な方へと倒すことが多いはずなため
- 例
- 1つでも「拒否」があれば、「拒否」と判定する
- 「拒否」がなく「許可」があれば、「許可」と判定する
実装
型
「誰が」の属性
今回の「誰が」はユーザーになるので、ユーザーの属性を型 SubjectAttributes として定義します。
export type SubjectAttributes = { userName: string // ユーザー名 department: Department clearanceLevel: SecurityLevel }
「何に」の型
今回の「何に」はドキュメントになるので、同じく型 ResourceAttributes として定義します。
export type ResourceAttributes = { // ドキュメント名 documentName: string // ドキュメントを管理している部門 department: Department // 機密度レベル(数値が高いほど機密) classificationLevel: SecurityLevel }
「いつ」「どこで」の型
「いつ」「どこで」の型 EnvironmentAttributes です。
export type EnvironmentAttributes = { // アクセス時刻 currentTime: Date // アクセス場所 location: Location }
「何をする」の型
「何をする」は、パーミッションを示す型 PermissionAction です。
export type PermissionAction = 'read' | 'write'
属性で使う共通の型定義
属性で使う共通の型は、それぞれ取り得る値の範囲を限定したいため、固定値として定義します。
// 部門 export type Department = 'engineering' | 'finance' | 'hr' | 'sales' // セキュリティレベル(1-5の範囲) // clearanceLevel(ユーザーの権限レベル)とclassificationLevel(ドキュメントの機密度)で共通使用 // 数値が高いほど高い権限・機密度を表す export type SecurityLevel = 1 | 2 | 3 | 4 | 5 // アクセス場所の種類 export type Location = 'office' | 'home' | 'external'
評価時のコンテキスト
ここまでで定義した属性をまとめたコンテキストを型として定義した EvaluationContext です。これを元にルールで評価します。
export type EvaluationContext = { // アクセス要求者の属性 subject: SubjectAttributes // アクセス対象の属性 resource: ResourceAttributes // 実行したいアクション action: PermissionAction // 環境属性 environment: EnvironmentAttributes }
個々のルール
個々のルールの型 Rule です。 effect でルールを満たしたときの効果を定義します。
また、 condition 関数にて属性の評価ロジックを定義し、条件を満たすかを boolean で返します。
export type Rule = { id: string description?: string // 効果(許可または拒否) effect: 'permit' | 'deny' // ルールの適用条件を評価する関数 // 属性間の関係性を動的に評価する condition: (context: EvaluationContext) => boolean }
ルール評価エンジンでの判定結果
ルールの判定結果は
- 許可
- 拒否
- ルールにマッチしない
の3値として表現したいため、型 RuleDecision を定義します。
export type RuleDecision = | { // 許可 type: 'permit' // 決定に使用されたルール appliedRule: Rule // 評価時のコンテキスト context: EvaluationContext } | { // 拒否 type: 'deny' appliedRule: Rule context: EvaluationContext } | { // 適用可能なルールが見つからない type: 'not-applicable' // not-applicableになった理由 reason: string }
クラス
ルール評価エンジン
今回のABACでは、クラスはルール評価エンジンのみ用意します。アクセス可否を判定したいコンテキストは、都度、ルール評価エンジンに渡します。
export class RuleEvaluationEngine { private REASONS = { unregistered: 'ルールが1つも登録されていない', noMatch: 'Permitルールを含む構成で、どの条件にもマッチしない', noMatchDenyOnly: 'Denyルールのみ存在し、条件にマッチしない' } as const; // 登録されたルールを管理 //key: ルール名, value: ルール private rules: Map<string, Rule> constructor() { this.rules = new Map() } // 与えられたコンテキストに対してルールを使って評価する evaluate(context: EvaluationContext): RuleDecision { // 後述 } // 新しいルールをエンジンに追加 addRule(rule: Rule): void { this.rules.set(rule.id, rule) } // 指定された名前のルールをエンジンから削除 removeRule(ruleId: string): void { this.rules.delete(ruleId) } }
ルール評価エンジンの evaluate メソッドを使って、Deny-Override に基づく判定を下します。
export class RuleEvaluationEngine { // 与えられたコンテキストに対してルールを評価し、アクセス可否を判定する // // 実装すべき評価アルゴリズム(Deny-Override): // 1. すべてのルールを評価し、条件にマッチするものを特定 // 2. 一つでもDenyルールがマッチした場合、即座にDenyを返す // 3. Denyがなく、Permitルールがマッチした場合、Permitを返す // 4. どのルールにもマッチしない場合、not-applicableを返す evaluate(context: EvaluationContext): RuleDecision { if (this.rules.size === 0) { return {type: 'not-applicable', reason: this.REASONS.unregistered} } const denyRules = [...this.rules.values()].filter(rule => rule.effect === 'deny') for (const rule of denyRules) { if (rule.condition(context)) { return { type: 'deny', appliedRule: rule, context: context } } } const permitRules = [...this.rules.values()].filter(rule => rule.effect === 'permit') for (const rule of permitRules) { if (rule.condition(context)) { return { type: 'permit', appliedRule: rule, context: context } } } if (denyRules.length > 0 && permitRules.length === 0) { return {type: 'not-applicable', reason: this.REASONS.noMatchDenyOnly} } return {type: 'not-applicable', reason: this.REASONS.noMatch} } }
動作確認
今回もテストコードで動作確認を行います。
まずは複雑な属性を組み合わせたときのテストコードです。
- 同一部門が要求
- オフィスからのアクセスで、クリアランスレベル5にアクセス可能な人が読み取りを要求
のいずれかの時に、許可を出しています。
describe('「同一部門」、もしくは、「locationがofficeでアクセスした人がclearanceLevelが5、actionがread」の場合は許可と定義したルール', () => { const engine = new RuleEvaluationEngine(); const rule = createPermitRule('complex-rule-1', (ctx) => // 同一部門の場合は許可 (ctx.subject.department === ctx.resource.department) || // もしくは、オフィスからのアクセスで、クリアランスレベル5、読み取り操作の場合は許可 (ctx.environment.location === 'office' && ctx.subject.clearanceLevel === 5 && ctx.action === 'read') ); engine.addRule(rule); describe('別部門', () => { describe('locationがoffice', () => { describe('clearanceLevelが5', () => { describe('actionがread', () => { const context = createDefaultContext(); context.subject.department = 'engineering'; context.resource.department = 'finance'; // 別部門 context.subject.clearanceLevel = 5; context.action = 'read'; context.environment.location = 'office'; it('Permitと判定されること', () => { const result = engine.evaluate(context); expect(result).toEqual({ type: 'permit', appliedRule: rule, context: context }); }) }) describe('actionがwrite', () => { const context = createDefaultContext(); context.subject.department = 'engineering'; context.resource.department = 'finance'; // 別部門 context.subject.clearanceLevel = 5; context.action = 'write'; // readではない context.environment.location = 'office'; it('not-applicableと判定されること', () => { const result = engine.evaluate(context); expect(result).toEqual({ type: 'not-applicable', reason: 'Permitルールを含む構成で、どの条件にもマッチしない' }); }) }) }) }) }) })
続いて、 Deny-Override の動作確認です。Permitが複数あったとしても、Denyがあれば拒否されます。
describe('評価がPermitとDenyで競合', () => { const engine = new RuleEvaluationEngine(); const permitRule = createPermitRule('permit-1', () => true); const denyRule = createDenyRule('deny-1', () => true); engine.addRule(permitRule); engine.addRule(denyRule); const context = createDefaultContext(); it('Denyと判定され、appliedRuleにはDenyルールが設定されること', () => { const result = engine.evaluate(context); expect(result).toEqual({ type: 'deny', appliedRule: denyRule, context: context }); }) })
ソースコード
GitHubに上げました。
https://github.com/thinkAmi-sandbox/authorization_practice_in_memory
今回のプルリクはこちら
https://github.com/thinkAmi-sandbox/authorization_practice_in_memory/pull/4
なお、Claude Codeに任せた部分と自分で書いた部分は、それぞれ別のコミットにしています。また、Claude Codeに任せたコミットには、Claude Codeに対するプロンプトも記載してあります。