📚

App Router でDDDっぽくやってみた(権限制御編)

2024/04/23に公開

本来、フロントエンド領域において、ドメイン駆動設計(以下、DDD)を採用することはほとんどないと思っています。なぜなら、当該のシステムにおいて、フロントエンドはサーバサイドから受け取ったデータを人間が視認しやすく表現するための領域であり、ドメインに沿った処理を責務とするのは、サーバサイド領域であるのがメジャーとされているためです。

この背景の真意は、個人的にはどちらでもいいのですが、現在開発中のシステムでは、フロントエンド領域で、DDDっぽいことをする必要にかられたので、そのまとめを記載しています。

システム概要 / 要素技術

以下が使用技術の抜粋です。

  • Next.js v14.1 / App Router
  • GraphQL / PostGraphile / Apollo Client

本システムのバックエンドは、複数サービスでのデータ集積までを責務としています。
フロントエンドでは、これら集積データをユーザーに提供することを責務としていますが、同時に、ユーザーの権限に応じた制御もやる必要がありました。

例えば、以下のような権限と操作があるとします。

  • 閲覧 ... そのデータを見ることのみ可能。
  • 編集 ... 上記に加えて、データの追加、リネームが可能。
  • 管理 ... 上記に加えて、データの削除、ユーザーへの権限操作等全てが可能。

outline

冒頭でも述べたように、定石に従えば、PostgreSQL から受け取るタイミングで、権限に沿ったデータになっていることが望ましいのですが、本システムの制約上 PostGraphile を使用する必要があったので、どうするべきか悩んでいました。

悩んでいた時の解決案1

https://casl.js.org/v4/en/advanced/typescript

各操作における権限制御をお手軽にやってくれるライブラリですが、クライアントサイドでの動作なので、ロジック漏洩が気になっていました。

悩んでいた時の解決案2

https://docs.nestjs.com/graphql/quick-start

NestJsを使って、APIサーバを別途立てて、そこに権限制御を閉じ込めるやり方です。やむなしでしょうが、面倒だなあという印象でした。私、NestJsかけないし。。。

最終的な解決策

https://nextjs.org/docs/app/building-your-application/data-fetching

上記で悩んでいた時に、NextJsがサーバーでの操作を基本とするようになったので、これなら一石二鳥では?ということで、NextJsのサーバサイド領域で権限制御を実装するようにしました。

表題にもある、ドメインですが、私は 「システムが 気にしたいこと」と定義しているので、この権限による操作もそれに該当するとして、DDDを採用することにしました。実体はサーバサイドですが、他のフロントエンドの資産と共存させています。

やったこと

決定表 [1]

権限と操作の組み合わせを考えると、組み合わせは膨大になります。実装の前に、状況を可視化するには、決定表が効果的でした。

  • ⚪︎ ... 条件に該当するもの
  • - ... ケース番号上は、どちらの条件でもよいもの
  • X ... 期待結果を得られるもの
条件 ケース番号 -> 1 2 3 4
権限 閲覧 ⚪︎
編集 ⚪︎
管理 ⚪︎ ⚪︎
対象ユーザー 自分自身 - - ⚪︎
他者 - - ⚪︎
期待結果 1 2 3 4
データを検索できる X X X X
データを追加できる X X X
データをリネームできる X X X
ユーザーを追加できる X
ユーザーをリネームできる X
ユーザーを削除できる X
ユーザーの権限を変更できる X

これの利点は、条件の種類が増えていっても、うまく可視化できるところだと思います。
よくある、縦×横の表では、2つの要素までしか扱えませんし。

例えば、ケース番号3であれば、
「条件が権限:管理かつ対象ユーザ:自分自身の場合は、期待結果として、データを検索、追加、リネームができる、ユーザーをリネームができる」と読みます。

ドキュメントとソース のトレース

上記をドキュメントに記録しておけば、このシステムの権限制御の仕様がわかるでしょう。これを実装するときに、表のままに まとまっている方が、システムを維持していく上で、あとで見直すときや、テストをしやすくする上でも 楽だろうと考えました。

