以下の内容はhttps://thinkami.hatenablog.com/entry/2025/08/29/221436より取得しました。


権限管理の1つであるRBAC (Role-Based Access Control) を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の学習をメインとするため、そのまま本番運用できるものを目指すのではなく、学習用途の方に仕様を寄せました。

  • 今まで同様、題材はファイル管理システム
  • パーミッションreadwrite のみ
  • ユーザーは複数ロールを保有可能とする
    • いずれかのロールで許可されていれば、その操作を行うことができる
      • 例:editorロール (write可) と auditor ロール (write不可) を持つユーザーは、 write可能になる
  • ロール判定パターンは、以下のどちらかを指定可能
    • ユーザーがいずれかのロールを所有している
    • ユーザーがすべてのロールを所有している
  • 実装しないもの
    • ロール定義をプログラムで追加・編集・削除すること
      • 複雑になるため実装しない
      • これにより、今回は「事前に定義したロールのみ、ユーザーに割り当てられる」となる
    • ロールに階層構造をもたせること
      • これも複雑になるため実装しない
      • 現実の会社だと、「部→課」のような階層構造がある
    • 権限として設定するのは「許可」のみとし、「拒否」は設定できない
      • 例:「 一般 グループの場合は書き込みを拒否する」は設定不可
      • 基本的なRBACでは「ロールを持っていなければ拒否」という考え方
        • もしRBACで「拒否」を明示的にしたい場合は、RBACの拡張的な感じで実装する必要がある

 

実装

パーミッション

今回のパーミッションも読み込み・書き込みのみとするため、型 PermissionBitsPermissonAction を用意します。

export type PermissionBits = {
  read: boolean
  write: boolean
}

export type PermissionAction = keyof PermissionBits // 'read' | 'write'

 

ロールの定義

ロールの定義も型として用意します。今回は追加しないため、事前に各ロールを定義します。

事前定義とするからには const assertiontype 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に対するプロンプトも記載してあります。




以上の内容はhttps://thinkami.hatenablog.com/entry/2025/08/29/221436より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14