CakePHP ACLに苦しむ・・・

ACLコンポーネントを使ってみた結果を結論から言うと、ACL(アクセス制御リスト)ってすごい便利だと思います。

ACLとは、マニュアルにも書いてあるように「優れた保守性と管理性を保ちつつ、アプリケーションのパーミッションをきめ細かく設定する」機能を提供してくれます。
例えば、管理者しかアクセスできないコントローラーやアクションを設定することができます。

「これは是非とも使いたい」と思い、マニュアルを見たのですが、説明がすごく分かりにくかった・・・

というわけで、自分のためにもブログに残しておきます。
(文章力がないのでマニュアルより分かりづらくなりそうですが・・・)

適当なコントローラーにアクションをいくつか作成し、管理者(administrators)、運営者(managers)、利用者(users)の3つのグループとユーザーを作成。グループごとにパーミッションを設定していく流れで説明していきたいと思います。


1・データベースの準備

まずはデータベースの準備を行います。
認証とACLはセットとなるのが一般的なので、まずはuserテーブルから作成することにします。
グループごとにパーミッションを設定していく予定なので、usersテーブルには以下のフィールドを用意し作成します。
(Bakeで作成しても問題ありません。)

▼usersテープルの作成

CREATE TABLE users (
    id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(80) NOT NULL,
    group_id INT(11) NOT NULL,
    created DATETIME,
    modified DATETIME
);

次に、usersテーブルの外部キーとなるgroupsテーブルを作成します。

▼groupsテープルの作成

CREATE TABLE groups (
    id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    created DATETIME,
    modified DATETIME
);

その他のテーブルを作成します。
今回はテストなので、適当なテーブルを1つ作成することにします。

▼その他のテープルの作成

CREATE TABLE posts (
    id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id INT(11) NOT NULL,
    title VARCHAR(255) NOT NULL,
    body TEXT,
    created DATETIME,
    modified DATETIME
);

ACL関連のテーブルを作成します。
Bakeコマンドで『cake schema create DbAcl』を実行するか、以下のSQLでテーブルを作成します。
(このSQL文は、CakePHP1.3.10では『/cake/console/templates/skel/config/schema/db_acl.php』にも書いてあります。)

▼ACL関連のテーブルを作成

CREATE TABLE acos (
    id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    parent_id INTEGER(10) DEFAULT NULL,
    model VARCHAR(255) DEFAULT '',
    foreign_key INTEGER(10) UNSIGNED DEFAULT NULL,
    alias VARCHAR(255) DEFAULT '',
    lft INTEGER(10) DEFAULT NULL,
    rght INTEGER(10) DEFAULT NULL,
    PRIMARY KEY  (id)
);

CREATE TABLE aros_acos (
    id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    aro_id INTEGER(10) UNSIGNED NOT NULL,
    aco_id INTEGER(10) UNSIGNED NOT NULL,
    _create CHAR(2) NOT NULL DEFAULT 0,
    _read CHAR(2) NOT NULL DEFAULT 0,
    _update CHAR(2) NOT NULL DEFAULT 0,
    _delete CHAR(2) NOT NULL DEFAULT 0,
    PRIMARY KEY(id)
);

CREATE TABLE aros (
    id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    parent_id INTEGER(10) DEFAULT NULL,
    model VARCHAR(255) DEFAULT '',
    foreign_key INTEGER(10) UNSIGNED DEFAULT NULL,
    alias VARCHAR(255) DEFAULT '',
    lft INTEGER(10) DEFAULT NULL,
    rght INTEGER(10) DEFAULT NULL,
    PRIMARY KEY  (id)
);

これで、データーベースの準備は終了です。


2・Authの準備

繰り返しになりますが、ACLはあくまで「アプリケーションのパーミッションをきめ細かく設定する」ものなので、認証(ログイン処理など)とは異なります。
認証はAuthコンポーネントを使用して行います。
(Authコンポーネントについては、「CakePHP Authコンポーネントを使ってみた」を参照。)

