💎

【Rails】SaaSにおける権限管理 module の設計と実装

2024/12/18に公開

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 を利用した実装を見てみます。

project_policy.rb
class ProjectPolicy < ApplicationPolicy
  def update?
    user.admin? || record.assignees.exists?(id: user)
  end
end

シンプルですが、頭の中でコードを実行をしないと理解が難しいです。
また、役割が増えたり、条件が増えたりすると、コードが複雑になっていきます。

非エンジニアでも理解できるとは言い難いです。

権限管理 module の定義例

今回実装した権限管理 module では、このようになります。

app/policies/policy/project/roles/admin.rb
module Policy
  module Project
    module Roles
      class Admin < Base
        def update
          true
        end
      end
    end
  end
end
app/policies/policy/project/roles/normal.rb
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

エントリーポイントです。
利用者がこのファイルを見るだけで、エラーやインターフェースを理解できるようにしています。

app/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

権限のクラスを特定して判定を行うクラスです。

app/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

userrole と対象の record から、権限のクラスを取得しています。
メタプログラミングを利用することで、クラスを追加するだけで権限を追加できるようにしています。

permissions は、userrole の権限を一覧で取得しています。
この一覧をクライアントに提供することで、役割ではなく権限に依存した細かい制御を実現しています。

jsonの例
{
  "permissions": {
    "project": {
      "read": true,
      "create": true,
      "update": ["assignee"],
      "delete": false,
      "invite": false
    }
  }
}

policies/policy/base.rb

権限の基底クラスです。

app/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

対象の基底クラスです。

app/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

権限の具体的なクラスです。

app/policies/policy/project/roles/admin.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
app/policies/policy/project/roles/normal.rb
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 で権限を実装する方の参考になれば幸いです。

GitHubで編集を提案

Discussion