話題になった SurrealDB で簡単な RBAC を構築してみる

この記事は Goodpatch Advent Calendar の20日目の記事です✨

本日の担当、やっはー はGoodpatchが提供しているビジュアルコラボレーションツール『Strap』でバックエンド寄りの開発をしております。
今回は今年少し話題になった、すごいDB、 SurrealDB での、ロールベースの認可管理を試してみた話を少し書いてみます。

なぜSurrealDBを?

今年私が一番衝撃を受けたのはこの記事でした。

qiita.com

なぜか。 私たちがStrapチームが利用していて、個人的にも大好きなCloud Firestoreを超えてくる可能性がありそうだからです。

Cloud Firestoreについて

私は以前Cloud Firestoreは最高だぜ!という記事を書いていました。

goodpatch-tech.hatenablog.com

ここでも触れていますが、

  • ユーザーがfrontendから直接アクセスできる
  • データベース側で認証認可を適切に設定できる
  • リアルタイムでデータが同期できる

という3拍子揃ったDBは、なかなかありません。

Google が提供している Cloud Firestore は Security Rules で細かい認可の設定が可能で、リアルタイムでのデータ同期もサーバー側のコードや設定なしで行えます。
また、Identity Platform などの認証システムや、他PaaSとも連携が良く、開発・運用工数の削減効果は目覚ましいものがあります。まだまだ初期フェーズであり、リアルタイムでのコラボレーションが重要な Strap にとっては、まさにうってつけのシステムではあるのです。

しかしながら、デメリットが全くないわけではありません。 たとえば、

  • クラウド上に構築するしかなく、GCP以外の選択肢がない
  • Cloud Firestoreの利用制限を超えてしまう可能性がある
  • Security Rules では制御しきれないケースがある

などの懸念は3年前から変わらず抱えています。

SurrealDB は Firestore を超える?

そんな中でてきた SurrealDB は上記で挙げた利点を持ちながら欠点を相殺できる可能性を秘めています。

  • データベース側で認証の仕組みを持っている
  • WebSocketを利用した同期の仕組みも備わっている
  • 自分でスケールアップ可能である
  • ほとんどのケースで µs(マイクロ秒)で処理が終わる
  • スキーマ定義の柔軟性が高い

特にスキーマフルでも、スキーマレスでも、Neo4j のようなグラフDBとしても扱える柔軟性は目を見張るものがあります。

今回試したかったこと: RBAC - ロールベースでの認可管理

RBAC (Role-Based Access Control) はすでにさまざまなシステムでも取り入れられていますが、個人ではなくチームで利用するようなSaaSにとっては非常に重要な仕組みです。 Strapでも、当初からロールの設定やセキュリティについて、十分な注意を払って開発を進めており、認証認可は最重要ポイントとして位置付けています。

その最重要な点をまだまだ未知な部分の多い、SurrealDBで試してみて、あわよくば本番環境への投入も検討したいというのが、今回の私のモチベーションです。
※ 今回の記事ではインストールやNSの設定などは公式にもあるので、省きます。
surreal 1.0.0-beta.8+20220930.c246533 での動作を前提としています。

RBACを試すためのシナリオとスキーマ定義

シンプルに記事を管理するようなシステムで、管理者と編集者に分けて、閲覧権限のみに絞って考えてみました。

  • ロール定義:
    • 管理者(admin): 全ての記事を閲覧可能
    • 編集者(editor): 「自分が作成した記事」、または「公開状態の記事」を閲覧可能
      • 逆に「他者が作成した記事」かつ「未公開の記事」は閲覧不可
  • ユーザー:
      1. 管理者1: 全部みれるはず
      1. 編集者1: 自分が作成したものは見れるはず
      1. 編集者2: 公開されているものは見れるはず
  • 記事コンテンツ:何かしらの投稿っぽく
    • タイトル(title)
    • 著者(author)
    • 公開状態(published)

こんな感じの設計でひとまず試してみます。

SurrealDB での認証

通常のバックエンドシステムは、DMZを構築した内側にDBを配置し、一枚APIなどを介してユーザーの認証認可を確認した上で、強めの権限を持ったDBUserがクエリを発行して、取得できるデータを制御していますが、APIなどを噛ませずに直接DBにアクセスする仕組みのものは、より堅牢な認証認可の仕組みが必要です。

SurrealDBの公式の記載を元にやってみましたが、なんとなくこんな感じっぽいですかね。
理解するのに、少し時間かかりましたが、

  • サインアップもサインインもSCOPEを通してできる
  • SCOPEでの認証に応じてテーブルへのアクセスを制御できる

という感じ。

SurrealDBでの認証のイメージ
(Rustのコードの仔細まで確認できてないので、設定する時のイメージとして)

認証関連のテーブル定義

以下のクエリでuserテーブルの定義、SCOPE account の定義を行いました。