今回は、すべてのコントローラーにAuthコンポーネントを使用したいので、『AppController』を作成したいと思います。

▼『AppController』の作成と認証処理

class AppController extends Controller {
    public $components = array('Auth', 'Acl', 'Session');

    public function beforeFilter() {
    // Authコンポーネントの設定

    /*
     * モードの指定
     * [actions] でアクション単位のパーミッションチェック(アクセス可能か確認)を行う
     * [crud]    でコントローラー単位のパーミッションチェック(CRUDによる確認)を行う
     */
    $this->Auth->authorize = 'actions';

    $this->Auth->loginRedirect  = array('controller' => 'posts', 'action' => 'index');
    $this->Auth->logoutRedirect = array('controller' => 'users', 'action' => 'login');

    // ↓グループとユーザーとパーミッションを登録したら削除する
    $this->Auth->allow('*');
    }
}

ACLでもっとも苦しんだのが『$this->Auth->authorize』でした・・・
実は、『$this->Auth->authorize』に指定する値でパーミッションをチェックする方法が大きく変わります。

『actions』を指定すると、アクションに対するアクセス可能か確認し、『crud』を指定するとコントローラーに対してCRUDによるパーミッションの確認となります。

つまり、『actions』ではCRUDによる確認はできず、『crud』ではコントローラーだけにCRUDによる確認をすることができるということになります。

今回のケースでは、アクションごとにパーミッションを設定したいので『actions』を指定します。

ちなみに、マニュアルの5.1 アクセス制御リストは『crud』を使った場合の説明
11.2 ACL を制御するシンプルなアプリケーションは『actions』を使った場合の説明となっています。
マニュアルを読むときは、このことを意識しながら見ないと私みたいに苦労します・・・

zistaさんの『CakePHPのACLを理解する(Authコンポーネントから探る) その2』がとても参考になりました。詳しく知りたい方は、是非一度ご覧ください。


3・AROの作成

次は、AROを作成していきます。
グループとユーザーをACLに関連付け、自動的にarosテーブルにも登録する処理を作成します。
AclBehaviorを使用した以下のコードをGroupモデルとUserモデルに書くことによって、グループやユーザーを登録すると自動的にarosテーブルにも登録してくれるようになります。

▼Userモデル

class User extends AppModel {
    public $name   = 'User';
    public $actsAs = array('Acl' => 'requester');

    public $belongsTo = array(
        'Group' => array(
            'className' => 'Group',
                'foreignKey' => 'group_id',
                'conditions' => '',
                'fields'     => '',
                'order'      => ''
            )
        );

    public $hasMany = array(
        'Post' => array(
            'className'    => 'Post',
            'foreignKey'   => 'user_id',
            'dependent'    => false,
            'conditions'   => '',
            'fields'       => '',
            'order'        => '',
            'limit'        => '',
            'offset'       => '',
            'exclusive'    => '',
            'finderQuery'  => '',
            'counterQuery' => ''
        )
    );

    /* 以下を追加
    --------------------------------- */
    // モデルとACLテーブルを自動的に結びつける処理
    public function parentNode() {
        if (!$this->id && empty($this->data)) return null;

        $data = $this->data;

        if (empty($this->data)) $data = $this->read();

        if (!$data['User']['group_id']) {
            return null;
        } else {
            return array('Group' => array('id' => $data['User']['group_id']));
        }
    }

    // ユーザーの所属するグループが変更された時の処理
    public function afterSave($created) {
        if (!$created) {
            $parent = $this->parentNode();
            $parent = $this->node($parent);

            $node = $this->node();

            $aro = $node[0];
            $aro['Aro']['parent_id'] = $parent[0]['Aro']['id'];
            $this->Aro->save($aro);
        }
    }
}

▼Groupモデル

class Group extends AppModel {
    public $name   = 'Group';
    public $actsAs = array('Acl' => array('requester'));

