TypeScriptで権限管理ができるCASLを使ってみた

TypeScriptで権限管理ができるCASLを使ってみた

2025.07.24

はじめに

アプリケーションを開発していると、ユーザーごとのリソースに対する権限の管理が必要になるケースがあります。
権限の考え方はアプリケーションによって様々で、アプリケーションの特性に合わせて ABAC や RBAC などを選択し設計していくことになると思います。
しかし実装においては権限によるアクセス制御はアプリケーションの本質ではないため、ビジネスロジックから可能な限り切り離し、設計やデータ構造に依存しない抽象的な構造で取り扱いたいと考えることがあります。

今回はこれらの助けとなる CASL というライブラリについて、概要の説明や調査した結果を記載していこうと思います。

https://casl.js.org/v6/en/

CASL の概要

クライアントがアクセス可能なリソースの権限管理をサポートする JavaScript 製の認可ライブラリです。
TypeScript のサポートもされており、コアとなる @casl/ability をベースに、Prisma や MongoDB、React などに向けた拡張が用意されています。

CASL は Ability と呼ばれる「ユーザーがアプリケーション内で実行可能な操作」を表すオブジェクトで権限の管理をします。
ユーザーの権限情報のまとまり= Ability といったイメージです。
Ability は以下の 4 つの要素で構成されています。

  • Action
    createread など、ユーザーが実行可能な行動そのものを表します。
    CRUD 操作に限らず、Action は自由に設定することができます。
    manage という Action が CASL で予約されており、これは任意の Action(全ての Action)を実行可能という意味を持ちます。
  • Subject
    行動を制御するリソースそのものを表します。
    通常は Entity のようなドメインオブジェクトになると思いますが、Action 同様自由に設定可能です。
    名前のみ、名前 + 型のいずれかで管理することができます。
    all という Subject のみ CASL で特別な扱いがされており、これは全ての Subject を表します。
  • Fields
    ユーザーの Action を制限したいフィールドを表します。
    ユーザーは特定のプロパティやカラムのみしか更新できないといった権限が必要なケースの場合に使用します。
  • Conditions
    条件を満たす場合にのみ権限を制御したい場合に設定する権限です。
    ユーザーは自分が作成したデータは更新可能ではあるものの他人の作成したデータは更新不可といった、同一 Subject に対して条件によって権限が変わる場合にその条件を設定します。

準備

まずは準備をしていきます。
今回は CASL Prisma を用いて DB への参照、操作権限をどのように制御するかを確認していきたいので DB を構成します。

DB、Seed を以下のようにしました。

schema.prisma
// ユーザー
model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  password  String
  isAdmin   Boolean  @default(false)

  Post Post[]
}

// 投稿
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  author User @relation(fields: [authorId], references: [id])
}

User

user

Post

post

Ability の作成

続いて Ability を作成していきます。
公式のサンプル を参考に以下の設定で構築していきます。

  • ユーザーは投稿を自由に作成できる
  • ユーザーは全ての公開済みの投稿を閲覧できる
  • ユーザーは自身の作成した非公開の投稿を閲覧できる
  • ユーザーは自身の投稿を更新できる
  • ユーザーは自身の投稿を削除できる
casl-ability.ts
// 指定可能なAction
export type Actions = 'read' | 'create' | 'update' | 'delete';

// 指定可能なSubject
export type AppSubjects =
  | 'all'
  | Subjects<{
      User: User;
      Post: Post;
    }>;

type AppAbility = PureAbility<[Actions, AppSubjects], PrismaQuery>;

const buildAbility = (userId: number) => {
  const { can, cannot, build } = new AbilityBuilder<AppAbility>(
    createPrismaAbility
  );

  // 投稿を作成できる
  can('create', 'Post');

  // 全ての公開済みの投稿を閲覧できる
  can('read', 'Post', { published: true });
  // 自身の非公開の投稿を閲覧できる
  can('read', 'Post', { published: false, authorId: userId });

  // 自身の投稿を更新できる
  can('update', 'Post', { authorId: userId });

  // 自身の投稿を削除できる
  can('delete', 'Post', { authorId: userId });

  return build();
};

export default buildAbility;

権限の確認

では作成した Ability を用いて権限の確認をしていきましょう。
まずは参照権限からです。
CASL Prisma では accessibleBy() を用いることで、Prisma の WhereInput の形式で権限に伴う条件の出力を行うことができます。
これを使用しテストコードを記述していきます。

casl-ability.spec.ts
describe('casl-ability', () => {
  it('テストユーザー1の参照権限確認', async () => {
    const user = await prisma.user.findUniqueOrThrow({
      where: {
        id: 1,
      },
    });

    const ability = buildAbility(user.id);

    // 参照可能な全ての投稿を取得
    const posts = await prisma.post.findMany({
      where: accessibleBy(ability, 'read').Post,
    });

    // 公開されている投稿が2件取得できる
    expect(posts.filter((post) => post.published)).toHaveLength(2);

    // テストユーザー1の非公開の投稿が取得できる
    expect(
      posts.filter((post) => post.authorId === user.id && !post.published)
    ).toHaveLength(1);

    // テストユーザー1以外の非公開の投稿が取得できない
    expect(
      posts.filter((post) => post.authorId !== user.id && !post.published)
    ).toHaveLength(0);
  });
});

