MultiTenancyなサービスにRBAC(Role Based Access Control)を実装した

はじめに

MultiTenancyなサービスにRBAC(Role Based Access Control)を実装したので紹介します。

以前、Auth0の機能を使ってRBACを実現する例を紹介しました。SingleTenantサービス、あるいはMultiTenancyであってもユーザとテナントがN:1(ユーザが所属できるテナントが1つだけ)であれば、以下の記事の方法で実現できます。

Auth0のRBAC(Role-Based Access Control)を使ってAPIのアクセス制御をやってみた

今回実装したのは、MultiTenancyサービスであり、ユーザとテナントがN:Nであるサービスであるため、上記の記事の方法だけでは実現ができません。具体的には、以下の要件を満たす必要があります。

  • ユーザは複数のテナントに所属できる
  • ユーザの権限はテナントごとに設定できる

分かりやすいサービスの例でいうと、チャットサービスのSlackでは、1つのユーザが複数のTeamに所属することができ、TeamごとにRole(権限)を設定することができます。本記事ではこのようなサービスのアクセスコントロールを実現します。

なお、今回も認証プロバイダとしてAuth0を利用していますが、Auth0のRolesやPermissionsといった機能は利用していないので、Auth0でなくても実現できます。

戦略

  • 認証プロバイダのユーザ情報に所属するテナントと、そのテナントにおける権限を定義します。それらの情報は、idTokenのClaimにカスタムAttributeとして追加します。これにより改竄は不可になります。
  • クライアントアプリケーション(SPA)は、認証プロバイダから受け取ったidTokenをそのままServerに受け渡します。本記事では触れていませんが、SPA側でもカスタムAttributeを参照してアクセスコントロールをすることはできます。(権限がないメニューは表示しないなど、あくまで表示上の制御です。)
  • サーバアプリケーションでは、リクエストの対象となったテナントのRoleをidTokenから取り出して、Permissionに変換して、エンドポイントに必要なPermissionを満たしているかを検証します。(図では省略していますが、その前にidToken自体の検証は行います。)

実装

Koa.js + TypeScriptの実装例です。

router.ts

import Router from '@koa/router'
    import { verifyIdToken } from '../middlewares/verifyIdToken'
    import { verifyPermissions as permissions } from '../middlewares/verifyPermissions'

    const router = new Router<{}, any>()

     // verifyIdTokenがidTokenの検証
    router.use(verifyIdToken)
    // permissionsがpermissionの検証
    router.get('/contents', permissions(['content:read']), ContentsController.show)
    router.post('/contents', permissions(['content:write']), ContentsController.create)
  • verifyPermissionsというmiddlewareを用意して、エンドポイントごとに必要なpermissionを定義し検証できるようにしています。

verifyIdToken.ts

export async function verifyIdToken(ctx: Context, next: Function): Promise<void> {

      // トークンの検証を行いtenantIdtとclaimsを取得した状態

        ctx.permissions = await getPermissions(tenantId, claims['https://{{domain}}/app_metadata'])
      }

      await next()
    }
  • idTokenを検証し、リクエスト対象のテナントに応じたpermissionsをコンテキストに設定します。

verifyPermissions.ts

import { AuthorizedApiContext } from './index'

    export function verifyPermissions(needsPermissions: string[]) {
      return async function(ctx: AuthorizedApiContext, next: Function) {
        if (!needsPermissions.every(permission => ctx.permissions.includes(permission))) {
          ctx.throw(403) // 権限がない
        }
        await next()
      }
    }
  • コンテキストに設定されているpermissionsが、エンドポイントのアクセスに必要なpermissionsを満たしているか検証します。
  • AuthorizedApiContextという型は、コンテキストにpermissionsというプロパティを持っているということを明示しています。(コンテキストの型の拡張については、別の記事で紹介したいと思います。)

おわりに

  • ユーザとテナントがN:Nの関係のアクセスコントロールを実現することができました。
  • ロールと権限が切り離されているので、あとからロールを追加したり、エンドポイントに必要な権限を変更したりすることも容易です。