    public $hasMany = array(
        'User' => array(
            'className'    => 'User',
            'foreignKey'   => 'group_id',
            'dependent'    => false,
            'conditions'   => '',
            'fields'       => '',
            'order'        => '',
            'limit'        => '',
            'offset'       => '',
            'exclusive'    => '',
            'finderQuery'  => '',
            'counterQuery' => ''
        )
    );

    /* 以下を追加
    --------------------------------- */
    // モデルとACLテーブルを自動的に結びつける処理
    public function parentNode() {
        return null;
    }
}

これで、自動的にarosテーブルにも登録されるようになりましたので、グループとユーザーを登録するアクションを作成し登録していきます。
管理者(administrators)、運営者(managers)、利用者(users)の3つのグループとそれぞれのグループに所属するユーザーを登録したら次に進みます。


4・ACOの作成

ACOの作成ですが、マニュアルに便利なコードがあったので、それをそのまま使用したいと思います。
以下がコードです。

▼ACOの作成を自動化するコード

// ACOの作成
public function build_acl() {
    if (!Configure::read('debug')) return $this->_stop();

    $log  = array();
    $aco  =& $this->Acl->Aco;

    $root = $aco->node('controllers');

    if (!$root) {
        $aco->create(array('parent_id' => null, 'model' => null, 'alias' => 'controllers'));
        $root = $aco->save();
        $root['Aco']['id'] = $aco->id;
        $log[] = 'Created Aco node for controllers';
    } else {
        $root = $root[0];
    }

    App::import('Core', 'File');
        $Controllers = Configure::listObjects('controller');
        $appIndex = array_search('App', $Controllers);

        if ($appIndex !== false ) unset($Controllers[$appIndex]);

        $baseMethods = get_class_methods('Controller');
        $baseMethods[] = 'buildAcl';
        $Plugins = $this->_getPluginControllerNames();
        $Controllers = array_merge($Controllers, $Plugins);

        // look at each controller in app/controllers
        foreach ($Controllers as $ctrlName) {
            $methods = $this->_getClassMethods($this->_getPluginControllerPath($ctrlName));

            // Do all Plugins First
            if ($this->_isPlugin($ctrlName)) {
                $pluginNode = $aco->node('controllers/'.$this->_getPluginName($ctrlName));

                if (!$pluginNode) {
                    $aco->create(array('parent_id' => $root['Aco']['id'], 'model' => null, 'alias' => $this->_getPluginName($ctrlName)));
                    $pluginNode = $aco->save();
                    $pluginNode['Aco']['id'] = $aco->id;
                    $log[] = 'Acoを作成しました ' . $this->_getPluginName($ctrlName) . ' Plugin';
                }
            }

            // find / make controller node
            $controllerNode = $aco->node('controllers/'.$ctrlName);

            if (!$controllerNode) {
                if ($this->_isPlugin($ctrlName)) {
                    $pluginNode = $aco->node('controllers/' . $this->_getPluginName($ctrlName));
                    $aco->create(array('parent_id' => $pluginNode['0']['Aco']['id'], 'model' => null, 'alias' => $this->_getPluginControllerName($ctrlName)));
                    $controllerNode = $aco->save();
                    $controllerNode['Aco']['id'] = $aco->id;
                    $log[] = 'Created Aco node for ' . $this->_getPluginControllerName($ctrlName) . ' ' . $this->_getPluginName($ctrlName) . ' Plugin Controller';
                } else {
                    $aco->create(array('parent_id' => $root['Aco']['id'], 'model' => null, 'alias' => $ctrlName));
                    $controllerNode = $aco->save();
                    $controllerNode['Aco']['id'] = $aco->id;
                    $log[] = 'Created Aco node for ' . $ctrlName;
                }
            } else {
                $controllerNode = $controllerNode[0];
            }

            //clean the methods. to remove those in Controller and private actions.
            foreach ($methods as $k => $method) {
                if (strpos($method, '_', 0) === 0) {
                    unset($methods[$k]);
                    continue;
                }

                if (in_array($method, $baseMethods)) {
                    unset($methods[$k]);
                    continue;
                }

                $methodNode = $aco->node('controllers/'.$ctrlName.'/'.$method);

                if (!$methodNode) {
                    $aco->create(array('parent_id' => $controllerNode['Aco']['id'], 'model' => null, 'alias' => $method));
                    $methodNode = $aco->save();
                    $log[] = 'Created Aco node for '. $method;
                }
            }
        }

        if(count($log)>0) $this->set('logs', $log);
    }

    private function _getClassMethods($ctrlName = null) {
        App::import('Controller', $ctrlName);

        if (strlen(strstr($ctrlName, '.')) > 0) {
            // plugin's controller
            $num = strpos($ctrlName, '.');
            $ctrlName = substr($ctrlName, $num+1);
        }

        $ctrlclass = $ctrlName . 'Controller';

        return get_class_methods($ctrlclass);
    }

    private function _isPlugin($ctrlName = null) {
        $arr = String::tokenize($ctrlName, '/');

        if (count($arr) > 1) {
            return true;
        } else {
            return false;
        }
    }

    private function _getPluginControllerPath($ctrlName = null) {
        $arr = String::tokenize($ctrlName, '/');

        if (count($arr) == 2) {
            return $arr[0] . '.' . $arr[1];
        } else {
            return $arr[0];
        }
    }

    private function _getPluginName($ctrlName = null) {
        $arr = String::tokenize($ctrlName, '/');

        if (count($arr) == 2) {
            return $arr[0];
        } else {
            return false;
        }
    }

    private function _getPluginControllerName($ctrlName = null) {
        $arr = String::tokenize($ctrlName, '/');

        if (count($arr) == 2) {
            return $arr[1];
        } else {
            return false;
        }
    }

    /**
     * Get the names of the plugin controllers ...
     *
     * This function will get an array of the plugin controller names, and
     * also makes sure the controllers are available for us to get the
     * method names by doing an App::import for each plugin controller.
     *
     * @return array of plugin names.
     *
     */
    private function _getPluginControllerNames() {
        App::import('Core', 'File', 'Folder');

        $paths = Configure::getInstance();
        $folder =& new Folder();
        $folder->cd(APP . 'plugins');

        // Get the list of plugins
        $Plugins = $folder->read();
        $Plugins = $Plugins[0];
        $arr = array();

        // Loop through the plugins
        foreach($Plugins as $pluginName) {
            // Change directory to the plugin
            $didCD = $folder->cd(APP . 'plugins'. DS . $pluginName . DS . 'controllers');

            // Get a list of the files that have a file name that ends
            // with controller.php
            $files = $folder->findRecursive('.*_controller\.php');

            // Loop through the controllers we found in the plugins directory
            foreach($files as $fileName) {
                // Get the base file name
                $file = basename($fileName);

                // Get the controller name
                $file = Inflector::camelize(substr($file, 0, strlen($file)-strlen('_controller.php')));

                if (!preg_match('/^'. Inflector::humanize($pluginName). 'App/', $file)) {
                    if (!App::import('Controller', $pluginName.'.'.$file)) {
                        debug('Error importing '.$file.' for plugin '.$pluginName);
                    } else {
                        // Now prepend the Plugin name ...
                        // This is required to allow us to fetch the method names.
                        $arr[] = Inflector::humanize($pluginName) . "/" . $file;
                    }
                }
            }
        }

        return $arr;
    }

