権限問題と UA 二分病と CakePHP でのロール分岐試案
権限問題
WEB アプリケーションを作っていて、ほぼ同じ機能なのだがユーザに与えられた権限によって微妙に動きが違ったり、情報のアクセス権(閲覧、編集)が違ったりするのを、どのように実現するのか、というのはよく遭遇する問題だ。私の持論は、役割が違うなら役割をはっきり、できるだけ区別すべき、というもの。例として、大学の演習授業用のシステムを考えてみる。「受講している学生(Student)」や「担当教官(Teacher)」がログインして使うほか、「ティーチングアシスタント(Assistant)」もログインして使う。担当する授業はないけど、教官を管理する立場の教授が「監督者(Manager)」としてログインして使ったりする。他にこれらの人をシステムに追加したりする「管理者(Operator)」もいる。さらに、「受講していない学生もログインなし(Guest)」で演習内容などが見られるようになっているとしよう。
Student、Teacher など、それぞれの役割につけたものはロール(role)名である。
User Admin 二分病
こういうシステムにおいて、ありがちな設計は、データベースのテーブルや機能体を User と Admin に分けるものだ。どう考えても適切でないのに、無理矢理この設計に落し込むことをよく見かけるので、この傾向に UA 二分病と名前をつけてやることにする。
例にした演習授業システムでは、Userを「Student」、Admin を「Student と Guest 以外」に分けたりする。あるいは、User を「Manager と Operator と Guest 以外」、Admin を「Manager と Operator」に分けたりする。本来ロールが違う利用者を無理矢理二つに分けたので、ロールを区別するために権限属性が必要になり、ソースコード内は権限の場合わけによって if 文の嵐*1に見舞われる。メンテナンス性が悪く、バグが入りやすく、ちょっとしたバグのせいで本来アクセス権のない利用者が情報を閲覧、更新できてしまったりする。テストパターンも組合せ爆発を起こす。
あと、この病の面白い症状としては、Guest ロールが念頭に置かれていない、ということが挙げられる。先に挙げた二分例でも Guest ロールは User にも Admin にも入っていない。性格正確に言えば、Guest - User - Admin に三分割しているのだが、その Guest がロールである、という認識が抜け落ちているから GUA 三分病でなく UA 二分病なのだ。
処方
UA 二分病に対処するには早期発見が重要だ。というか、どういうアクターがいて、どういうユースケースがあって、という基本設計の際に UA 二分病に気づいて訂正しておかないと、そのプロジェクトが健康な一生を過ごすことはあきらめるしかない。User - Admin という「いつものアレ」を一旦忘れて、ユースケースに真正面から立ち向かう必要がある。そのうえで必要なロールをユースケースから発見すべきである。そして、そのロールにふさわしいロール名を与える。
このふさわしいロール名も非常に重要である。たいていのシステムにおいて、適切でないのになおかつよく使われるロール名が User である。なにせシステムを使う人は、一般的な意味で、すべて利用者なのだから。たとえ強力な権限を持っている人であっても。これまたよく使われるのに適切でないことが多いロール名のが Admin である。administrator と言えば権力と責任を持つロールである。しかし、多くのシステムにおいて Admin というロール名がついている人は、責任のある人から頼まれて作業をしている単なる作業者、operator である。
実際にどの粒度まで分割する(Teacher と Assistant を分ける? Teacher と Manager を分ける?)か、どこまで分割するか(URL も別? DB のテーブルは別? マシンも別?)は、システムに要求されるサービスレベルと納期と予算によって変わってくる。
CakePHP で admin ルーティング以上に細かいロールでのルーティング方法の試案
さて、ここからが本題である。CakePHP で admin ルーティングよりも細かくロールごとに分岐する方法を考えてみた。批評を乞う。
2008-02-20 追記 Auth コンポーネントってのがあるのね。機能的にかぶるところがある。
ロールの定義
まず、Student や Teacher などロールごとに DB のテーブルを作ることにする。当然 CakePHP の流儀によって、Student モデルや Teacher モデルも作る。
次に、Role モデルを作る。これは、それぞれのロールごとの StudentRole モデル、TeacherRole モデルなどのベースとなるモデルである。
<?php class Role extends AppModel { var $useTable = false; var $role; // ロールの種別を表わす整数値 var $id; // 認証済みユーザの ID var $displayName; // 同表示名称 var $css; // ロールに特有な CSS ファイル名 function _init($id, $displayName) { $this->id = $id; $this->displayName = $displayName; } function getLoginStateMessage() { return ''; } function isLoggingIn() { return true; } } ?>
GuestRole モデルは DB に関連するテーブルがない。
<?php App::Import('Model', 'Role'); class GuestRole extends Role { var $useTable = false; var $role = ROLE_GUEST; var $css = 'guest'; function isLoggingIn() { return false; } } ?>
そうそう、ROLE_GUEST などの定数は、app/config/core.php 内で define している。もっとふさわしい場所があるだろうけど、とりあえず。
StudentRole モデルなどは、こんな感じで。
<?php App::Import('Model', 'Role'); class StudentRole extends Role { var $useTable = false; var $role = ROLE_STUDENT; var $css = 'student'; function setStudent($student) { $this->_init($student['id'], $student['class'].$student['surname'].$student['firstname']); } function getLoginStateMessage() { return '【学生「{$this->displayName}」としてログイン中】'; } } ?>
AppController でロールチェック
AppController でログインしているユーザのロールを調べ、許可されていなければ強制的にはじくようにする。
<?php App::Import('Model', 'Role'); App::Import('Model', 'GuestRole'); App::Import('Model', 'StudentRole'); // ... 必要分 import class AppController extends Controller { var $role; function beforeFilter() { $this->__acceptRoleOrRedirct(); } function __acceptRoleOrRedirct() { if ($this->Session->check('role')) { $role = $this->Session->read('role'); } else { $role = new GuestRole(); } $validity = true; if (isset($this->validRoles)) { $validity = in_array($role->role, $this->validRoles); } if (! $validity) { redirect_to_home(); return; } $this->role = $role; } function beforeRender() { $this->set('role', $this->role); } function redirect_to_home() { $this->redirect('/'); } } ?>
ふむ。ここに書いた実装では、redirect_to_home() ではロールに関係なく '/' に飛ばしてるけど、各 Role モデルごとに飛ばし先を格納しておいてもよさそうだ。
そうそう、$this->role を当然のように使っているけど、これはログインに成功したときに格納しておく。たとえば。
$found = $this->Student->find('all', aa('conditions', aa('username', $username, 'password',sha1($password)), 'fields', a('id', 'class', 'surname', 'firstname')) ); if (count($found)) == 1) { $student = $found[0]['Student']; $role = new StudentRole($student); $role->setStudent($student); $this->Session->write('role', $role); $this->redirect('/cources/'); }
ふむ。このログイン成功時のリダイレクト先も redirect_to_home() の飛ばし先と同じく Role モデルに持たしておくとよさそうだ。
レイアウトでロールを利用
AppController の beforeRender で view に role をセットしているので、これを使う。
まずは head タグの中で
<?=$html->css('base')?> <?=$html->css($role->css)?>
とする。これにより、基本的なデザイン要素を base.css に定義しておき、ロールごとに違った見せ方を CSS で切り換えられるようになる。ロールごとにできるだけ分割したほうがいい、という原則どおり、ぱっと見た目もロールごとに全然違っているほうがいい。一般ユーザのつもりで管理者権限で誤操作して大変 !! という事故も、UA 二分病の症状の一つであり、予防が可能である。
あと、適当なところでログイン状態とログアウトリンクを貼る。たとえばこんな感じ。
<div id="stateBar"> <div class="leftBox"><?=$role->getLoginStateMessage()?></div> <div class="rightBox"><?php if ($role->isLoggingIn()) { echo $html->link('<<ログアウト>>', '/roles/logout'); } ?></div> </div>
ロールによるアクション分岐
各コントロールでは、そのコントロールにアクセス可能な機能を列挙し、アクセス可能なものについてはアクション先を登録しておく。たとえば、
class CourcesController extends AppController { // ... var $validRoles = array(ROLE_STUDENT, ROLE_TEATCHER, ROLE_ASSISTANT); var $actionTable = array( 'index' => array( ROLE_STUDENT => 'index_as_student', ROLE_TEATCHER => 'index_as_teacher', ROLE_ASSISTANT => 'index_as_assistant', ), 'edit' => array( ROLE_STUDENT => 'redirect_to_home', ROLE_TEATCHER => 'index_as_teacher', ROLE_ASSISTANT => 'redirect_to_home', ), // ... ); // ... function index() { $this->setAction($this->actionTable['index'][$this->role->role]); } function edit() { $this->setAction($this->actionTable['edit'][$this->role->role]); } ?>
こんな感じに。
*1:switch 文かもしれないけど。とりあえず条件分岐の嵐