🍱

SupabaseでRLSを使ったマルチテナントを試す

2023/03/02に公開約13,600字

はじめに

この記事は前回からの続きとなっています。
https://zenn.dev/smallstall/articles/c71e356c35a7e3
ソースコードはこちらにあります。
https://github.com/smallStall/multitenant_supabase/tree/main
RLSを用いたマルチテナント実装について、軽く説明いたします。

マルチテナント

テナントとはサービスの一区画を利用しているグループのことであり、マルチ+テナントで、そんなグループが複数あるような状態を指します。ビルを想像してみるとわかりやすいかもしれません。ビルがサービスであり、その中にいろんな企業(テナント)が入っている状況です。企業向けのサービスであれば、共同作業することが多いのでマルチテナントになりやすいです。
色々な実装方法があるようなのですが、今回はRLSを使って実装してみたいと思います。マルチテナントと言っても複雑なものから簡単なものまでいくつかあるようなのですが、最も簡単な「複数のテナントに人が所属していることはない」ケースで考えていきます。具体的に言うとテナントをまたいで兼務する人は考えないということです。
概念の詳しい説明については、先達がいらっしゃるので、そちらをご参照ください。
https://aws.amazon.com/jp/blogs/news/multi-tenant-data-isolation-with-postgresql-row-level-security/
https://speakerdeck.com/yudppp/row-level-security-is-silver-bullet-for-multitenancy

アプリの概要

今回バックエンドにKnex.js、フロントエンドにNext.jsを使います。Next.jsを使うのは、テナントのIDをクライアントに漏らさないようにしたかったからです。作るアプリはTODOにします。また、ローカル環境でのお試しになっており、本番環境ではありません。
テーブル構造は大体こんな感じを想定しています。

インストール

以下のコマンドを実行してみてください

git clone https://github.com/smallStall/multitenant_supabase.git
cd multitenant_supabase
npm install
cd supabase
npm install

インストールしたらSupabaseを動かします。SupabaseのDockerも事前に動かしておいてください。

/supabaseにて
npm run start

うまくいけばこんな表示が出るはずです。

Using environment: development
Ran 1 seed files

http://localhost:54323/projects を開いてテーブルにデータが入っていることを確認します。
このときanon keyも表示されるので、ルートの.envファイルに追記しておきます。

.env
NEXT_PUBLIC_SUPABASE_ANON_KEY="ここにanonキーを貼り付け"

次に、npm run startで何をやっていたのかについて説明します。

バックエンドでのマイグレーション

npm run startでは次の3つのコマンドを順次実行していました。

npx supabase start
npx knex migrate:up
npx knex seed:run

1行目でsupabaseが動くとともにseed.sqlが実行されます。2行目でmigrations/migration.js、3行目でseeds/seed.jsが実行されます。

seed.sql
-- デフォルト権限からanonを外す
alter default privileges in schema public revoke all on tables from anon;
alter default privileges in schema public revoke all on functions from anon;
alter default privileges in schema public revoke all on sequences from anon;

-- ユーザー登録時にtenant_idがあればそのテナントの一般ユーザーだとみなして登録する
-- tenant_idがなければテナントの管理ユーザーだとみなして登録する
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = public
as $$
declare
  tenant_id uuid :=gen_random_uuid();
begin
  if new.raw_user_meta_data ->>'tenant_id' is null then -- 管理ユーザー登録の場合
    insert into public.tenants (id, tenant_name) 
    values (tenant_id,  new.raw_user_meta_data ->>'tenant_name');
    insert into public.profiles (tenant_id, user_id, user_name, role)
    values (tenant_id, new.id, new.raw_user_meta_data ->>'user_name', 'manager'); 
    update auth.users
    set raw_user_meta_data = to_jsonb(jsonb_build_object('tenant_id', tenant_id))
    where id = new.id;
  else -- 一般ユーザー登録の場合
    insert into public.profiles (tenant_id, user_id, user_name, role)
    values (cast(new.raw_user_meta_data ->>'tenant_id' as uuid), new.id, new.raw_user_meta_data ->>'user_name', 'general');
    update auth.users
    set raw_user_meta_data = to_jsonb(jsonb_build_object('tenant_id', new.raw_user_meta_data ->>'tenant_id'))
    where id = new.id;
  end if;
  return new;  
