【Rails】SaaSにおける権限管理 module の設計と実装
SaaS における権限管理 module を開発したので、思考や実装を共有します。
事業やチームの将来を踏まえての設計になりますが、この記事では省略しています。
権限管理で大事なこと
間違えないことの一点に尽きます。
具体的には
- 実装するときに間違えない
- 利用するときに間違えない
- 理解するときに間違えない
この 3 点を満たせない権限管理は、大きな障害を生むことになります。
特に「理解するときに間違えない」は、お問い合わせ対応などを考えると、非エンジニアでも理解できるレベルを目指したいです。
よくあるアンチパターン
admin?
など役割を利用した判定です。
if user.admin?
project.update!(status: :completed)
end
役割の境界はサービスの成長と共に変化をするため、意外と脆いです。
例えば、サービスの対象の市場や企業の規模が変わることで、管理者のやるべきことが変わったりします。super_admin?
が必要になるかもしれません。
このときにすべての admin?
を利用している箇所を漏れなく確認するのは非常に大変です。
また、役割のできることが暗黙知になるため、間違えやすいです。
上記の例では、管理者にプロジェクトの更新権限があるかはコードだけではわかりません。レビュワーは、管理者が何をできるかを常に把握していないといけません。
役割ではなく権限に依存した実装が望ましいです。
if user.can_update?
project.update!(status: :completed)
end
これなら 権限と処理が紐づくため理解しやすいです。そして、役割の境界が変わった場合も変更箇所が限定されます。
概念整理
ここで、権限という概念を整理してみます。
まず、権限を日本語で表現してみます。
例として「プロジェクトの更新は、管理者か担当者ならできる」とします。
抽象化すると、「対象の、操作は、役割か、条件ならできる」となります。
対象 -> 操作 -> 役割 -> 条件
Rails は schema・model・controller と対象を意識させるフレームワークのため、権限も対象を軸とした方が良さそうです。
対象(プロジェクト) -> 役割/条件(管理者/担当者) -> 操作(更新)
抽象化します。
また、権限に依存したいので、役割は複数の権限がまとまったものとして捉えます。
ユーザー -> 役割 -> 対象 -> 条件 -> 操作(CRUD) -> ※属性(white list / カラム)
日本語で表現してみます。
- (管理者のとき)プロジェクトは更新できる
- (通常のとき)プロジェクトは担当者であれば更新できる
わかりやすくなりました。
権限で意識させたいこと
間違えないために、以下の 3 つを意識させたいです。
- 権限の対象が何か
- 対象にはどの権限があるか
- どんな境界(役割)があるのか
既存の gem を見てみる
今回は、大きく影響を受けた pundit という gem を利用した実装を見てみます。
class ProjectPolicy < ApplicationPolicy
def update?
user.admin? || record.assignees.exists?(id: user)
end
end
シンプルですが、頭の中でコードを実行をしないと理解が難しいです。
また、役割が増えたり、条件が増えたりすると、コードが複雑になっていきます。
非エンジニアでも理解できるとは言い難いです。
権限管理 module の定義例
今回実装した権限管理 module では、このようになります。
module Policy
module Project
module Roles
class Admin < Base
def update
true
end
end
end
end
end
module Policy
module Project
module Roles
class Normal < Base
def update
assignee?
end
end
end
end
end
boolean と英語がわかれば、非エンジニアでも理解できるようになりました。
権限管理 module の実装
ディレクトリ構成
app
└── policies
├── policy
│ ├── project # Project が対象の権限について
│ │ ├── base.rb # Project の共通基底クラス
│ │ └── roles
│ │ ├── admin.rb # Project が対象の 管理者 の権限
│ │ └── normal.rb # Project が対象の 通常 の権限
│ ├── event
│ │ ├── base.rb
│ │ └── roles
│ │ ├── admin.rb
│ │ └── normal.rb
│ ├── base.rb
│ └── context.rb
└── policy.rb
権限の対象ごとにディレクトリを分けています。
model と対応させることで権限の対象が何かを明確にします。
また、対象ごとに base.rb
を用意することで、ドメイン特有の権限や処理の拡張を実現しています。
そして、役割ごとにファイルを分けることでどんな境界(役割)があるのかを明確にします。
基本は同じ数のファイルを持つため、役割の定義漏れに気づきやすくなります。
ファイルに権限をすべて記述することで対象にはどの権限があるかを明確にします。
policies/policy.rb
エントリーポイントです。
利用者がこのファイルを見るだけで、エラーやインターフェースを理解できるようにしています。
module Policy
class Error < StandardError; end
class NotAuthorizedError < Error
attr_reader :policy, :action
def initialize(options = {})
@policy = options[:policy]
@action = options[:action]
super("not allowed to #{policy.class.name}##{action}")
end
end
class NotDefinedError < Error
attr_reader :record_class, :role
def initialize(options = {})
@record_class = options[:record_class]
@role = options[:role]
super("unable to find #{record_class} policy for #{role}")
end
end
def self.authorize(user, record, action)
context = Context.new(user:)
context.authorize(record, action)
end
def self.authorize_scope(user, scope, action)
context = Context.new(user:)
context.authorize_scope(scope, action)
end
def self.permissions(user)
context = Context.new(user:)
context.permissions
end
end
利用方法は 3 つあります。
1. 権限の判定を行う
project = Project.find(1)
Policy.authorize(user, project, :update)
2. 権限があるものだけに絞り込む
scope = Project.all
Policy.authorize_scope(user, scope, :update)
3. 権限の一覧を取得する
Policy.permissions(user)
policies/policy/context.rb
権限のクラスを特定して判定を行うクラスです。
module Policy
class Context
attr_reader :user
def initialize(user:)
@user = user
end
def authorize(record, action)
raise(ArgumentError, 'record cannot be nil') unless record
policy = policy_class(user, record.class).new(user:, record:, mode: :record)
raise(NotAuthorizedError, policy:, action:) unless policy.public_send(action.to_sym)
policy.record
end
def authorize_scope(scope, action)
raise(ArgumentError, 'scope cannot be nil') unless scope
raise(ArgumentError, 'scope must be ActiveRecord::Relation') unless scope.is_a?(ActiveRecord::Relation)
policy = policy_class(user, scope.klass).new(user:, scope:, mode: :scope)
policy.public_send(action.to_sym)
end
def permissions
list = {}
policy_constants.each do |constant|
klass = policy_class(user, constant)
policy = klass.new(user:, mode: :list)
constant_result = klass.public_instance_methods(false).each_with_object({}) do |method, result|
result[method.to_sym] = policy.public_send(method)
end
list[constant.to_s.underscore.to_sym] = constant_result
end
list
end
private
def policy_constants
reject_constants = [:Base, :Context, :Error, :NotAuthorizedError, :NotDefinedError]
Policy.constants.reject { |constant| reject_constants.include?(constant) }
end
def policy_class(user, record_class)
role = user.role.camelize
"Policy::#{record_class}::Roles::#{role}".safe_constantize || raise(NotDefinedError, record_class:, role:)
end
end
end
user
の role
と対象の record
から、権限のクラスを取得しています。
メタプログラミングを利用することで、クラスを追加するだけで権限を追加できるようにしています。
permissions
は、user
の role
の権限を一覧で取得しています。
この一覧をクライアントに提供することで、役割ではなく権限に依存した細かい制御を実現しています。
{
"permissions": {
"project": {
"read": true,
"create": true,
"update": ["assignee"],
"delete": false,
"invite": false
}
}
}
policies/policy/base.rb
権限の基底クラスです。
module Policy
class Base
attr_reader :user, :record, :scope, :mode
# NOTE: mode: :list, :record, :scope
def initialize(user:, record: nil, scope: nil, mode: nil)
@user = user
@record = record
@scope = scope
@mode = mode
end
def read
raise(NotImplementedError)
end
def create
raise(NotImplementedError)
end
def update
raise(NotImplementedError)
end
def delete
raise(NotImplementedError)
end
end
end
権限の具体的なクラスは、この Base
クラスを継承して実装します。
デフォルトで CRUD の権限を定義しています。
policies/policy/project/base.rb
対象の基底クラスです。
module Policy
module Project
class Base < Policy::Base
def assignee?
record.assignees.exists?(id: user)
end
def assignee_scope
scope.left_joins(:assignees).where(assignees: { id: user }).distinct
end
def invite
raise(NotImplementedError)
end
end
end
end
各対象の base.rb
には、権限の具体的な処理とドメイン特有の権限を定義します。
例として「プロジェクトに招待できるか」の権限を invite
として定義しています。
ApplicationRecord
に対しての処理と ActiveRecord::Relation
に対しての処理を分けています。
policies/policy/project/roles/admin.rb, normal.rb
権限の具体的なクラスです。
module Policy
module Project
module Roles
class Admin < Base
def read
case mode
when :list
true
when :record
true
when :scope
scope
end
end
def create
case mode
when :list
true
when :record
true
when :scope
scope
end
end
def update
case mode
when :list
true
when :record
true
when :scope
scope
end
end
def delete
case mode
when :list
true
when :record
true
when :scope
scope
end
end
def invite
case mode
when :list
true
when :record
true
when :scope
scope
end
end
end
end
end
end
module Policy
module Project
module Roles
class Normal < Base
def read
case mode
when :list
true
when :record
true
when :scope
scope
end
end
def create
case mode
when :list
true
when :record
true
when :scope
scope
end
end
def update
case mode
when :list
[:assignee]
when :record
assignee?
when :scope
assignee_scope
end
end
def delete
case mode
when :list
false
when :record
false
when :scope
scope.none
end
end
def invite
case mode
when :list
false
when :record
false
when :scope
scope.none
end
end
end
end
end
end
役割ごとにファイルを分けることで、コードが複雑になるのを防いでいます。
対象の基底クラスの関数を利用して、権限を表現しています。
例えば、メンバー かつ 担当者 または 作成者 のような判定もシンプルに表現できます。
when :record
member? && (assignee? || author?)
また、一覧の場合は具体的なデータが必要な判定はできないため、key を返すようにしています。
権限管理 module の利用例
class ProjectsController < ApplicationController
def index
projects = Project.all
@projects = Policy.authorize_scope(user, projects, :read)
end
def show
project = Project.find(params[:id])
@project = Policy.authorize(user, project, :read)
end
def update
project = Project.find(params[:id])
@project = Policy.authorize(user, project, :update)
@project.update!(status: :completed)
redirect_to @project
end
def invite
project = Project.find(params[:id])
@project = Policy.authorize(user, project, :invite)
@project.invite!(email: params[:email])
redirect_to @project
end
end
役割でなく権限に依存した処理になりました。
権限と処理が紐づくため理解しやすいです。
また、役割の境界が変わった場合でも ProjectsController
の変更は不要です。
まとめ
SaaS における権限管理 module について記載をしました。
サービスが成長する中で変化に強く間違えないために、役割でなく権限に依存した仕組みを実装しました。
カラムレベルの権限管理については省きましたが、同様の仕組みで実現できます。
ドメイン特有の拡張とシンプルなわかりやすさを両立できたと思います。
お問い合わせ対応の場合は、ユーザーの役割と対象を聞いて when :record
の箇所を見れば理解ができます。
非エンジニアでも理解ができるため、権限の判定についてエンジニアにまで質問が来ることは稀になりました。
ここまで読んでいただき、ありがとうございます。
今後、Rails で権限を実装する方の参考になれば幸いです。
Discussion