Row Level Securityをアプリに組み込む
はじめに
はじめまして、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のメソッドです。
ポイントは、👇です。
- tableのprefixを定義して、commonとcompanyを分けているので、うっかり間違ったschemaにクエリが流れないこと
- 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エンジニアチームは現在一緒にはたらく仲間を募集中です!
同じ夢について語り合える仲間と一緒に、環境問題を解決するプロダクトを作りませんか?
参考
Discussion