Hubot に権限管理機能を追加し, より安全に使用する


# 発端

Hubot に様々な機能を追加していると, 権限管理を行いたくなる.

 - deploy は, A さんと B さんのみが行える
 - Trello のチケット作成は, Chat Room に所属している者のみが行える

等々…

少し調べると, [auth.coffee](https://github.com/github/hubot/blob/master/src/scripts/auth.coffee) が見つかるが, Chat Room に制限が掛けられない.
Hubot は尻軽であるため, 招待されると, どのような Chat Room であっても顔を出してしまい, コマンドを入力されると, 素直に受け入れてしまう.

そこで, auth.coffee に Chat Room を判別する機能を追加しつつ, リファクタを試みる.

# Source Code

```coffeescript:auth.coffee
# Description:
#   Auth allows you to assign roles to users which can be used by other scripts
#   to restrict access to Hubot commands
#
# Dependencies:
#   None
#
# Configuration:
#   HUBOT_AUTH_ADMIN - A comma separate list of user IDs
#   HUBOT_AUTH_ROOM - A comma separate list of room IDs
#
# Commands:
#   hubot <user id> has <role> role - Assigns a role to a user
#   hubot <user id> doesn't have <role> role - Removes a role from a user
#   hubot what role does <user id> have - Find out what roles are assigned to a specific user
#   hubot who has admin role - Find out who's an admin and can assign roles
#
# Notes:
#   * Call the method: robot.auth.validUser(msg, '<role>')
#   * returns bool true or false
#
#   * the 'admin' role can only be assigned through the environment variable
#   * roles are all transformed to lower case
#
# Author:
#   IKUTA Masahito

module.exports = (robot) ->
  admin = process.env.HUBOT_AUTH_ADMIN or ''
  room = process.env.HUBOT_AUTH_ROOM or ''

  class Auth
    validUser: (msg, role) ->
      user = msg.message.user

      unless @inRoom(user)
        msg.reply "Can not execute in this room."
        return false

      unless @hasRole(user, role)
        msg.reply "#{user.name} has not the '#{role}' role."
        return false

      return true

    hasRole: (user, role) ->
      if @hasAdminRole(user.id)
        return true

      unless user.roles?
        return false

      return role in user.roles

    hasAdminRole: (id) ->
      return id.toString() in admin.toLowerCase().split(',')

    inRoom: (user) ->
      return user.room == room

  robot.auth = new Auth

  validParams = (msg, id, role) ->
    if not validIDParam(msg, id)
      return false

    if not validRoleParam(msg, role)
      return false

    return true

  validIDParam = (msg, id) ->
    if isNaN(id)
      msg.reply "ID is not integer."
      return false

    user = robot.brain.userForId(id)
    unless user?
      msg.reply "#{id} does not exist"
      return false
      
    if robot.auth.hasAdminRole(id)
      msg.reply "ID have admin role."
      return false

    return true

  validRoleParam = (msg, role) ->
    if role == 'admin'
      msg.reply "Sorry, the 'admin' role can only be defined in the HUBOT_AUTH_ADMIN env variable."
      return false

    return true

  robot.respond /auth help$/i, (msg) ->
    msg.reply '''
              <user id> has <role> role - Assigns a role to a user
              <user id> doesn't have <role> role - Removes a role from a user
              what role does <user id> have - Find out what roles are assigned to a specific user
              who has admin role - Find out who's an admin and can assign roles
              '''

  robot.respond /@?(.+) (has) (["'\w: -_]+) (role)/i, (msg) ->
    unless robot.auth.validUser(msg, 'auth')
      return

    name = msg.match[1].trim()
    newRole = msg.match[3].trim().toLowerCase()

    if name == 'who' and newRole == 'admin'
        msg.reply "The following people have the 'admin' role: #{admin.split(',')}"
        return

    id = parseInt(name)
    if not validParams(msg, id, newRole)
      return

    user = robot.brain.userForId(id)
    user.roles = user.roles or []
    if newRole in user.roles
      msg.reply "#{user.name} already has the '#{newRole}' role."
      return

    user.roles.push(newRole)
    msg.reply "Ok, #{user.name} has the '#{newRole}' role."

  robot.respond /@?(.+) (doesn't have|does not have) (["'\w: -_]+) (role)/i, (msg) ->
    unless robot.auth.validUser(msg, 'auth')
      return

    id = parseInt(msg.match[1].trim())
    newRole = msg.match[3].trim().toLowerCase()

    if not validParams(msg, id, newRole)
      return

    user = robot.brain.userForId(id)
    user.roles = (role for role in user.roles when role isnt newRole)
    msg.reply "Ok, #{user.name} doesn't have the '#{newRole}' role."

  robot.respond /(what role does|what roles does) @?(.+) (have)\?*$/i, (msg) ->
    id = parseInt(msg.match[2].trim())

    if not validIDParam(msg, id)
      return

    user = robot.brain.userForId(id)
    user.roles = user.roles or []

    if robot.auth.hasAdminRole(id)
      isAdmin = ' and is also an admin'
    else
      isAdmin = ''

    msg.reply "#{user.name} has the following roles: " + user.roles + isAdmin + "."
```

```coffeescript:say.coffee
# Description:
#   Hubot always has a snappy comeback.
#
# Dependencies:
#   None
#
# Configuration:
#   None
#
# Commands:
#   hubot who am i - Display your name, id and room name.
#
# Notes:
#   None
#
# Author:
#   IKUTA Masahito

module.exports = (robot) ->
  robot.respond /who am i$/i, (msg) ->
    user = msg.message.user
    msg.reply """
              Name: #{user.name}
              ID: #{user.id}
              Room: #{user.room}
              """
```

# 使い方

say.coffee を設置し, 環境変数に設定する値を調べる.

```
Hubot> hubot who am i
Hubot>
Name: Shell
ID: 1
Room: Shell
```

値が得られたら, 環境変数を設定する.

```shell:bin/hubot-local
#!/bin/sh

npm install
export PATH="node_modules/.bin:node_modules/hubot/node_modules/.bin:$PATH"
export HUBOT_LOG_LEVEL="debug"

export HUBOT_AUTH_ADMIN="1"
export HUBOT_AUTH_ROOM="Shell"

exec node_modules/.bin/hubot "$@"
```

権限を設定する.

```
Hubot> hubot 2 has fabric role
Hubot> hubot 2 has trello role
Hubot> hubot what role does 2 have
Hubot> 2 has the following roles: trello,fabric.
```

各コマンドに権限を確認する処理を追加する.

```coffeescript:trello.coffee
# ..snip..

module.exports = (robot) ->
  robot.respond /trello (.*)$/i, (msg) ->
    unless robot.auth.validUser(msg, 'trello')
      return

# ..snip..
```

これで, 少し安全に hubot を運用できる.