これをアクションとして実行するとACOが自動的に作成されます。

実行するアクションは『build_acl』なので、GroupsControllerのアクションにした場合は『http://ドメイン/groups/build_acl』といった感じで実行します。

ACOの作成が終了したら、パーミッションの設定を行っていきます。


5・パーミッションを設定する

ここまででACLを使用する準備が完了したので、実際にパーミッションを設定していきます。
今回のケースでは、『$this->Auth->authorize』に『actions』を指定したので、アクションごとにパーミッションを設定することになります。

設定するパーミッションは、

管理者(administrators)グループには、GroupsController・UsersController・PostsControllerの全てのアクションに対してアクセスを許可。

運営者(managers)グループには、UsersControllerのlogin・logoutアクションとPostsControllerの全てのアクションに対してアクセスを許可。

利用者(users)グループには、UsersControllerのlogin・logoutアクションとPostsControllerのindex・show・addアクションに対してのアクセスを許可

と、します。

また、各コントローラーのアクションは、

GroupsControllerにはindex・show・add・delete・build_aclアクション。

UsersControllerにはlogin・logout・index・show・add・deleteアクション。

PostsControllerにはindex・show・add・deleteアクション。

があるものとします。

パーミッションの設定は、『$this->Acl->allow($aroAlias, $acoAlias)』『$this->Acl->deny($aroAlias, $acoAlias)』で行っていきます。