end;
$$;


-- trigger the function every time a user is created
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();


create or replace function set_tenant_id (tenant_id text)
  returns void as $$
  begin
  perform set_config('app.tenant', tenant_id, false);
  end;
  $$ language plpgsql;

seed.sqlでは主にユーザー登録時のトリガーを作っています。このトリガーではユーザー登録時にテーブルにtenant_idを差し込んでいます。ユーザーはテナントに所属している「一般ユーザー」とテナントを管理している「管理ユーザー」に大きく分かれます。管理ユーザーの登録の際にはテナントを作るようにしますが、一般ユーザーの登録の際にはすでにテナントはできているので、そのテナントに割り当てるようにしました。
set_tenant_id関数は、あとでNext.jsから呼び出します。ここでtenant_idを引数に取り、app.tenant変数にセットしています。この変数の値はセッション内で維持されます。

migration.js
/**
 * @param { import("knex").Knex } knex
 * @returns { Promise<void> }
 */

const basicDefault = (table, knex) => {
  table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()"));
  table.boolean("is_deleted").notNullable().defaultTo(false);
  table.timestamps(true, true);
};


const tenantSecurityPolicy = (tableName, isTenantsTable) => `
CREATE POLICY tenant_policy_${tableName} ON ${tableName}
USING (${
  isTenantsTable ? "id" : "tenant_id"
} = current_setting('app.tenant')::uuid)
`;

const enableRLS = (tableName) => `
ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY
`;

const enableTenantRLS = (tableName, knex, isTenantsTable) => {
  return new Promise((resolve) => {
    knex.raw(tenantSecurityPolicy(tableName, isTenantsTable)).then(() => {
      knex.raw(enableRLS(tableName)).then(() => {
        resolve();
      });
    });
  });
};

exports.up = function (knex) {
  return knex.schema
    .createTable("tenants", (table) => {
      table.string("tenant_name", 40).notNullable();
      table.uuid("customer_id");
      basicDefault(table, knex);
    })
    .createTable("profiles", (table) => {
      table
        .uuid("user_id")
        .notNullable()
        .references("id")
        .inTable("auth.users");
      table.string("user_name", 18).notNullable();
      table
        .enu("role", ["manager", "general", "beginer"])
        .notNullable()
        .defaultTo("beginer");
      table.uuid("tenant_id").notNullable().references("id").inTable("tenants");
      basicDefault(table, knex);
    })
    .createTable("todos", (table) => {
      table.string("todo_name", 24).notNullable();
      table.boolean("is_done").notNullable().defaultTo(false);
      table
        .uuid("profile_id")
        .notNullable()
        .references("id")
        .inTable("profiles");
      table.uuid("tenant_id").notNullable().references("id").inTable("tenants");
      basicDefault(table, knex);
    })
    .then(() => enableTenantRLS("tenants", knex, true))
    .then(() => enableTenantRLS("profiles", knex))
    .then(() => enableTenantRLS("todos", knex));
};

/**
 * @param { import("knex").Knex } knex
 * @returns { Promise<void> }
 */

exports.down = function (knex) {
  return knex.schema
    .dropTable("todos")
    .dropTable("profiles")
    .dropTable("tenants");
};

migration.jsではテーブルの作成を行っています。注目すべきはtenantSecurityPolicyのところで、ここでRLSを記述しています。current_setting('app.tenant')にて、現在のセッションにあるapp.tenantパラメータを取り出しています。通常、ここに現在のユーザーが所属しているテナントIDが入っています。そのテナントIDとテーブルのテナントIDカラムの値を比較して一致する行だけを取得します。結果として、ユーザーが所属しているテナントのTODOを取得することができるようになります。

