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


権限管理の1つであるACL (Access Control List) をTypeScriptで実装してみた

前回は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パーミッション同様、題材はファイル管理システム
  • 権限の判定は readwrite のみ
  • 権限判定を要求する対象は、ユーザーもしくはグループ
  • 対象とリソースに対して 許可拒否 を設定・管理する
    • 許可と拒否の両方が設定されている場合は、 拒否 と判定する
  • 判定結果は以下の3つ
    • 許可
    • 拒否
    • マッチしない
      • マッチしないときは、アプリケーションに判断を任せる
  • 1つのリソースは、1つのインスタンスで管理する
  • root などの特権ユーザーは定義しない

 

実装

パーミッション

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

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

 
今回のACLでは 許可拒否 の2種類を扱います。TypeScriptは構造的型付けを採用しているため、同じ構造を持つオブジェクトはすべて同じ型として扱われてしまうことから、以下のような問題が発生しそうでした。

  • 許可用の権限データを、誤って拒否の処理で使ってしまう
  • 拒否用の権限データを、誤って許可の処理で使ってしまう

 
そこで、TypeScriptの Branded Types を使い、型レベルで問題を発生させないようにします。

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に対するプロンプトも記載してあります。




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

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