リリース後5年経ったサービスの、CanCanCanを使った権限管理の現在地
こんにちは!ourly株式会社 執行役員CTO(@tigers_loveng)の相澤です。
先月 KaigionRails2025 に参加してきたのですが、その中でPunditのgemを利用した権限管理に関するセッションがありました。
役割はサービスの規模や扱う企業のフェーズなどによって変わるため、権限は「役割」と「条件」に分けて管理しましょうというお話しだったのですが、大変勉強になり、素晴らしいセッションでした。
ourlyではCanCanCanというgemを用いた権限管理の仕組みを入れており、現状と課題感などを共有できればと思います。
ourlyのアクセス制御モデル
まずは基本的なアクセス制御モデルを列挙して整理してみます。
-
DAC(Discretionary Access Control)
- オブジェクト所有者が自由にアクセス権を渡す方式。Unixパーミッション的な世界観。
- ex. この本はA君とB君には貸してあげるけど、C君はダメだよ
-
MAC(Mandatory Access Control)
- ランクや機密区分でアクセス可否が決まる厳格派。官公庁や軍事システムで多い。
- ex. この本はクラスの成績上位10名のグループの子にしか貸してあげないよ
-
RBAC(Role-Based Access Control)
- ロールに権限を束ねてユーザーに付与する方式。チーム開発ではこれが基本線。
- ex. A君は風紀委員なのでこの本を借りることができるよ
-
ABAC(Attribute-Based Access Control)
- ユーザー属性・リソース属性・環境を組み合わせて判定。AWS IAMのポリシーが代表格。
- ex. この本を借りることができるのは風紀委員の代表とクラス委員の代表にしよう
このうち、ourlyではRBACのモデルを採用しています。
ourlyには管理権限/分析権限/編集権限/一般権限の4種類の権限があり、ユーザーに対して付与することができます。
各権限ごとにできること(Kaigi on Railsの発表でいうところの「条件」)が定義されており、権限を付与されたユーザーは許可された操作のみ可能です。
また、各権限で許可された操作範囲は玉ねぎ状に増えていく構造になっており、管理権限は全ての機能に触ることができるようになっています。

