この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
はじめに
MultiTenancyなサービスにRBAC(Role Based Access Control)を実装したので紹介します。
以前、Auth0の機能を使ってRBACを実現する例を紹介しました。SingleTenantサービス、あるいはMultiTenancyであってもユーザとテナントがN:1(ユーザが所属できるテナントが1つだけ)であれば、以下の記事の方法で実現できます。
今回実装したのは、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の関係のアクセスコントロールを実現することができました。
- ロールと権限が切り離されているので、あとからロールを追加したり、エンドポイントに必要な権限を変更したりすることも容易です。