seed.js
const crypto = require("crypto");
/**
 * @param { import("knex").Knex } knex
 * @returns { Promise<void> }
 */

const makeAuthUser = async (name, knex, tenant_id) => {
  //ユーザーログイン用の暗号化されたパスワードを取得
  const crypts = await knex.raw(
    "SELECT crypt('pass!12word', gen_salt('bf', 4));"
  );
  //https://github.com/supabase/supabase/discussions/9251
  await knex("auth.users").insert({
    id: crypto.randomUUID(),
    instance_id: "00000000-0000-0000-0000-000000000000",
    email: name + "@mail.como",
    encrypted_password: crypts.rows[0].crypt,
    role: "authenticated",
    aud: "authenticated",
    email_confirmed_at: knex.fn.now(),
    confirmation_token: "",
    email_change: "",
    email_change_token_new: "",
    recovery_token: "",
    created_at: knex.fn.now(),
    updated_at: knex.fn.now(),
    raw_app_meta_data: JSON.stringify({
      provider: "email",
      providers: ["email"],
    }),
    raw_user_meta_data: JSON.stringify({
      user_name: name,
      tenant_name: "tenant_" + name,
      tenant_id: tenant_id,
    }),
  });
};

exports.seed = async function (knex) {
  // Deletes ALL existing entries
  await knex("todos").del();
  await knex("profiles").del();
  await knex("auth.users").del();
  await knex("tenants").del();
  await makeAuthUser("nohoho", knex);
  await makeAuthUser("hogehoge", knex);
  const nohoho = await knex("profiles")
    .select("*")
    .where({ user_name: "nohoho" });
  //momomoはnohohoと同じテナント
  await makeAuthUser("momomo", knex, nohoho[0].tenant_id);
  const profs = await knex("profiles").select("*");

  for (let i = 0; i < profs.length; i++) {
    await knex("todos").insert({
      tenant_id: profs[i].tenant_id,
      profile_id: profs[i].id,
      todo_name: profs[i].user_name + "さんのtodo",
    });
  }
};

seed.jsではテスト用のデータをデータベースに入れています。ユーザーを3人作り、それぞれ、auth.usersにデータを入れています。これにより、seed.sqlで作成したトリガーにより、profilesテーブルやtenantsテーブルにデータが入ります。
nohohoとmomomoは同じテナント内のユーザーとして、hogehogeは別テナントのユーザーとしてデータを入れています。hogehogeからは自分のテナントしかデータを見れず、nohohoとmomomoのデータは見れません。一方、nohohoとmomomoは自分たちのデータを見ることはできますが、hogehogeのデータは見れません。そういうふうになっていないと他社に自社のデータが漏れてしまうような状況になるため、よろしくありません。

フロントの実装

次に、ルートフォルダの説明をします。

src/components/loginForm.tsx
import React from "react";
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import { useRouter } from "next/router";
type Props = {
  switchSignupLogin: () => void;
};

export const LoginForm = ({ switchSignupLogin }: Props) => {
  const router = useRouter();
  const supabaseClient = useSupabaseClient();
  supabaseClient.auth.onAuthStateChange(async (_event, session) => {
    if (session) {
      router.push("protected");
    }
  });
  return (
    <div>
      <button type="button" onClick={switchSignupLogin}>
        新規登録はこちら
      </button>
      <h1>ログイン</h1>
      <form
        method="post"
        onSubmit={async (event: React.SyntheticEvent) => {
          event.preventDefault();
          const target = event.target as typeof event.target & {
            email: { value: string };
            password: { value: string };
          };
          supabaseClient.auth.signInWithPassword({
            email: target.email.value,
            password: target.password.value,
          });
        }}>
        <div>
          <label htmlFor="email">
            メールアドレス:
            <input id="email" name="email" autoComplete="username" type="email" />
          </label>
        </div>
        <div>
          <label htmlFor="password">
            パスワード:
            <input id="password" type="password" name="password" autoComplete="current-password" />
          </label>
        </div>
        <input type="submit" />
      </form>
    </div>
  );
};

