💎

Prisma on SupabaseでRow Level Securityをいい感じに扱う(tRPCを添えて)

2024/09/04に公開

SupabaseのDBは大変便利ですが,Supabaseのクライアントではまだトランザクション機能が実装されておらず,何かしらのORMを使っている方は多いかと思います.
しかし,もしSupabase Authを通したRow Level Security(RLS)を設定していた場合,SupabaseのDBクライアントからORMに移行するときにRLSの適用を明示的に行う必要が出てきます.

この記事では特にPrisma上でSupabaseのユーザ認証を行いクエリにRLSを効かせる方法,そしてその設定をtRPCでいい感じに扱う方法の1つを紹介します.

割愛:プロジェクトのセットアップ

長くなるので省きます.いわゆるT3 Stackベースの構築ですので,本家サイト等が参考になるかと思います.細かい実装はこちらのリポジトリを参考にしました.
https://github.com/Jaaneek/t3-supabase-app-router

クエリにRLSを効かせる

動作する例

結論から言いますと自分の環境("@prisma/client": "5.19.0" / "@supabase/ssr": "0.4.0")では以下のコードでRLSが動作しました.

import { PrismaClient } from '@prisma/client'
import { createClient } from '@/supabase/server'
import { jwtDecode, type JwtPayload } from 'jwt-decode'

const client = new PrismaClient()
const supabase = createClient()

client.$transaction(async (tx) => {
  const { data: { session } } = await supabase.auth.getSession()
  const decoded: SupabaseJwt = jwtDecode(session?.access_token)
  // request.jwt.claimsにJWTトークンを設定
  db.$executeRawUnsafe(`SELECT set_config('request.jwt.claims', '${claims}', TRUE);`)
  // roleを設定
  db.$executeRawUnsafe(`SET ROLE '${decoded.aud}';`)
  // Prismaでのクエリの実行: RLSにより自身のtask以外は取得できない
  console.log("[Prisma]", await tx.tasks.findMany())
})

RLSの設定は以下の通りです.tasksテーブルにはuser_idのカラムがあります.

CREATE POLICY "user_policy_tasks" ON "public"."tasks" FOR SELECT USING ((( SELECT "auth"."uid"() AS "uid") = "user_id"));
ALTER TABLE "public"."tasks" ENABLE ROW LEVEL SECURITY;

動作しなかった例

当初,こちらの「トランザクションとRLSを有効化したクエリ」の章を参考に実装していました.
https://zenn.dev/smallstall/articles/596d3981984587#トランザクションとrlsを有効化したクエリ

しかし,以下のコードだと自分の環境ではRLSが動作しませんでした.

失敗例
import { PrismaClient } from '@prisma/client'
import { createClient } from '@/supabase/server'

const client = new PrismaClient()
const supabase = createClient()

client.$transaction(async (tx) => {
  const { data: { user } } = await supabase.auth.getUser()
  // request.jwt.claim.subにユーザIDを設定
  await tx.$executeRawUnsafe(`SELECT set_config('request.jwt.claim.sub', '${user?.id}', TRUE);`)
  // Prismaでのクエリの実行: だが他のユーザのtaskも見えてしまっている...
  console.log("[Prisma]", await tx.tasks.findMany())
})

参考にさせていただいた記事で引用されているIssueを確認したところ,以下のようなコメントがありました.

For those who has this problem, for now I am using this function helper so I can emulate RLS for supabase.

// ...
  return db.transaction(async (tx) => {
    // Set JWT to enable RLS. supabase adds the role and the userId (sub) to the jwt claims
    await tx.execute(
      sql`SELECT set_config('request.jwt.claims', '${sql.raw(jwtClaim)}', TRUE)`
    )

    // do not use postgres because it will bypass the RLS, set role to authenticated
    await tx.execute(sql`set role '${sql.raw(role)}'`)
// ...

https://github.com/drizzle-team/drizzle-orm/issues/594#issuecomment-1685079921

そこで,set_configする値をrequest.jwt.claim.subからrequest.jwt.claimsに変更し,roleを設定するようにしたところ,RLSが適切に動作するようになりました.ユーザIDをセットするだけで本当に認証が通るのか?という疑問はあったので,この変更は個人的には納得がいきます.

tRPCのルータ上でいい感じにRLSを効かせる

T3 StackではバックエンドはtRPCベースで構築します.RLSが必要となるのはユーザ認証を行った場合のみですので,ミドルウェアを通してユーザ認証を行っているエンドポイントにはRLS用のExtensionをかませたPrismaクライアントを渡すとコードが綺麗になりそうです.

まず,Prismaのクライアント,Extensionを返す関数を以下のように定義します.

db.ts
import { PrismaClient } from '@prisma/client'
import { jwtDecode, type JwtPayload } from 'jwt-decode'

export const db = new PrismaClient()

export const withSupabaseAuth = (db: PrismaClient, claims: string) => {
  // role取得のためにJWTをデコード
  const decoded: SupabaseJwt = jwtDecode(claims)

  return db.$extends({
    query: {
      $allModels: {
        async $allOperations({ args, query }) {
          // トランザクション定義
          const [, , result] = await db.$transaction([
            // request.jwt.claimsにclaimsを設定
            db.$executeRawUnsafe(`SELECT set_config('request.jwt.claims', '${claims}', TRUE);`),
            // roleを設定
            db.$executeRawUnsafe(`SET ROLE '${decoded.aud}';`),
            // クエリの実行
            query(args),
          ])

          // クエリの実行結果のみ返却
          return result
        },
      },
    },
  })
}

自分はtRPCのプロシージャは以下のように定義しています.

trpc.ts
import { PrismaClient } from '@prisma/client'
import { createClient } from '@/supabase/server'
import { type User } from '@supabase/supabase-js'
import { db, withSupabaseAuth } from './db'

// ...他のtRPCの定義

export interface PrivateTRPCContext {
  db: PrismaClient
  user: User
}

// Supabaseで認証されていることを判定するミドルウェア
const enforceUserIsAuthed = t.middleware<PrivateTRPCContext>(async ({ ctx, next }) => {
  const supabase = createClient()

  // JWTトークンの取得のためのsessionに加えてcurrentUserの情報を取得
  // ユーザ情報をよく使用するのと,sessionは改竄されやすく認証には非推奨とされているため
  // 参考:https://supabase.com/docs/reference/javascript/auth-getsession
  const [{ data: { session } }, { data: { user }}] = await Promise.all([
      supabase.auth.getSession(),
      supabase.auth.getUser(),
  ])

  const accessToken = session?.access_token

  // もしJWTトークンやcurrentUserが存在しなければ401エラー
  if (!accessToken || !user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
    })
  }

  return next({
    ctx: {
      db: withSupabaseAuth(db, accessToken),
      user,
    },
  })
})

export const privateProcedure = t.procedure.use(enforceUserIsAuthed)

この設定により,tRPCルータ上ではRLSが効いているかどうかを意識することなくクエリを発行できるようになります.

router.ts
import { router, privateProcedure } from './trpc'

export const taskRouter = router({
  list: privateProcedure.query(({ ctx }) => (await ctx.db.findMany())),
})

ただし,以上の実装だと何かしらのクエリが呼ばれるたびにJWTやroleの設定がPrismaのログに記録されていってしまうという問題があります(あと毎回TRANSACTIONが呼ばれるので普通にクエリがうるさい).まだプロダクション環境にはデプロイしていないため放置していますが,今後ログに認証情報を残さないように設定していきたいところです.

Discussion