包含している権限で許可されている操作は全て可能
なぜこの形式にしたのか?
開発初期の設計時にまず思いついたのは AWS IAMのモデル、つまりABACでした。
ですが、
- そもそもどんな権限が必要になるのかがあまり見えていなかった
- 非IT系の企業でそこまで作り込んだ権限管理が必要か分からなかった
- 柔軟に権限を作成・管理できる仕組みを管理画面で実装するには工数がかかる
などのことを考えて、シンプルで実装しやすいRBACのモデルを採用することにしました。
ただ、後々ABACのモデルに変更することも十分に考えられたので、その際にデータ負債とならないように、テーブル設計はABACを実現できる構造にしておきました。
テーブル構成
権限管理のテーブル構成は以下のようになっており、roles テーブルが「役割」を、permissions テーブルが将来的に「条件」を管理するというイメージです。
管理権限/分析権限/編集権限/一般権限の4つの権限は roles テーブルで定義しており、ABACの場合はそれらの権限に紐づく条件を permissions テーブルで管理するという形になりますが、RBACでは permissions テーブルにも同じように管理権限/分析権限/編集権限/一般権限の4つの条件を作ります。
roles テーブル
| ID | role_type | label |
|---|---|---|
| 1 | admin | 管理者 |
| 2 | analyzer | 分析者 |
| 3 | editor | 編集者 |
| 4 | employee | 一般 |
permissions テーブル
| ID | permission_type | label |
|---|---|---|
| 1 | admin | 管理者 |
| 2 | analyzer | 分析者 |
| 3 | editor | 編集者 |
| 4 | employee | 一般 |
permissions_roles テーブル
| role_id | permission_id |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
permissions テーブルがほぼ意味をなしていないことは重々承知ですが、一旦実装を最小限でおさえるために役割 = 条件の形で定義しています。
ただ、前述の通り、途中からABACのモデルに切り替える際は permissions テーブルに条件を追加することで、役割と条件を自由に組み合わせできるようにすることができます。
Abilityクラス
ourlyの Ability クラス は、権限粒度ごとに小さなクラスへ分割したモジュール群をマージする構造にしています。
class Ability
include CanCan::Ability
ABILITY_CLASSES = [
Abilities::Employee,
Abilities::Editor,
Abilities::Analyzer,
Abilities::Admin
].freeze
ABILITY_THAT_INCLUDES_OVERRIDING_CLASSES = [
Abilities::FeatureFlag
].freeze
def initialize(user)
user ||= User.new
cannot(:manage, :all)
ABILITY_CLASSES.inject(self) { |acc, klass| acc.merge(klass.new(user)) }
ABILITY_THAT_INCLUDES_OVERRIDING_CLASSES.inject(self) { |acc, klass| acc.merge(klass.new(user, acc)) }
end
end
- 意図しない権限解放の事故を防ぐため、最初に
cannot :manage, :allで全面禁止し、必要なところだけcanで開放する許可リスト形式 - 権限追加は
Abilities::*クラスを1つ用意してABILITY_CLASSESに積むだけ - 例外系は後段の
ABILITY_THAT_INCLUDES_OVERRIDING_CLASSESにまとめ、既存権限を受け取って上書きする
この構造にしておくと、「admin権限の振る舞いを触るなら Abilities::Admin と周辺だけを読めばよい」状態が保てます。複数人開発でも影響範囲を追いやすいのがメリットです。
Abilitiesモジュール
各権限用モジュール内では、権限を確認するメソッドを呼び出してガード節を入れています。
module Abilities
class Admin
include CanCan::Ability
def initialize(user)
return unless user.permission?(:admin)
## 管理者に許可された条件
end
end
end
FeatureFlagのような例外系のモジュールは各権限と同じように定義しますが、先に各権限で許可されてしまっているものでフィーチャーフラグがオフの場合は cannnot で上書きするようにしています。
RBACを採用した現在地
現時点で、サービスリリース後5年経ちますが、大きな問題は今の所は発生しておらず、初期設計時に採用した形式のまま来ることができています。
ただ、最適な形でいけているかというと実はそうではなく、やはり既存で定義した権限以外の権限が必要なシーンがところどころ出てきています。
- メールアドレスを持たないユーザーのパスワード初期化権限
- 一般/編集者/分析者 など管理者以外にも付与することができる権限
- 記事ごとに部署単位や役職単位で閲覧可否をコントロールする閲覧権限
- サーベイごとに部署単位や役職単位で細かく閲覧可否をコントロールする閲覧権限
- ...etc
パスワード初期化権限と記事ごとの閲覧権限はどうにか既存の仕組みを邪魔しない形で入れることができたのですが、3つ目のサーベイの閲覧権限は一癖あり、現在進行形で悩み中です。
エンドポイントごとに権限が分けることができれば良いのですが、返却するデータの中身に対して細かく閲覧可否が設定される形なのでややこしいのです。
ex. 〇〇部署のサーベイの結果は〇〇部署のマネージャー以上にのみ見せたい
段々とサービス規模が大きくなるにつれて既存ルールに当てはまらないケースが出てくるのは想定内でしたが、影響範囲が大きいためテコ入れには正直腰が重いです😅
ですが、リリース後5年間大きな問題もなく来れたことは、一定初期構築時の技術選定としては正しかったのではないかと考えています。
いかがでしたでしょうか?
これからプロダクトをゼロから作る際に、権限管理どうしようかと悩んでいる方の参考になれば幸いです。
ただ、今後もずっと同じ形での権限管理でいけるとは思っていないので、再設計して変えた際にはまた経過報告としてテックブログにしたためようと思います!
それでは今回はこのへんで。最後まで読んでいただきありがとうございました!
Discussion