--  DEFINE `user` table
DEFINE FIELD email ON TABLE user TYPE string ASSERT is::email($value);
DEFINE INDEX email ON TABLE user COLUMNS email UNIQUE;

-- DEFINE SCOPE `account`
DEFINE SCOPE account SESSION 24h
 SIGNUP ( CREATE user SET email = $email, pass = crypto::argon2::generate($pass) )
 SIGNIN ( SELECT * FROM user WHERE email = $email AND crypto::argon2::compare(pass, $pass) )
;

crypto 関数がちゃんと用意されており、それでパスワードは暗号化して保存されていますね。 (実際の運用で利用する場合は、メールアドレスの有効性の確認などもう少し考慮が必要ですが、今回はそのまま進めます。)

クライアントサイドからのログイン

まず、簡単にクライアントサイドも必要な初期定義だけさらっとしておきます。

export const DB_URL = 'http://127.0.0.1:8000/rpc';
export const NAMESPACE = 'my_ns';
export const DATABASE = 'rbac_test';
export const SCOPE = 'account';

const USER_PARAMS = [
    ['admin@example.com', 'pass-admin', 'admin'],
    ['editor1@example.com', 'pass-editor1', 'editor'],
    ['editor2@example.com', 'pass-editor2', 'editor'],
];

class User {
    email;
    pass;
    role;
    constructor(params) {
        this.email= params[0];
        this.pass= params[1];
        this.role= params[2];
    }
}

export const LOGIN_USERS = USER_PARAMS.map(param => {
    console.log(param);
    return new User(param);
});

(以降上記は common.js として読み込まれます)

実際に、 FrontendのClientでログインする場合は以下のようなコードでそれぞれログインできました。

import Surreal from 'surrealdb.js';
import { DB_URL, NAMESPACE, DATABASE, SCOPE, LOGIN_USERS } from './common.js';

const signUp = async (email, pass) => {
    console.log('signup -> email:' + email + ' pass:' + pass);
    try {
        const db = new Surreal(DB_URL);
        await db.signup({
            NS: NAMESPACE,
            DB: DATABASE,
            SC: SCOPE,
            email: email,
            pass: pass,
        });
        console.log('sign up succeed! email:'+ email);
    } catch (e) {
        console.warn('sign up failed... email:'+ email, e.name);
        // console.warn(e);
    }
};

Promise.allSettled(
    LOGIN_USERS.map(async user => {
        return signUp(user.email, user.pass);
    })
).then(() => {
    process.exit(0); 
})

公式から提供されている Javascriptライブラリ を使用しています。

認証部分の実行結果

$ node ./signUp.js
[ 'admin@example.com', 'pass-admin', 'admin' ]
[ 'editor1@example.com', 'pass-editor1', 'editor' ]
[ 'editor2@example.com', 'pass-editor2', 'editor' ]
signup -> email:admin@example.com pass:pass-admin
signup -> email:editor1@example.com pass:pass-editor1
signup -> email:editor2@example.com pass:pass-editor2
sign up succeed! email:admin@example.com
sign up succeed! email:editor2@example.com
sign up succeed! email:editor1@example.com

ちゃんとサインアップできました!

SurrealDB での認可制限

ここからが本番です。
ロール別に制限を加えるためにデータを設定していきます。

ロールを設定

まずは以下のSQLで先程サインアップした実際のユーザにロールを割り当てます。

-- CREATE roles for each users
CREATE role SET
  user = (SELECT id FROM user WHERE email=='admin@example.com' LIMIT 1),
  name = 'admin'
;

CREATE role SET
  user = (SELECT id FROM user WHERE email=='editor1@example.com' LIMIT 1),
  name = 'editor'
;

CREATE role SET
  user = (SELECT id FROM user WHERE email=='editor2@example.com' LIMIT 1),
  name = 'editor'
;

(もう少し上手く書けそうな気がしますが、一旦スルーで)

記事レコードの作成

以下のようなSQLで作ってみます。
今回は簡略化のためにIDを指定して作成します。

-- CREATE `post`
CREATE post:one SET
  author = (SELECT id FROM user WHERE email=='editor1@example.com' LIMIT 1),
  title = 'Bow wow! I am dog.',
  published = false
;

CREATE post:two SET
  author = (SELECT id FROM user WHERE email=='admin@example.com' LIMIT 1),
  title = 'Yo! Ho! Ho!',
  published = true
;

SELECT * FROM post:one, post:two でアクセスできます。
(この辺りももう少し上手く書けそうですが、一旦このまま)

期待値とては以下のような整理ができそうです。

期待する結果
編集者2は未公開かつ自分以外の投稿は閲覧できない

ここで閲覧してみる

クライアントサイドにて、以下のようなコードで、それぞれのユーザーからのログインとDBへの接続を行ってみます。

import Surreal from 'surrealdb.js';
import { DB_URL, NAMESPACE, DATABASE, SCOPE, LOGIN_USERS } from './common.js';

