🐡

Row Level Securityをアプリに組み込む

2024/09/12に公開

はじめに

はじめまして、e-dash 株式会社でバックエンドエンジニアを担当している石田です!
弊社では、toB向けにCO2排出を可視化するSaaSを提供しています。

toB向けのSaaSをマルチテナントで展開する際に特に気にすべきなのが、テナントを跨いだ処理が行わないようにする機密性の担保です。

万一「A会社で見れる情報の中に、B会社の情報が含まれた」となると、機密性のインシデントレベルはMaxです。
エンジニアは夜でも緊急対応に当たることになりますし、プロダクト・会社の信頼低下に直結します。

ですが、未だ多くのプロダクトでSQLのwhere句にcompanyIDを入れる&レビューで弾く、が実情かと思います。
そこを仕組みで解決してくれるのが、Row Level Security(以下、RLS)で、
SQLの実行時にcompanyIDをクエリ条件に入れてくれます。

RLSの検証記事はよくみますが、じゃあ実際のtoB向けのアプリでは、
・RLSを設定する・設定しないテーブルはどう分けるべきか?
・RLS設定後、どうやってアプリから動かすの?
という点にあまり触れられてないので、この記事ではこれら2点について触れます。

RLS導入の一助になれば幸いです。
初めて記事を書くのでお手柔らかにお願いします。

注意

2024/8/31(執筆時点)でMySQLでは、RLSが提供されていません。

RLSを設定する・設定しないテーブルはどう分けるべきか?

postgresqlでは、以下の青枠のようにdatabaseの下に、schema単位でテーブルを分けることができます。(※ここもMySQLにはない特徴)

引用:https://www.javadrive.jp/postgresql/schema/index1.html

①サーバーからアクセスする際、schema単位で、RLSの条件を入れる or 入れないを分けられる。
②schemaを、companyIDによらず取得するcommon、companyIDで必ず絞りたいcompanyの2つにすると見通しが良い
③RLSの設定を入れていないテーブルを検出する仕組みを入れやすい(DDLのPRが上がった時に、CIでRLSの設定入ってないかチェック入れたいですよね。)
これらの点で、schema単位でRLSを設定するのが良いかと考えています。

common schemaに入るテーブルの例として、プロダクト全体で利用するマスタデータや、認証前にテーブルを見に行くことがあるaccountsが挙げられます。
company schemaには、会社個別のトランザクションデータが入るイメージです。

RLS設定後、どうやってアプリから動かすの?

じゃあ、上のスキーマに分けて、アプリからはどう動かすのか?を解説していきます。
※参考の記事を元に、RLSの設定(policyの設定)済みのテーブルがある前提とします。
※Goのorm gormを利用したコードですが、他の言語・ormでも同様の考え方かと思います。

company schemaのtodosテーブルへのRLS設定例
# company schemaに対し、todo_policy という名前でtodosテーブルのcompany_idでRLSを有効化。
SET search_path TO 'company';

ALTER TABLE todos ENABLE ROW LEVEL SECURITY;

CREATE POLICY todo_policy ON todos
  USING (company_id = current_setting('app.company_id')::uuid);

こちら↓は、repositoryを呼び出す側(usecaseやservice層)で利用する、dbclientのメソッドです。
ポイントは、👇です。

  1. tableのprefixを定義して、commonとcompanyを分けているので、うっかり間違ったschemaにクエリが流れないこと
  2. companyIDとsessionを引数に渡すようになっており、渡したcompanyIDは、SET app.company_id に代入されているので、実装側がcompanyIDの渡し方を意識しなくて良いこと
// client.go
package database
// ...
func CompanyQuery[T any](db *gorm.DB, companyID uuid.UUID, callback func(session *gorm.DB) (T, error)) (T, error) {
	db.Config.NamingStrategy = schema.NamingStrategy{
		TablePrefix: "company.",
	}

	var result T
	var err error
	db.Connection(func(session *gorm.DB) error {
		session.Exec(fmt.Sprintf("SET app.company_id = '%s';", companyID.String()))
		result, err = callback(session)
		if err != nil {
			return errors.New("error:" + err.Error())
		}
		return err
	})

	return result, err
}

あとは利用する層で、database.CompanyQueryを呼び出すだけです!
(companyIDはuserのctxに持ってるはず。)

// service.go
type TodoServiceImpl struct {
	todoRepository ITodoRepository
	db          *gorm.DB
}

func NewTodoServiceImpl(tr ITodoService, db *gorm.DB) ITodoService {
	return &TodoServiceImpl{
		todoRepository: tr,
		db:          db,
	}
}
// 渡したcompanyIDで絞られた結果が返る
func (tr *TodoService) FindAll(ctx *gin.Context, userContext user.UserContext) ([]Todo, error) {
	return database.CompanyQuery(tr.db, userContext.companyID, func(session *gorm.DB) ([]Todo, error) {
		return tr.Repository.FindAll(ctx, userContext, session)
	})
}

これで実装側が意識することは、where句の書き忘れ => client.goのメソッドを呼び出し になりました。(楽っ!)
sessionを直で渡すコードを書かれたらRLSが効かない点は注意ですが、where句の書き忘れよりは、レビューで弾きやすいんじゃないでしょうか?

余談ですが、client.goには、transaction用のdatabase.CompanyTxもあると良いですね。

まとめ

今回、RLSを実際にアプリに組み込むにはどうするのか?について、触れてみました。
shemaの単位やsessionの渡し方などはプロダクトによって考えはあるかと思いますので、あくまで一例として捉えていただければ幸いです。
「RLSの設定を入れていないテーブルを検出する仕組み」については、別の記事で執筆予定です。
RLS導入の参考になれば幸いです。
ここまで読んでいただきありがとうございました。

採用情報

e-dashエンジニアチームは現在一緒にはたらく仲間を募集中です!
同じ夢について語り合える仲間と一緒に、環境問題を解決するプロダクトを作りませんか?

参考

https://zenn.dev/taxin/articles/postgresql-row-level-security-policy

https://www.slideshare.net/AmazonWebServicesJapan/20220107-multi-tenant-database#10

Discussion