つまり、ロジックが分散している 以下のような実装はしたくないってことです。

src/feature/sample/add/AddSampleOnServer.tsx
export const AddSampleOnServer = async () => {
  const permissionKind = await getPermissionKind(); 
  
+  if(!(permissionKind === 'editor' || permissionKind === 'admin')) {
+  // または permissionKind === 'viewer'
+    return null; 
+  }

  return <AddSampleOnClient {...anyProps} />
};
/src/feature/permission/permissionKind.ts
export type PermissionKind = 'viewer' | 'editor' | 'admin'; 
// 左から順番に、閲覧、編集、管理

条件を変更したいってなると、ファイル探さないといけないし、テストがしずらい上に、反映漏れが出そうです。また、middleware.tsx とpage.tsx とはロジックを揃える必要があるときに、これだと実装がバラつくリスクもあります。
まったく同じ制御をする必要がある場合[2]は、同じ関数を使用するのが望ましいです。

データ操作の実装

はじめに、データの操作について考えてみようと思います。ユーザーの操作と分離した理由として、条件の数が異なるためです。

データの操作の条件は、権限に対しての2値の判定、すなわち静的なものなので、以下のような定数を作成し、パッと見えるようにしています。

/src/feature/permission/dataOperation.ts
+ export const dataOperation = {
+   dataSearch: higherThanViewer;
+   dataAdd: higherThanEditor;
+   dataRename: higherThanEditor;
+ }

type PermissionLevel = Record<PermissionKind, boolean>

const higherThanViewer: PermissionLevel = {
  viewer: true;
  editor: true;
  admin: true;
}

... 割愛

const adminOnly: PermissionLevel = {
  viewer: false;
  editor: false;
  admin: true;
}

表までの可読性はないですが、閲覧権限以上のレベルなのか、編集権限以上のレベルなのかが集積されるので、ロジックの散らばりを防ぐことができます。
さらに、参照先で呼びやすいように関数を定義すれば完成です。