const selectPost = async (user) => {
    console.log('selectPost: email:' + user.email);

    try {
        const db = new Surreal(DB_URL);
        await db.signin({
            NS: NAMESPACE,
            DB: DATABASE,
            SC: SCOPE,
            email: user.email,
            pass: user.pass,
        });
        console.log('signin! email:'+ user.email);
        
        try {
            const post = await db.select('post:one');
            console.log('selectPost post:one succeed! email:'+ user.email + ' title:' + post[0].title);
        } catch (e) {
            console.warn('selectPost post:one failed... email:'+ user.email);
        }

        try {
            const post = await db.select('post:two');
            console.log('selectPost post:two succeed! email:'+ user.email + ' title:' + post[0].title);
        } catch (e) {
            console.warn('selectPost post:two failed... email:'+ user.email);
        }

    } catch (e) {
        console.warn('selectPost failed... email:'+ user.email);
        // console.warn(e);
    }
}

Promise.allSettled(
    LOGIN_USERS.map(async user => {
        return selectPost(user);
    })
).then(() => {
    process.exit(0); 
})

まだ制限をかけていないので、全て閲覧できない状態です。

$ node ./select.js
[ 'admin@example.com', 'pass-admin', 'admin' ]
[ 'editor1@example.com', 'pass-editor1', 'editor' ]
[ 'editor2@example.com', 'pass-editor2', 'editor' ]
selectPost: email:admin@example.com
selectPost: email:editor1@example.com
selectPost: email:editor2@example.com
signin! email:admin@example.com
selectPost post:one failed... email:admin@example.com
selectPost post:two failed... email:admin@example.com
signin! email:editor2@example.com
selectPost post:one failed... email:editor2@example.com
selectPost post:two failed... email:editor2@example.com
signin! email:editor1@example.com
selectPost post:one failed... email:editor1@example.com
selectPost post:two failed... email:editor1@example.com

記事レコードに対しての閲覧制限を設定

ここが今回のキモです。

以下のようなクエリで、テーブルに対してpermissionを設定します。 $auth というのがログインしているユーザーを示しているようでした。

-- DEFINE TABLE `post` with  PERMISSIONS
DEFINE TABLE post
  PERMISSIONS
    FOR select
      -- Published posts can be selected
      WHERE
       published = true
      -- A user can select all their own posts
      OR author = $auth.id
      -- A user has role as `admin`
      OR (SELECT user FROM role WHERE name='admin') CONTAINS $auth.id 
;

ポイントは以下の3点のいずれかマッチした場合に許可しています。

  • published = true 公開状態のデータであるか
  • author = $auth.id ユーザー自身が作成したデータである
  • (SELECT user FROM role WHERE name='admin') CONTAINS $auth.id ユーザーは ’admin’ ロールである

再度、閲覧してみる

先程のコードを再度実行してみます。

$ node ./select.js
[ 'admin@example.com', 'pass-admin', 'admin' ]
[ 'editor1@example.com', 'pass-editor1', 'editor' ]
[ 'editor2@example.com', 'pass-editor2', 'editor' ]
selectPost: email:admin@example.com
selectPost: email:editor1@example.com
selectPost: email:editor2@example.com
signin! email:admin@example.com
selectPost post:one succeed! email:admin@example.com title:Bow wow! I am dog.
selectPost post:two succeed! email:admin@example.com title:Yo! Ho! Ho!
signin! email:editor2@example.com
selectPost post:one failed... email:editor2@example.com
selectPost post:two succeed! email:editor2@example.com title:Yo! Ho! Ho!
signin! email:editor1@example.com
selectPost post:one succeed! email:editor1@example.com title:Bow wow! I am dog.
selectPost post:two succeed! email:editor1@example.com title:Yo! Ho! Ho!

見事、ちゃんと制限されて、編集者2は post:one を閲覧できないことがわかりますね!

今後の展開

このPERMISSIONSは create,update,delete にも設定できるので、基本はそれらも含めてちゃんと設定した上で、テストコードを定義して運用しながら開発していくのが良さそうです。

この他にもTOKENでの認証や更新時の返り値にDIFFを指定できたり、まだまだ試してきれていませんが、組み合わせていけば、より柔軟かつ強固な管理はできそうです。

感想とまとめ

ただ、今回、SurrealDBを扱ってみての正直な感想は、まだまだ奥が深いなと。私自身は全然理解しきれておらず、本番環境への投入には踏み切れないという感じ。

ドキュメントやチュートリアルもまだまだ少なく、簡単なDBとは言えない印象はあります。 ただ、それでも導入する場合のメリットはたくさんあり、バックエンドエンジニアが十分に設定や運用のための施策を的確に実施できれば、1つの選択肢として考慮に入れておきたいDBです。

本体の開発スピードも早く、まだまだ機能も増えていくようなので、これからの展開に期待しましょう!

surrealdb.com

おまけ

今回の画像も全て Strap で作ってます。 いやー便利だよ。トライアルもできるのでぜひお試しくださいね。

product.strap.app