Errbotを安全に使うために


[LIFULL Advent Calendar 2017](https://qiita.com/advent-calendar/2017/lifull)の8日目の記事です。

普段は海外向けの不動産ポータルサイトの開発に携わっていますが
本記事ではそれとは関係のない個人的な話をします。

## :computer: はじめに

### 背景
サービスがスケールすると運用業務が増えますが、時間は有限です。
効率化の一手段として、定型的な仕事をチャットボットに任せることを考えました。

### 導入に際して
今やチャットボットはどこでも導入されている仕組みですが
チャットボット自体の作成や運用にもそれなりに手間がかかります。

- ソースコードの管理（情報の秘匿）
- 使用するユーザの権限の管理
- チャットボット実行サーバの運用・監視・権限の管理

など。最初から一気に考えるとやる気とスピードを損ねそうです。

そこで

- __ソースコードは自身のPCの中だけに秘めて__  
- __自分のタスクだけを実行するために__  
- __ローカルでボットを動かす__  

スモールスタートで始めよう と思い立ちました。  

### 導入

今回は Python製の[Errbot](https://github.com/errbotio/errbot)を使用します。
（自分しか触らないので、選択の自由度は非常に高いです）

[Setup](http://errbot.io/en/latest/user_guide/setup.html)に従ってチャットボット「benriya」さんを作成し
[Slackの設定](http://errbot.io/en/latest/user_guide/configuration/slack.html)をするといい感じにやりとりができるようになりました。

完全に個人的な用途で言えば
[toggl](https://toggl.com/)で管理しているタスクをサマライズして表示させたりしています。

![mockmock-bot___mockmock_Slack.png](https://qiita-image-store.s3.amazonaws.com/0/43116/c5dc5855-c99b-99f8-38ca-079701c20000.png "mockmock-bot___mockmock_Slack.png")

togglのダッシュボードではかゆいところに手が届かなかったりするので
APIの返り値を加工して体裁を整えて出力するようにしました。

なんとなく便利になりましたが、ここで ~~マッチポンプ的な~~ 問題が発生します。

## :warning: 問題

他の人に絡まれる。

![other.png](https://qiita-image-store.s3.amazonaws.com/0/43116/a308c82e-7ee7-56f8-895d-8236035fd09c.png "other.png")

私しか使わないつもりで APIキーはばっちりハードコーディングしたので
他の人が勝手に私のタスクを読み取ったり、サーバを操作したりできます。

（今回は極端な実装をしていますが）真面目な話で言うと
そもそもボット自身が強い権限を持つ可能性もあるので、場合によっては危険です。

__我が子が変な人に声をかけられてもついていかないようにする必要があります。__

## :no_pedestrians: アクセス制限

（ここからが本題）

[Errbot](https://github.com/errbotio/errbot) には、各コマンドを実行できるユーザを制限する機能があります。  
参照: [Administration#Restricting access](http://errbot.io/en/latest/user_guide/administration.html#restricting-access)

### BOT_ADMINS

Errbotの設定ファイルである`config.py`において
`BOT_ADMINS`変数にはチャットボットの管理者のユーザ名を登録します。
なお、Slackの場合はこちらのページに正式なアカウント名が表示されます。
https://xxx.slack.com/account/settings#username

#### admin_onlyなコマンドの設定

ビルトインのコマンドの中では`!restart`や`!shutdown`など
管理者しか実行することができません。
[ソースコード](https://github.com/errbotio/errbot/blob/master/errbot/core_plugins/health.py)では下記のようにadmin用コマンドとしての設定がされています。

```py:!restart
@botcmd(admin_only=True)
def restart(self, msg, args):
```

```py:!shutdown
@arg_botcmd('--confirm', dest="confirmed", action="store_true",
            help="confirm you want to shut down", admin_only=True)
@arg_botcmd('--kill', dest="kill", action="store_true",
            help="kill the bot instantly, don't shut down gracefully", admin_only=True)
def shutdown(self, msg, confirmed, kill):
```

`!shutdown`はコマンドのオプションがあるため複雑に見えますが
コマンド作成のための`@botcmd`や`@arg_botcmd`というデコレータにおいて
引数として`admin_only=True`が指定されているのがわかります。

`!task`も同様に設定してみると、adminの私以外は使用できなくなります。

```py:!task
@botcmd(admin_only=True)  
def task(self, msg, args):
```

![benriya___mockmock_Slack.png](https://qiita-image-store.s3.amazonaws.com/0/43116/e95f03d5-8dc0-153d-fd3d-0300dba5c419.png "benriya___mockmock_Slack.png")

#### 管理者への通知

アクセス制限から少し話がそれますが
`BotPlugin`クラスに実装された`warn_admins()`を使用すると
`BOT_ADMINS`に記載したユーザ全員にメッセージを通知します。

危険なコマンドや怪しい挙動を検知した際の通知に活用できそうです。

### ACCESS_CONTROLS

今回の件は`admin_only`のコマンドを増やしていけば解決しますが
後の運用のためにその他のアクセス制限について確認します。

細やかなACLが必要な場合は、`config.py`において
`ACCESS_CONTROLS`や`ACCESS_CONTROLS_DEFAULT`を設定します。
詳細は[config.pyのサンプルファイル](http://errbot.io/en/latest/_downloads/config-template.py)に記載されています。

これらの項目では、特定のユーザや部屋（チャンネル？）に対して
コマンドの実行を許可する/拒否する ための設定を行います。

例えば、下記のサンプルではデフォルトですべてのアクセスを許しつつ
`status`、`about`、`uptime`などのコマンドには個別の設定がされています。

```py:config.pyのサンプルファイルから抜粋
# Example:

ACCESS_CONTROLS_DEFAULT = {} # デフォルトですべてのアクセスを許可する
ACCESS_CONTROLS = {'status': {'allowrooms': ('someroom@conference.localhost',)},
                   'about': {'denyusers': ('*@evilhost',), 'allowrooms': ('room1@conference.localhost', 'room2@conference.localhost')},
                   'uptime': {'allowusers': BOT_ADMINS},
                   'help': {'allowmuc': False},
                   'ChatRoom:*': {'allowusers': BOT_ADMINS},
                  }
```

#### 設定箇所の違い

| 名称 | 説明 |
|--------|--------|
|`ACCESS_CONTROLS_DEFAULT`|すべてのコマンドにデフォルトで適用するルールを記載|
|`ACCESS_CONTROLS`|コマンドごとの詳細なルールを記載|

[ソース](http://errbot.io/en/latest/_modules/errbot/core_plugins/acls.html#ACLS)を読むと、`ACCESS_CONTROLS_DEFAULT`を取得後に
コマンドの制限が`ACCESS_CONTROLS`に存在すれば上書きするようになっています。

```py:errbot.core_plugins.acls
acl = self.bot_config.ACCESS_CONTROLS_DEFAULT.copy()
for pattern, acls in self.bot_config.ACCESS_CONTROLS.items():
    if ':' not in pattern:
        pattern = '*:{command}'.format(command=pattern)
    if ciglob(cmd_str, (pattern,)):
        acl.update(acls)
        break
```

#### 使用可能なフィルタ

設定するルールは下記のフィルタで定義されます。

| 名称 | 説明 |
|--------|--------|
|`allowusers`|使用可能なユーザを定義（ホワイトリスト）|
|`denyusers`|使用不可能なユーザを定義（ブラックリスト）|
|`allowrooms`|使用可能な部屋を定義（ホワイトリスト）|
|`denyrooms`|使用不可能な部屋を定義（ブラックリスト）|
|`allowprivate`|ダイレクトメッセージでのアクセスを許可（Bool）|
|`allowmuc`|部屋の中でのコマンド実行を許可（Bool）|

Slackでは「部屋」は「チャンネル」にあたるため
`{'allowrooms': ('#general',)}` のようなかたちで記載するようです。

上記のフィルタは[ソース](http://errbot.io/en/latest/_modules/errbot/core_plugins/acls.html#ACLS)中では下記のようなロジックで評価されています。
`allow_xxx`は`deny_xxx`よりも先に評価されているため
両方指定がある場合はホワイトリスト方式の指定が優先されます。

```py:errbot.core_plugins.acls
if 'allowusers' in acl and not glob(usr, acl['allowusers']):
    return self.access_denied(msg, "You're not allowed to access this command from this user", dry_run)
if 'denyusers' in acl and glob(usr, acl['denyusers']):
    return self.access_denied(msg, "You're not allowed to access this command from this user", dry_run)
if msg.is_group:
    if not isinstance(msg.frm, RoomOccupant):
        raise Exception('msg.frm is not a RoomOccupant. Class of frm: %s' % msg.frm.__class__)
    room = str(msg.frm.room)
    if 'allowmuc' in acl and acl['allowmuc'] is False:
        return self.access_denied(msg, "You're not allowed to access this command from a chatroom", dry_run)

    if 'allowrooms' in acl and not glob(room, acl['allowrooms']):
        return self.access_denied(msg, "You're not allowed to access this command from this room", dry_run)

    if 'denyrooms' in acl and glob(room, acl['denyrooms']):
        return self.access_denied(msg, "You're not allowed to access this command from this room", dry_run)
elif 'allowprivate' in acl and acl['allowprivate'] is False:
    return self.access_denied(
        msg,
        "You're not allowed to access this command via private message to me",
        dry_run
    )
```

#### その他の設定

コマンドの存在自体も隠したいような場合は下記の設定でTrueを設定します。

| 名称 | 説明 |
|--------|--------|
|`HIDE_RESTRICTED_COMMANDS`|使用が制限されているコマンドをhelpで隠す（Bool）|
|`HIDE_RESTRICTED_ACCESS`|使用が制限されているコマンドの呼出におけるエラーメッセージを出さない（Bool）|

### Command filters

既存のACL機能でも物足りない場合は
コマンドフィルタで独自のアクセス制限を実装します。

errbotは各コマンドの実行直前にこの関数を呼びます。
（前述の`BOT_ADMINS`や`ACCESS_CONTROLS`での制限もこれで書かれています。）

[ドキュメント](http://errbot.io/en/latest/errbot.html#errbot.cmdfilter)によると下記のような構造でコマンドフィルタを定義します。

```
@cmdfilter
def some_filter(self, msg, cmd, args, dry_run):
    # 実行をブロックしたい場合はすべてNoneを返す
    return None, None, None
    # それ以外は次のフィルタ（コマンド）用の引数を返す
    return msg, cmd, args
```

時刻によってアクセスを制限するような
ユーザベースでの制限以外のコントロールに使用できそうです。

## :end: 終わりに
Errbotを安全に使うために、アクセスコントロールの話をしました。

ちなみに、チャットボットの私物化も
やってみると意外とメリットがあります。

- 好きな言語や環境で取り組めて自由に事故れる（勉強になる）
- 多少の不便は自分で吸収するため、開発のスピード感がある
- 自分にしか必要ない機能も迷わず組み込める
- ただ定形のスクリプトを書くよりもレールに乗れて、愛着も湧く

安定したら、共用できるところはメンバーにも公開しようと思います。