▼パーミッションを設定する

// パーミッションの設定
public function build_parmition() {
    // 管理者のパーミッションを設定
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/groups/index');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/groups/show');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/groups/add');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/groups/delete');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/groups/build_acl');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/users/login');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/users/logout');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/users/index');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/users/show');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/users/add');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/users/delete');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/posts/index');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/posts/show');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/posts/add');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 1), 'controllers/posts/delete');

    // 運営者のパーミッションを設定
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 2), 'controllers');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 2), 'controllers/groups/index');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 2), 'controllers/groups/show');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 2), 'controllers/groups/add');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 2), 'controllers/groups/delete');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 2), 'controllers/groups/build_acl');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 2), 'controllers/users/login');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 2), 'controllers/users/logout');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 2), 'controllers/users/index');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 2), 'controllers/users/show');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 2), 'controllers/users/add');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 2), 'controllers/users/delete');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 2), 'controllers/posts/index');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 2), 'controllers/posts/show');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 2), 'controllers/posts/add');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 2), 'controllers/posts/delete');

    // 利用者のパーミッションを設定
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 3), 'controllers');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/groups/index');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/groups/show');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/groups/add');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/groups/delete');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/groups/build_acl');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 3), 'controllers/users/login');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 3), 'controllers/users/logout');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/users/index');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/users/show');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/users/add');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/users/delete');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 3), 'controllers/posts/index');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 3), 'controllers/posts/show');
    $this->Acl->allow(array('model' => 'Group', 'foreign_key' => 3), 'controllers/posts/add');
    $this->Acl->deny(array('model' => 'Group', 'foreign_key' => 3), 'controllers/posts/delete');
}

グループごとにパーミッションを設定するので、aroAliasにはGroupモデルを使用しています。
上記のアクションを適当なコントローラーに配置し、実行します。
これでパーミッションの設定は完了です。

『build_parmition』アクションは、パーミッション設定後は削除してしまってオッケーです。


6・確認

AppControllerの『$this->Auth->allow(‘*’);』をコメントアウトか削除し、動作確認を行います。

ちゃんと動作していれば、リンクからの推移なら推移元のページにリダイレクト、直接URLを入力ならトップページにリダイレクトされると思います。


7・エラーメッセージを出力する場合

エラーメッセージを出力したい場合は、『$this->Auth->authError』にエラーメッセージを指定します。

▼エラーメッセージの例

$this->Auth->authError  = 'アクセス権限がありません。';

あとは、『$session->flash』でエラーメッセージをビューに表示します。

▼エラーメッセージの例

echo $session->flash('auth');

基本的なACLの使い方は以上です。
あとは、パーミッションを簡単に変更するアクションを作成したりして、使いやすいようにしていくだけです。

ACLコンポーネントを使用する上で、私が思う注意点は『$this->Auth->authorize』に指定した値でパーミッションチェックの方法が変わるという事だと思います。
私のように、マニュアルや他の方のブログなど読んで混乱する人は、この事を強く意識した方が理解への近道になるかもしれませんね。

さすがに長文になりました・・・
最後まで読んでくださった方。ありがとうございました。