ログイン用のコンポーネントを作成しました。
これを使ってログインページを作成します。

pages/index.tsx
import type { NextPage } from "next";
import { SignUpForm } from "../components/signUpForm";
import { LoginForm } from "../components/loginForm";
import { useState } from "react";

interface Props {
  count: number;
}

const Home: NextPage<Props> = () => {
  const [isSignup, setIsSignup] = useState(false);
  const switchSignupLogin = () => setIsSignup((prev) => !prev);

  if (!isSignup) return <LoginForm switchSignupLogin={switchSignupLogin} />;
  return <SignUpForm switchSignupLogin={switchSignupLogin} />;
};

export default Home;

次に、ログインしたユーザーだけが入れるようなページを作成します。

pages/protected.tsx
import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs";
import { GetServerSidePropsContext } from "next";
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import { useRouter } from "next/router";
import Link from "next/link";
import { ProfilesTodos } from "@/types/table";

export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
  const supabase = createServerSupabaseClient(ctx);
  const {
    data: { session },
  } = await supabase.auth.getSession();

  if (!session)
    return {
      redirect: {
        destination: "/",
        permanent: false,
      },
    };
  const user = await supabase.auth.getUser();
  if (!user) return;
  const { error: setTenantError } = await supabase.rpc("set_tenant_id", {
    tenant_id: user.data.user?.user_metadata.tenant_id,
  });
  if (setTenantError)
    return {
      redirect: {
        destination: "/",
        permanent: false,
      },
    };

  const { data } = await supabase
    .from("profiles")
    .select(`user_name, todos (todo_name)`);
  const name = await supabase
    .from("profiles")
    .select(`user_name`)
    .eq(`user_id`, user.data.user?.id)
    .single();

  if (!name.data)
    return {
      redirect: {
        destination: "/",
        permanent: false,
      },
    };
  return {
    props: {
      userName: name.data.user_name,
      data: data,
    },
  };
};

export default function Protected({
  userName,
  data,
}: {
  userName: string;
  data: ProfilesTodos[] | null;
}) {
  const router = useRouter();
  const supabaseClient = useSupabaseClient();
  return (
    <>
      <p>
        [<Link href="/">Home</Link>]
      </p>
      <p>ようこそ{userName}さん</p>
      {data ? (
        data.map((user, index) => {
          return (
            <div key={"div" + index}>
              <p
                key={"p" + index}
              >{`${user.user_name}さんのTodoは次の通りです`}</p>
              <ul key={"ul" + index}>
                {user.todos.map((todo) => {
                  return (
                    <li
                      key={"todo" + todo.id}
                    >{`タイトル:${todo.todo_name}`}</li>
                  );
                })}
              </ul>
            </div>
          );
        })
      ) : (
        <p>なんも返ってきませんでした</p>
      )}
      <button
        onClick={async () => {
          await supabaseClient.auth.signOut();
          router.push("/");
        }}
      >
        Logout
      </button>
    </>
  );
}

supabase.rpcにてseed.sqlで定義した関数を呼び出しています。これにより、そのセッションにおけるtenant_idを保存することができます。これでマルチテナントを実現できるようになりました。
フロント側で開発サーバを実行します。

npm run dev

localサーバーが開くのでURLをクリックします。
ログインのメールアドレスはnohoho@mail.como、パスワードはpass!12wordで開けるはずです。

終わりに

今回はローカル環境でのRLSを用いたマルチテナントを試してみました。今後は本番環境で動くのかどうかを試していきたいと考えています。ではでは。

Discussion

ログインするとコメントできます