以下のような実行結果になりました。

 ✓ src/app/core/casl-ability.spec.ts (1 test) 23ms
   ✓ casl-ability > テストユーザー1の参照権限確認 22ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  10:04:27
   Duration  458ms (transform 29ms, setup 35ms, collect 32ms, tests 23ms, environment 155ms, prepare 61ms)

テストが無事成功しましたが、具体的にどのようなデータが取得されているのかを確認してみます。
テストコードの以下の部分です。

const posts = await prisma.post.findMany({
  where: accessibleBy(ability, 'read').Post,
});

以下のようになっていました。

[
  {
    id: 1,
    title: 'テストユーザー1のはじめての投稿',
    content: '最初の投稿です',
    published: true,
    authorId: 1,
    createdAt: 2025-07-24T00:27:11.713Z,
    updatedAt: 2025-07-24T00:27:11.713Z
  },
  {
    id: 2,
    title: 'テストユーザー1の下書きの投稿',
    content: '下書きの投稿です',
    published: false,
    authorId: 1,
    createdAt: 2025-07-24T00:27:11.717Z,
    updatedAt: 2025-07-24T00:27:11.717Z
  },
  {
    id: 3,
    title: 'テストユーザー2のはじめての投稿',
    content: '最初の投稿です',
    published: true,
    authorId: 2,
    createdAt: 2025-07-24T00:27:11.718Z,
    updatedAt: 2025-07-24T00:27:11.718Z
  }
]

作成した Ability の read 権限は「公開済みの投稿は全て閲覧可能」かつ「自身の非公開の投稿は閲覧可能」でしたので、autherId2publishedfalse のデータが取得されていないのは期待通りです。
それ以外のデータも条件に合致しています。

続いて作成、更新権限の確認をしていきます。
(削除権限は更新と条件が同じなので割愛)

casl-ability.spec.ts
describe('casl-ability', () => {
  ...

  it('テストユーザー1の作成権限確認', async () => {
    const user = await prisma.user.findUniqueOrThrow({
      where: {
        id: 1,
      },
    });

    const ability = buildAbility(user.id);

    // ユーザーの投稿の作成権限の確認
    expect(() =>
      ForbiddenError.from(ability).throwUnlessCan('create', 'Post')
    ).not.toThrow();
  });

  it('テストユーザー1の更新権限確認', async () => {
    const user = await prisma.user.findUniqueOrThrow({
      where: {
        id: 1,
      },
    });

    // テストユーザー1の投稿
    const userPost = await prisma.post.findFirstOrThrow({
      where: {
        authorId: user.id,
      },
    });

    // テストユーザー1以外の投稿
    const notUserPost = await prisma.post.findFirstOrThrow({
      where: {
        authorId: {
          not: user.id,
        },
      },
    });

    const ability = buildAbility(user.id);

    // ユーザーの投稿の更新権限の確認
    expect(() =>
      ForbiddenError.from(ability).throwUnlessCan(
        'update',
        subject('Post', userPost) as AppSubjects
      )
    ).not.toThrow();

    // 他者の投稿の更新権限の確認
    expect(() =>
      ForbiddenError.from(ability).throwUnlessCan(
        'update',
        subject('Post', notUserPost) as AppSubjects
      )
    ).toThrow(ForbiddenError);
  });
});

作成や更新権限の確認では ForbiddenError を使用して確認していきます。
throwUnlessCan() 関数は権限がない場合に ForbiddenError を throw します。
作成権限は条件を指定していないため、Post に対して create の権限があるかどうかのみを確認しています。

// ユーザーの投稿の作成権限の確認
expect(() =>
  ForbiddenError.from(ability).throwUnlessCan('create', 'Post')
).not.toThrow();

更新権限は自身の作成した投稿のみを許可しているため、更新対象のオブジェクトを引き渡しその属性が条件と一致するかで判断してもらいます。
評価対象のオブジェクトを Subject として扱うために、subject() 関数を用いています。

// ユーザーの投稿の更新権限の確認
expect(() =>
  ForbiddenError.from(ability).throwUnlessCan(
    'update',
    subject('Post', userPost) as AppSubjects
  )
).not.toThrow();

// 他者の投稿の更新権限の確認
expect(() =>
  ForbiddenError.from(ability).throwUnlessCan(
    'update',
    subject('Post', notUserPost) as AppSubjects
  )
).toThrow(ForbiddenError);

テスト結果も期待通りになりました。

 ✓ src/app/core/casl-ability.spec.ts (3 tests) 48ms
   ✓ casl-ability > テストユーザー1の参照権限確認 40ms
   ✓ casl-ability > テストユーザー1の作成権限確認 2ms
   ✓ casl-ability > テストユーザー1の更新権限確認 6ms

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  12:03:27
   Duration  923ms (transform 34ms, setup 78ms, collect 49ms, tests 48ms, environment 363ms, prepare 32ms)

まとめ

CASLを用いて簡単な権限管理を実装、確認をしてみました。
CASLを用いることで、データ構造や設計に依存しない抽象的な管理ができる点が魅力ではないかと思います。
今回はcan() 関数を用いた許可のみの構成で実装してみましたが、実際の開発では cannot() を用いた拒否の設定も必要になると思いますし、 ABAC や RBAC などより複雑なユースケースへの対応も必要になってくると思います。
より具体的なユースケースについて、CASLでのアプローチとどのような課題があるかを調査し、プロダクトの開発で活用していきたいと思います。

この記事をシェアする

facebookのロゴhatenaのロゴtwitterのロゴ

© Classmethod, Inc. All rights reserved.