/src/feature/permission/judgeDataAddable.ts
export const judgeDataAddable = (permissionKind: PermissionKind) => {
  return dataOperation.dataAdd[permissionKind];
}
src/feature/sample/add/AddSampleOnServer.tsx
export const AddSampleOnServer = async () => {
  const permissionKind = await getPermissionKind(); 
  
-  if(permissionKind === 'viewer') {
-  // または !(permissionKind === 'editor' || permissionKind === 'admin')
+  if(!(await judgeDataAddable(permissionKind))) {
    return null; 
  }

  return <AddSampleOnClient {...anyProps} />
};

このロジックが打倒であるか、テストもしやすくなります。

judgeDataAddable.test.ts
/src/feature/permission/judgeDataAddable.test.ts
describe("judgeDataAddable", () => {
  it.each`
    permissionKind | expected
    ${'viewer'}    | ${false}
    ${'editor'}    | ${true}
    ${'admin'}     | ${true}
  `('$permissionKind のとき、$expected である', ({ permissionKind, expected }) => {
    expect((judgeDataAddable(permissionKind)).toEqual(expected);
  });
});

ユーザー操作の実装

自分自身かどうかの要素が加わりますが、基本的には同じ具合です。

judgeUserDeletable.ts
/src/feature/permission/judgeUserDeletable.ts
export const judgeUserDeletable = (permissionKind: PermissionKind, isOwn: boolean) => {
  return userOperation.userDelete[permissionKind] && !isOwn;
}
/src/feature/permission/judgeUserDeletable.test.ts
describe("judgeUserDeletable", () => {
  it.each`
    permissionKind | isOwn    | expected
    ${'viewer'}    | ${true}  | ${false}
    ${'viewer'}    | ${false} | ${false}
    ${'editor'}    | ${true}  | ${false}
    ${'editor'}    | ${false} | ${false}
    ${'admin'}     | ${true}  | ${false}
    ${'admin'}     | ${false} | ${true}
  `('$permissionKind , isOwn=$isOwn のとき、$expected である', ({ permissionKind, isOwn, expected }) => {
    expect((judgeUserDeletable(permissionKind, isOwn)).toEqual(expected);
  });
});

もう少し使いやすく

ロジックが集約されますし、目的は果たせているのですが、よくあるのが、権限操作の処理ってどの関数使えばいいの?ってメンバー内でしばしば聞かれることです。これは、この関数、あれは、あの関数 って正直面倒なので、よりよく選択しやすくできないか考えます。

/src/feature/permission/permission.ts
export const permission = {
  data: {
    search: () => dataCallBacker(judgeDataSearchable),
    add: () => dataCallBacker(judgeDataAddable),
    rename: () => dataCallBacker(judgeDataRenamable),
  },
  user: {
    add: (targetUserId: string) => userCallBacker(judgeUserAddable, targetUserId),
    rename: (targetUserId: string) => userCallBacker(judgeUserRenamable, targetUserId),
    delete: (targetUserId: string) => userCallBacker(judgeUserDeletable, targetUserId),
    changeMode: (targetUserId: string) => userCallBacker(judgeUserModeChangable, targetUserId),
  },
  // ↑ 実際はもう少し可読性いいように実装していますが、
  // ファイル数多くなって、記事が読みづらくなるので、羅列しています。
}

const dataCallBacker = async (func: (permissionKind: PermissionKind) => boolean) => {
  const permissionKind = await getPermissionKind();

  return func(permissionKind);
}

const userCallBacker = async (func: (permissionKind: PermissionKind, targetUserId: string) => boolean) => {
  const permissionKind = await getPermissionKind();
  const userId = await getUserId();

  return func(permissionKind, targetUserId === userId);
}

とすることで、

src/feature/sample/add/AddSampleOnServer.tsx
export const AddSampleOnServer = async () => {
-  const permissionKind = await getPermissionKind(); 
  
-  if(!(await judgeDataAddable(permissionKind))) {
+  if(!(await permission.data.add())) {
    return null; 
  }

  return <AddSampleOnClient {...anyProps} />
};

って感じで、シンプルに書けるようになりますし、権限制御したい時は、 permission.ts を使うようにと統制を取りやすくなります。

そうはいっても、TypeScriptのexportは、どこからでもアクセスされてしまうので、せっかくまとめた関数があっても、まとめる前の個別のロジックがうっかり使用されることもあります。そんなときの強い味方がこちらです。

https://zenn.dev/uhyo/articles/eslint-plugin-import-access

/src/feature/permission/dataOperation.ts
+ /**
+ * @package
+ */
export const dataOperation = {
  dataSearch: higherThanViewer;
  dataAdd: higherThanEditor;
  dataRename: higherThanEditor;
}

...
/src/feature/permission/judgeDataAddable.ts
+ /**
+ * @package
+ */
export const judgeDataAddable = (permissionKind: PermissionKind) => {
  return dataOperation.dataAdd[permissionKind];
}
/src/feature/permission/judgeUserDeletable.ts
+ /**
+ * @package
+ */
export const judgeUserDeletable = (permissionKind: PermissionKind, isOwn: boolean) => {
  return userOperation.userDelete[permissionKind] && !isOwn;
}

@package をつけることで、/src/feature/permission 以外から使用した場合に、eslintで検知することが可能です。
ファイルが増えたとしても、使えるものが絞られるのは、メンバー間の混乱を防げるので、だいぶ重宝しています。

3行まとめ

  • ロジックは散らばせないように、専用のファイルを用意しよう。
  • メンバー間で使いやすい形にロジックを集積してみよう。
  • 意図しない使われ方をされないように、exportが最小限になるように工夫してみよう。
脚注
  1. デシジョンテーブル と等しい。 ↩︎

  2. まったく同じとは言い切れず、似ているだけの場合は、同じ関数を使用してはいけません。 ↩︎

GitHubで編集を提案
フィシルコム

Discussion