はじめに
初めまして、株式会社Techouseエンジニアインターンの sakaidubz と申します。本日は私の携わっているプロダクトであるクラウドハウス労務で利用している RLS (Row Level Security) の技術について紹介します。
Techouse では、重要技術として RLS を多用しています。
通常 PostgreSQL の運用時には利用しないものであるため Techouse の開発メンバーとしてジョインしたみなさんが手慣れるまでに少し苦労をされているようです。
そこでこの場を借りて解説してみようと思い立ちました。
クラウドハウス労務について
RLS について紹介する前に、私が開発しているクラウドハウス労務について紹介します。
クラウドハウス労務は人事労務における複雑な業務の電子化を推進するセミオーダー型・クラウド業務支援サービスです。各種手続きや年末調整といった法定業務など様々な機能を持っており、企業の人事労務担当者と従業員とのやりとりを簡単に行うことができます。
これらのたくさんの手続きによって集められた大量の従業員データは、クラウドハウス労務のデータベースに格納されています。
マルチテナントアーキテクチャとシングルテナントアーキテクチャ
複数企業のデータを収容するにあたって、「マルチテナントアーキテクチャ」と「シングルテナントアーキテクチャ」の2つの考え方があります。
「シングルテナントアーキテクチャ」は、顧客単位で独立したインフラを持つアーキテクチャです。これにより各顧客のデータは他の顧客のデータと完全に分離され、セキュリティとパフォーマンスの面で優れたメリットがあります。
しかし、運用難易度の増加、ピーク時に必要とされる性能に合わせて通常時の性能を確保する必要があるなどコストがかかります。
「マルチテナントアーキテクチャ」は、複数の顧客データを1つのインフラで収容するアーキテクチャです。
我々のプロダクトでは複数の顧客データを1つの Amazon Aurora PostgreSQL クラスタに収容するアプローチを採用していますので、「マルチテナントアーキテクチャ」にあたります。
このアプローチのメリット・デメリットを解説してみましょう。
メリット
- コストが安い
- 複数のテナントが同じインフラを共有するため、各テナントごとに専用のリソースを用意する必要がなく、コスト削減につながる
- 余剰性能を他の顧客で共用できるため、シングルテナントアーキテクチャに比べて余剰性能を多く持つ必要がない
- 1つのデータベースで複数テナントのデータ管理するため、運用管理が簡素化される
- データベースのアップデートやバックアップを一元的に行うことができる
- 複数のデータベースを個別に管理する必要がない
デメリット
- セキュリティリスク
- 他の顧客のデータが同じデータベースで保持されるため、情報流出のリスクがある
- パフォーマンスの影響
- 複数テナントが同一リソースを利用するため、一部のテナントの高負荷が全体のパフォーマンスに影響する恐れがある
セキュリティリスクは非常に大きく、労務データを管理するサービスとしては何か問題が発生したら即大事故となります。とくに、顧客企業をまたいでデータを参照できるような状況に至った場合にはたいへん重大なセキュリティ事故となります。最悪の事態です。
クラウドハウス労務では、RLS によってそうした事故が起きない事を技術的に保証しています。その内容について次章で詳しく説明します。
引用: https://opensource-db.com/multi-tenancy-on-postgres/
我々の RLS に対する考え方
たとえば、下記のような脆弱性のあるコードが混入した際には即「最悪の事態」に至ります。
- パラメータ未チェックに起因する脆弱性
- SQLインジェクション脆弱性
これらの脆弱性が万一混入したとしても「最悪の事態」に至らないことを技術的に保証するため、RLS を利用しています。
RLS をちょっと深掘り
簡単に説明すると
RLS (Row Level Security) は、データベースの行ごとにアクセス制御を行う技術です。これにより、ユーザは自分がアクセス権を持つデータのみを閲覧・操作できます。
RLS は、データベースのセキュリティポリシーを定義することで実現されます。
※補足: 以下、例としてコードが出てきますが、あくまで例のため当社プロダクトの中の構造とは異なります。
ちょっと深掘り
RLS の実装には、以下のステップが含まれます。
- Policy の定義:どのユーザがどのデータにアクセスできるかを定義
- Filter Predicate の作成:データベースクエリに適用されるフィルター条件を作成
- Policy の適用:定義したポリシーをデータベーステーブルに適用
例えば、PostgreSQL では以下のように RLS を設定します。
-- テーブルの作成 CREATE TABLE employees ( id SERIAL PRIMARY KEY, name TEXT, company_id INT ); -- RLSの有効化 ALTER TABLE employees ENABLE ROW LEVEL SECURITY; -- ポリシーの作成 CREATE POLICY company_policy ON employees USING (company_id = current_setting('myapp.current_company_id')::int);
ソースコードでの利用
次に、Rails アプリケーションで現在ログインしている従業員の会社IDを設定する方法を示します。
class ApplicationController < ActionController::Base # Controller の Action が実行される前に、現在の会社IDを PostgreSQL の変数として設定 # # WARNING: この行を消したら即大事故。消してはいけない。 before_action :set_current_company private def set_current_company # 現在のユーザの会社IDを設定 ActiveRecord::Base.connection.execute("SET myapp.current_company_id = #{current_user.company_id}") end end class EmployeesController < ApplicationController def index # 現在の会社における従業員の一覧を取得 @employees = Employee.all end end
このように、アプリケーションコード内で現在の会社IDを設定し、その後のクエリで RLS が適用されるようにします。
RLSにおけるアンチパターン
アンチパターン 1
RLS の実装にはいくつか注意が必要ですが、ここではアプリケーションコード内で RLS を無効化してしまうアンチパターンを紹介します。
class EmployeesController < ApplicationController def index # 現在の会社における従業員の一覧を取得 ActiveRecord::Base.connection.execute("SET myapp.current_company_id = 1") @employees = Employee.all end end
このコードでは、@employees
の取得の前に myapp.current_company_id
がハードコーディングで更新されてしまっています。したがって、他の企業の従業員が company_id
が 1 の企業の従業員情報を閲覧できるようになってしまいます。
上記のコードは極端な例になっていますが、RLS のポリシーを意図通りに機能させるため、アプリケーション側でパラメータを適切に更新する必要があります。
アンチパターン 2
もう1つのアンチパターンとして、RLS の設定が不適切な実装例を示します。これまでの例では、企業IDによってフィルタリングを行うために、RLS を利用していました。
しかし、パフォーマンスやビジネスロジックの問題で、一部のテーブルで RLS を設定できない場合が存在したとします。
そのような場合、RLS が無効であるテーブルにアクセスする際には適切なフィルタリングを行うことが重要です。
class NotRlsHogeController < ApplicationController def index # RLSが無効であるテーブルのデータを取得 @hoge = NotRlsHoge.all end end
not_rls_hoge
は、上で説明したような「企業IDによるフィルタリングを行いたいが、RLS を設定できない」テーブルであるとします。
RLS が有効であるかのようにアプリケーションコードを書いてしまっているため、会社IDによるフィルタリングがされないままクライアントにデータが渡されてしまいます。
このようなアンチパターンを避けるために会社IDでのフィルタリングを適切に行うことが重要です。その例が以下のようになります。
class NotRlsHogeController < ApplicationController def index # RLSが無効であるテーブルのデータを取得 @hoge = NotRlsHoge.where(company_id: current_user.company_id) end end
このように実装することで、RLS が無効であるテーブルにおいても適切なアクセス制御を行うことができます。
ただし、RLS が有効であるか無効であるかの確認が手間であり、ケアレスミスを誘発しやすいという課題があります。
そのため、RLS の設定を確認し、それを意識してコーディングしなければ、おもわぬ事故を引き起こす可能性があります。
アンチパターン 3
さらに、アンチパターンとして「1:NのリレーションのN側のテーブルに RLS を適用しない」場合の例もあります。
仮に、1側のテーブルには RLS を適用していても、N側のテーブルには適用しないとします。これは、正規化の観点から意図的に行われることがありますが、セキュリティリスクを高めます。
例えば、1側のテーブル employees
とN側のテーブル projects
があるとします。
そして、projects
テーブルには company_id
カラムが含まれていないとします。すなわち、N側のテーブルに RLS が適用されていない状態です。
-- テーブルの作成 CREATE TABLE employees ( id SERIAL PRIMARY KEY, name TEXT, company_id INT ); CREATE TABLE projects ( id SERIAL PRIMARY KEY, name TEXT -- company_id を含めない ); -- RLSの有効化 ALTER TABLE employees ENABLE ROW LEVEL SECURITY; -- projects テーブルには RLS を適用しない -- ポリシーの作成 CREATE POLICY company_policy ON employees USING (company_id = current_setting('myapp.current_company_id')::int);
つまり、次の図のような状態です。
この状態における次のコードでは、別顧客のプロジェクト情報を閲覧できる状態となり、大事故につながります。
class ApplicationController < ActionController::Base # Controller の Action が実行される前に、現在の会社IDを PostgreSQL の変数として設定 # # WARNING: この行を消したら即大事故。消してはいけない。 before_action :set_current_company private def set_current_company # 現在のユーザの会社IDを設定 ActiveRecord::Base.connection.execute("SET myapp.current_company_id = #{current_user.company_id}") end end class EmployeesController < ApplicationController def index # 現在の会社における従業員の一覧を取得 # RLSが適用されているため、会社IDによるフィルタリングが行われる @employees = Employee.all end end class ProjectsController < ApplicationController def index # プロジェクトの一覧を取得 # RLSが適用されていないため、会社IDによるフィルタリングが行われない @projects = Project.all # ここで大事故 end end
このようなアンチパターンを避けるためには、1:NのリレーションのN側のテーブルにも company_id
カラムを追加し、RLS を適用することで、適切なフィルタリングを行う必要があります。
以下の例は、projects
テーブルにも company_id
カラムを追加し、RLS を適用した場合の例です。
-- テーブルの作成 CREATE TABLE employees ( id SERIAL PRIMARY KEY, name TEXT, company_id INT ); CREATE TABLE projects ( id SERIAL PRIMARY KEY, name TEXT, company_id INT ); -- RLSの有効化 ALTER TABLE employees ENABLE ROW LEVEL SECURITY; ALTER TABLE projects ENABLE ROW LEVEL SECURITY; -- ポリシーの作成 CREATE POLICY company_policy ON employees USING (company_id = current_setting('myapp.current_company_id')::int); CREATE POLICY company_policy ON projects USING (company_id = current_setting('myapp.current_company_id')::int);
つまり、次の図のような状態です。
この状態における次のコードでは、適切なフィルタリングが行われます。
class ApplicationController < ActionController::Base # Controller の Action が実行される前に、現在の会社IDを PostgreSQL の変数として設定 # # WARNING: この行を消したら即大事故。消してはいけない。 before_action :set_current_company private def set_current_company # 現在のユーザの会社IDを設定 ActiveRecord::Base.connection.execute("SET myapp.current_company_id = #{current_user.company_id}") end end class EmployeesController < ApplicationController def index # 現在の会社における従業員の一覧を取得 # RLSが適用されているため、会社IDによるフィルタリングが行われる @employees = Employee.all end end class ProjectsController < ApplicationController def index # 現在の会社におけるプロジェクトの一覧を取得 # RLSが適用されているため、会社IDによるフィルタリングが行われる @projects = Project.all end end
このようにすることで、1:NのリレーションのN側のテーブルに対しても適切なアクセス制御が行われ、セキュリティリスクを低減できます。
補足
このように頼もしい RLS ですが、Techouse ではRLS に依存した実装をしてはいけないというルールにしています。
RLS があるからといっても完全に安全なわけではなく、あくまで顧客企業間をまたがるデータ参照を防止することしかできません。RLS はあくまで「転ばぬ先の杖」として使います。すなわち、エンジニアは RLS がない場合でも全く同じ動作となるようにコードを書くべし、ということです。
最後に
RLS (Row-Level Security) は、データベースの行ごとにアクセス制御を行う強力な技術です。特にマルチテナントアーキテクチャを用いた環境では、適切に実装することでデータ漏洩のリスクを部分的に低減できます。
私たちのプロダクト、クラウドハウス労務では、RLS を利用して顧客企業を横断したデータ参照を原理上不可能とすることでセキュリティを強化しています。
最近開発に関わり始めたインターンの方々へも、重要な知識として周知していきたいです。
コードレビューをする際にも、注意したいですね。
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。