CASLによるアイソモーフィックな権限管理:NestJSとReactのロジックを同期させる決定版

Development tutorial - IT technology blog
Development tutorial - IT technology blog

権限管理における「ロジックの二重管理」という罠

NestJSでAPIを保護するために if (user.role === 'admin') のようなロジックを大量に書いたことはありませんか?最近ではPasskey (WebAuthn)による認証の簡略化が進んでいますが、認可のロジックは依然として複雑になりがちです。その直後、ボタンの表示・非表示を切り替えるためにReact側にも全く同じコードをコピーしているはずです。要件が変更されると、これは悪夢に変わります。例えば、上司が「エディター」も記事を削除できるようにしたいと言い出した場合、プロジェクトの両端をくまなく探して修正しなければなりません。一箇所でも修正し忘れると、ロジックエラーや、さらに深刻なセキュリティホールが発生します。これは、Node.jsにおけるクリーンアーキテクチャが欠如した際によく見られる問題です。

そこで役立つのが Isomorphic Authorization(アイソモーフィックな認可)です。二箇所でロジックを維持する代わりに、単一のルールセットのみを定義します。CASL は、これを実現するための最も強力なライブラリです。

なぜCASLがゲームチェンジャーなのか?

CASL(キャッスルと発音)は、アビリティベース(Ability-based)で権限を管理します。isAdmin のような硬直したロールチェックの代わりに、CASLは「ユーザーが どの対象 に対して どのようなアクション を実行できるか」に焦点を当てます。これは、マイクロサービス等で利用されるOpen Policy Agent(OPA)の考え方に通じるアプローチです。

CASLの最大の利点はその一貫性です。共有のTypeScriptファイルでロジックを定義し、それをNestJSにインポートしてAPIを制限し、ReactにインポートしてUIをレンダリングします。修正が必要なのは一つのファイルだけです。この「スキーマ共有」のメリットは、React Hook Form + Zodでバリデーションを構築する場合と同様、開発の堅牢性を大きく高めます。

4つの押さえておくべき概念

  • Actions: manage(全権限)、createreadupdatedelete などのアクション。
  • Subject: UserPost、または 'all' などの操作対象。
  • Ability: ユーザーの操作を許可するルールの集合体。
  • Conditions: より詳細な条件。例:authorId がユーザーの userId と一致する場合のみ記事の編集を許可する。

実践:フルスタックプロジェクトへのCASLの導入

1. 共有ロジック(Shared Logic)の設定

shared フォルダに ability.ts ファイルを作成しましょう。これがバックエンドとフロントエンドの両方にとっての「信頼できる唯一の情報源(Single Source of Truth)」になります。

// shared/ability.ts
import { AbilityBuilder, PureAbility, AbilityClass, ExtractSubjectType, InferSubjects } from '@casl/ability';

export enum Action {
  Manage = 'manage',
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete',
}

export type Subjects = InferSubjects<'Post' | 'User'> | 'all';
export type AppAbility = PureAbility<[Action, Subjects]>;
export const AppAbility = PureAbility as AbilityClass<AppAbility>;

export function defineAbilityFor(user: any) {
  const { can, cannot, build } = new AbilityBuilder<AppAbility>(AppAbility);

  if (user.role === 'admin') {
    can(Action.Manage, 'all'); 
  } else {
    can(Action.Read, 'all');
    // 実践的なルール:自分の投稿のみ編集を許可
    can(Action.Update, 'Post', { authorId: user.id }); 
    cannot(Action.Delete, 'Post').because('管理者のみが記事を削除できます');
  }

  return build({
    detectSubjectType: (item) => item.constructor.name as ExtractSubjectType<Subjects>,
  });
}

2. NestJSバックエンドへの統合

バックエンドでは、リクエストからのユーザーに基づいて権限を初期化するFactoryが必要です. メタデータを多く含む複雑な権限オブジェクトを扱う際、私はよく toolcraft.app を使用してJSON構造を素早くフォーマットし確認しています。これにより、ロジックの混乱を防げます。

npm install @casl/ability

Guardで使用するための CaslAbilityFactory を作成します:

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: any) {
    return defineAbilityFor(user);
  }
}

あとは、コントローラーにリクエストが到達する前に権限を確認する PermissionsGuard を作成するだけです。この方法により、最初の保護レイヤーで不正アクセスを阻止できます。

3. Reactフロントエンドへの同期

Reactサポート用のパッケージをインストールします:

npm install @casl/react @casl/ability

AbilityContext を使用してアプリケーションをラップします。ステップ1で作成した defineAbilityFor 関数を再利用します。これにより、UIの表示・非表示の切り替えが非常に楽になります:

import { Can } from './context/AbilityContext';

function PostItem({ post }) {
  return (
    <div>
      <h2>{post.title}</h2>
      {/* ユーザーがこの投稿を更新する権限を持っている場合のみ編集ボタンを表示 */}
      <Can I="update" this={post}>
        <button>編集</button>
      </Can>

      <Can I="delete" a="Post">
        <button>削除</button>
      </Can>
    </div>
  );
}

実践的なヒント:クラスを見失わないように

よくあるミスは、バックエンドが生のJSONを返し、フロントエンドのCASLがそれがどのクラスのオブジェクトであるか認識できないことです。その結果、a('Post') のようなルールが正しく動作しなくなります。

解決策は、ヘルパー関数を使用してオブジェクトを「ハイドレート(復元)」することです。__type 属性を付与するか、class-transformer を使用して生のデータをクラスのインスタンスに変換します。そうすることで、CASLは定義した can/cannot ルールを正確にマッピングできるようになります。

まとめ

CASLを導入することで、コードをDRY(Don’t Repeat Yourself)原則に従わせることができます。初期設定には少し時間がかかりますが、将来のデバッグ時間を大幅に節約できます。これは、ドメイン駆動設計(DDD)を意識した、保守性の高いコードベースを構築するための第一歩となります。特にプロジェクトが数十のロールや数百の権限に拡大した場合には非常に有効です。必要に応じて権限データを確認するために toolcraft.app を活用することも忘れないでください。実装の成功を祈っています!

Share: