🙆‍♂️

権限によるUI制御をラクにするためにReactでやった設計/実装✅

に公開

はじめに

ユーザーが特定の操作を行っても良いのか、情報を閲覧しても良いのか、などを制御するのに必要な認可処理。セキュリティの観点や、データの正しい操作を行えるようにするという点で主にバックエンド側が責任を持ってこれを行うのですが、フロントエンドにおいてもユーザー体験の向上を主なモチベーションとして認可の実装が必要になってきます。

この記事では自分がReactで行ったフロントエンドの認可周りの設計や、具体的な実装の一部を共有できたらなと思っています。

どんなことができると良さそうか

UIの表示非表示、差し替え

ユーザーの権限に応じて利用可能な機能や閲覧可能な情報などが制限されるので、操作を行うためのUIや、情報を閲覧するための導線は、非表示にしたり代替UIに差し替えたりする必要があります。そうなった場合、実装する側としてはこのようなコンポーネントがあるとやりやすそうです。

<AuthorizationBoundary
  requirements={/* 必要な権限 */}
  fallback={() => <Button disabled>閲覧専用</Button>}
>
  <Button>登録</Button>
</AuthorizationBoundary>

AuthorizationBoundary内でユーザーの認可情報を参照し、それが必要な権限を持つかどうか判定するイメージ。

内部処理のロジックの分岐

<AuthorizationBoundary />のようなタグだけだと、描画内容の切り替えはできてもボタンを押した時のアクションとして実装しているロジックや、データの整形ロジックなどの切り替えはできないので、このようなhooksを作って判定結果をbooleanで受け取れると便利そうです。

export function useAuthorization(requirements: /* 必要な権限 */) {
  /* ユーザーの権限情報などをとってくる */

  const isAuthorized: boolean = useMemo(() => /* ユーザーの権限が`requirements`を満たすかどうかの判定 */), [...])

  return { isAuthorized }
}

特定のページへのアクセスの防止、リダイレクト

例えば、ユーザーが閲覧する権限を持たないページへ何らかの理由でアクセスしてきてしまった場合、他のページやエラーページにリダイレクトしてあげる必要があります。リダイレクトをせず通してしまったら、バックエンド側の認可にひっかかってエラーが返ってくるなどして有用なデータをユーザーに表示することができず、体験としては良くないですからね。

先述したuseAuthorizationuseEffectを使って権限がないならリダイレクトをする、という処理を各ページの実装に追加することもできますが、ページの実装でどうこうというよりもそもそもそのページパス(ルート)へのアクセスをブロックするような感覚で実装をしたいなと思ったので、自分は以下のように、ルーティングに関する設定を行うファイルで認可が必要なページを明示していく実装方針を取りました。

// React Routerのルーティング設定ファイル(routes.ts)

export default [
  index('components/pages/index.tsx'),

  // 権限に応じて通常通りレンダリングするかリダイレクトを行うコンポーネントをLayoutとして実装
  layout('./AuthorizationBoundary/route-guard/user-read.tsx', [
    route('/users', 'components/pages/users/index.tsx'),
    route('/users/:id', 'components/pages/users/detail.tsx'),
  ])
  layout('./AuthorizationBoundary/route-guard/user-write.tsx', [
    route('/users/create', 'components/pages/users/create.tsx'),
    route('/users/:id/edit', 'components/pages/users/edit.tsx'),
  ])
]
// AuthorizationBoundary/route-guard/user-read.tsx
import { Navigate } from 'react-router'

export default function RGUserRead() {
  return (
    <AuthorizationBoundary
      requirements={/* 必要な権限 */}
      fallback={() => <Navigate to="/" />}
    >
      <Outlet />
    </AuthorizationBoundary>
  )
}

ユーザーの権限情報の管理

権限に応じて表示を切り替えたりする必要のある箇所は各ページの実装内に散在しています。となるとログイン中のユーザーの権限情報には広範囲からアクセスしたくなるため、一定の統制を効かせながら状態管理、公開をする必要があります。

まず状態の公開自体はreactに備わってる機能を利用しContext/Providerパターンで実装することにしました。

export const AuthorizationContext = createContext</* 認可情報 */>(null)

export function AuthorizationProvider({ children }: PropsWithChildren) {
  /* 認可情報をstateなどで管理 */
  /* 認可情報がまだ取れていないなら、APIから情報を取ってくる */

  return (
    <AuthorizationContext.Provider value={{ /* 認可情報 */ }}>
      {children}
    </AuthorizationContext.Provider>
  )
}

無秩序にuseContextを使っていろんなところで認可情報にアクセスするコードを書かせると参照を追いづらくなるので、「どんなことができると良さそうか」項で紹介したコンポーネントたちのみがこの情報へアクセスするようにします。

export function useAuthorization(requirements: /* 必要な権限 */) {
  const { /* 認可情報 */ } = useContext(AuthorizationContext)

  const isAuthorized: boolean = /* ユーザーの権限が`requirements`を満たすかどうかの判定 */

  return { isAuthorized }
}
interface Props {
  requirements: /* 必要な権限 */
  fallback?: () => React.ReactNode
}
export function AuthorizationBoundary({ requirements, children, fallback }: PropsWithChildren<Props>) {
  const { isAuthorized } = useAuthorization(requirements)

  return isAuthorized ? children : fallback?.() ?? null
}

各ページでの実装ファイルではuseAuthorizationAuthorizationBoundaryを介してのみユーザーの権限に基づく分岐ロジックを実装することで、権限情報への直接のアクセス経路が限定され、またその権限情報を「どう扱うか」の責任をuseAuthorizationAuthorizationBoundaryに移譲することができます。

利用側では難しいことは考えず、判定に必要な「必要な権限(requirements)」や「権限を持つなら何をするか、持たないなら何をするか」だけを実装すれば良いのです。

「権限」の表現

自分が認可処理を実装したアプリでは、APIからユーザーのRoleのenum値("ADMIN", "USER"など)がもらえるかたちになっていたのですが、前述したように権限による分岐が細かく散在するフロントエンドで用いるにはやや抽象度が高い概念で扱いづらそうだと思ったのでPBAC(Policy Based Access Control)っぽい感じで権限を表現することにしました。

(参考にした記事)
https://zenn.dev/ukkyon/articles/9bac5194f91e53

フロントエンドで扱う概念としては、「何の機能であるか(Feature)」「どの程度の強さの権限を持つか(Authority)」、これを機能の種類分セットでもつ「あるRoleについての権限の範囲(Policy)」の3つを定義しています。実態はコードと一緒に後述しますが、おおまかな関係は画像の通り。

authorization
APIからもらったRoleをPolicyに読み替える。RoleとPolicyの対応は定数ファイルで定義しておく

Feature

権限によって分岐制御を行う単位である「機能」はenumっぽくunique symbolとunionでシンプルに表現。

export const USERS: unique symbol = Symbol('USERS')
export const ARTICLES: unique symbol = Symbol('ARTICLES')
export const COMMENTS: unique symbol = Symbol('COMMENTS')
export const FILES: unique symbol = Symbol('FILES')

export type Feature =
  | typeof USERS
  | typeof ARTICLES
  | typeof COMMENTS
  | typeof FILES

Authority

ある機能について「禁止」なのか「読み取り専用」なのかなどを表す権限の強さは、Featureと同じようにunique symbolを使いつつ、「読み取り専用権限以上ならOK」のような表現を可能にするための比較関数を定義しておきます。

export const FORBIDDEN: unique symbol = Symbol('FORBIDDEN')
export const READONLY: unique symbol = Symbol('READONLY')
export const WRITE: unique symbol = Symbol('WRITE')

export type Authority = typeof FORBIDDEN | typeof READONLY | typeof WRITE

const rankMap: Record<Authority, number> = {
  [FORBIDDEN]: 0,
  [READONLY]: 1,
  [WRITE]: 2,
} as const

export const compare = (x: Authority, y: Authority) => rankMap[x] - rankMap[y]

Requirement

主にページ実装の各所で「この権限を持っていればOK(UIを表示する)」を表現するためのFeatureとAuthorityのペア値を、Requirementとして定義しています。

import { Authority } from './authority'
import { Feature } from './feature'

export type Requirement = [Feature, Authority]

export const feature = (r: Requirement): Feature => r[0]
export const authority = (r: Requirement): Authority => r[1]

export const of = (feature: Feature, authority: Authority): Requirement => [feature, authority]
// alias of `of`
export const requirement = of

Policy

あるRoleが、各機能についてどの程度の権限を持つのかを表す辞書オブジェクトをPolicyとして表現します。さらに関連関数として、Requirementと比較して「必要な権限を持つかどうか」の判定を行うものを実装しておきます。

import * as Auth from './authority'
import * as Feat from './feature'
import * as Req from './requirement'

export type Policy = {
  [key in Feat.Feature]: Auth.Authority
}

export const satisfies = (policy: Policy): (r: Req.Requirement) => boolean => {
  const geqAuth = (x: Auth.Authority, y: Auth.Authority) => Auth.compare(x, y) >= 0

  return (r) => {
    return geqAuth(policy[Req.feature(r)], Req.authority(r))
  }
}

export const requires = (requirements: readonly Req.Requirement[]): (p: Policy) => boolean => {
  return p => requirements.every(satisfies(p))
}

これらを踏まえて最終的に出来上がるユーティリティ

これまでの考え方をベースに、各画面での具体的なアクセス制御は以下のユーティリティを通じて実装していきます。

import { createContext, useContext, type PropsWithChildren } from 'react'
import { Navigate } from 'react-router'
import * as P from './policy'
import type { Requirement } from './policy/requirement'

export const AuthorizationContext = createContext<P.Policy | null>(null)

export function AuthorizationProvider({ children }: PropsWithChildren) {
  const policy: P.Policy | null = /* 認可情報をAPIから取ってきてPolicyに変換する */

  return (
    <AuthorizationContext.Provider value={{ policy }}>
      {children}
    </AuthorizationContext.Provider>
  )
}

// ユーザーの持つ権限が引数に取った`Requirement[]`を満たすなら`isAuthorized: true`を返す
export function useAuthorization(requirements: readonly Requirement[]) {
  const { policy } = useContext(AuthorizationContext)
  const checkPolicy = P.requires(requirements)

  const isAuthorized = policy ? checkPolicy(policy) : false

  return { isAuthorized }
}

// ユーザーの持つ権限が引数に取った`Requirement[]`を満たすなら`children`を返し、そうでないなら`fallback`、あるいは`null`を返す
// UIの実装部分で権限に応じた分岐を設ける際に用いる
interface ABProps {
  requirements: readonly Requirement[]
  fallback?: () => React.ReactNode
}
export function AuthorizationBoundary({ requirements, children, fallback }: PropsWithChildren<ABProps>) {
  const { isAuthorized } = useAuthorization(requirements)

  return isAuthorized ? children : fallback?.() ?? null
}

// ユーザーの持つ権限が引数に取った`Requirement[]`を満たすなら`children`を返し、そうでないならリダイレクトをする
// 権限に応じたアクセス制御をページ単位で行う際に利用する
export function AuthorizationGuardBoundary({ requirements, children }: PropsWithChildren<Omit<ABProps, 'fallback'>>) {
  return (
    <AuthorizationBoundary requirements={requirements} fallback={() => <Navigate to="/" />}>
      {children}
    </AuthorizationBoundary>
  )
}

例えば読み取り権限以上のユーザーじゃないと操作できないボタンは以下のように実装できます。

import * as Auth from './authority'
import * as Feat from './feature'
import { requirement } from './requirement'

/* ... */

<AuthorizationBoundary
  requirements={[requirement(Feat.USERS, Auth.WRITE)]}
  fallback={() => <Button disabled>閲覧専用</Button>}
>
  <Button>登録</Button>
</AuthorizationBoundary>

おわりに

フロントエンドにおける認可は、ページ単位だけではなくUIひとつひとつにも気を配って実装をしていくことにもなってくるので、末端の実装ではコンポーネント一つ、あるいはhook一つ呼べば分岐処理が簡単に実現できるように再利用性が高く統制の効いた基盤を整えることを意識しました。

もちろん、要件やスケールによってはもっとシンプルな構成で十分なケースもあると思いますが、認可処理の実装が必要になったときに、今回紹介した考え方が参考になれば嬉しいです。

ispec inc.

Discussion