マルチテナントアプリケーションのストレージをDirectCloud-BOX APIでセキュア化

権限管理に優れたストレージでよりセキュアなアプリケーションを開発

## マルチテナントアプリケーションとは
B向け(会社向け)のアプリケーション開発において単社に対して行う受託開発のようなものと複数の会社に対して提供するものがあります。

**通常複数社に対して行う場合会社ID(company_id)と各機能を紐付ける必要があるのですが、バックエンド担当の実装に間違いがあるとA社の情報がB社に見えてしまったりします**。それを防ぐために一定の分離(マルチテナント化)を行う場合があり、そういったアーキテクチャーのアプリをマルチテナントアプリケーションと言ったりします。

## 分離するのはデータベースだけでよいか
通常分離の対象となるのはデータベースです。ここでは詳細を省きますが

* データベースサーバーを分割
* サーバーは同じだが同構造のデータベースを用意
* 同じデータベースで各テーブルに会社ID(company_id)を持たせる

などのやり方があります。

一方でグループウェアのようなアプリでは、データベースに加えてPDFやWordのようなファイルもその管理対象です。 **全ての会社のデータを同じ場所に入れるよりは会社ごとに一定の分離をしておくとよりセキュア**になるわけです。保存するストレージのディレクトリを分ける以上のことをしたい場合、ファイルストレージ部分を権限管理が強い共有ストレージサービスに任せることで簡便に実現できます。

## DirectCloud-BOXとは
[DirectCloud-BOX](https://directcloud.jp)は法人向けのクラウドストレージサービスです。

権限管理機能があり、国内データセンターで定額制です。

こういった共有サーバーのサービスではアップロード方法がFTPのみの場合もあるのですが、WEB API対応されているので今回使ってみます。

フォームから申し込むとログインIDとパスワードが発行されます。
ベースとなる画面が2つあり

一般ユーザー用と
![Screen Shot 2021-08-22 at 16.15.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103496/1f6c67c9-4aa5-83a8-a8f6-f111688e5f8b.png)


管理者用
![Screen Shot 2021-08-22 at 16.15.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103496/e12df41e-5f84-fa73-e760-835436a1fb4f.png)


が存在しています。**APIも同様に一般ユーザー用と管理者用の2系統**が分かれています。

# DirectCloud-BOXのAPIの呼び方
管理者画面からサービスキーを発行します。共有=>カスタム設定からサービスとサービスキーを取得できます。

![Screen Shot 2021-08-22 at 16.15.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103496/1b9130d5-05ac-78ba-e682-6cdcc097f15f.png)


サービスキーそのもので実ファイル操作ができるわけではなく、期限付きのアクセストークンを経由する必要があります。

流れとしては

1. 認証APIからアクセストークンを発行
1. 発行されたアクセストークンから各種APIを呼ぶ

になっています。

urlが似ていてわかりづらいですが認証APIと一般APIではベース文字列が違うので注意してください。

|    | 認証API |  通常API |
|----|----|---|
| 一般ユーザー(ファイル操作) |  /openapi/***  |  /openapp/*** |
| 管理者(権限変更)   | /openapi/***    |  /openapp/*** |

一般ユーザーと管理者と聞くと後者が前者のスーパーセットの感じがしますが実際はファイルの実操作と権限周りが分かれているだけです。

リソース指向のAPIなら削除ならDELETE、更新ならPUTといきたいところですが、GETとPOSTのみしか存在しないため削除も専用urlに対してPOSTします。(例: `POST
/openapp/v1/files/delete/{node}`)

## TLDR
デモ用に簡易Railsアプリを作成しました。

https://twitter.com/hiromichinomata/status/1430146490324844551

コードもGitHubに上がっています。
https://github.com/hiromichinomata/multi-tenant-direct-cloud

画面からファイルをアップロードするとDirectCloudの指定フォルダにファイルが上がります。
実際のマルチテナントアプリケーションでは会社ごとにフォルダが分かれるわけですがデモでは固定にしています。

画面からプレビューにも飛べます。

以下実装の解説です

## アップロード先の情報を取得
前準備としてアップロード用のフォルダを共有フォルダに作成します。`/共有/invoices`
一般ユーザー画面では共有(SharedBOX, 共有フォルダ)とマイボックス(MyBOX、個人フォルダ)があります。

APIを叩いてアップロードフォルダの情報を取得します
APIのベースエンドポイントは https://api.directcloud.jp です。InsomniaのようなGUIクライアントかcurlを使います。

### アクセストークンの取得
リクエスト

```
POST https://api.directcloud.jp/openapi/jauth/token
# Body
service: <SERVICE>
service_key: <SERVICE_KEY>
code: <会社コード>
id: <ユーザーID>
password: <パスワード>
```

レスポンス

```json
{
  "success": true,
  "access_token": "asdfxxxx",
  "expire": "2021-10-21 19:28:47",
  "expire_timestamp": 1634844527
}
```

### フォルダ一覧の取得
リクエスト

```
GET https://api.directcloud.jp/openapp/v1/folders/index
# Header
access_token: asdfxxxx
```

レスポンス

```json
{
  "success": true,
  "lists": [
    {
      "dir_seq": "123456",
      "node": "1",
      "name": "My Box",
      "type": "my",
      "datetime": "2021-08-22 17:20:20"
    },
    {
      "dir_seq": "123457",
      "node": "1{2",
      "name": "Shared Box",
      "type": "shared",
      "datetime": "2021-08-12 19:54:53"
    }
  ]
}
```

ドキュメントでは`GET /openapp/v1/folders/index/{node}`のnodeがstringなので"MyBOX"か"SharedBOX"を入れるように見えますが正しくありません。node指定なしでルートのフォルダーをとった後にたどっていく必要があります。連結リストになっているというとわかりやすいでしょうか。


### アップロードフォルダの取得
```
GET https://api.directcloud.jp/openapp/v1/folders/index/1{2
# Header
access_token: asdfxxxx
```

レスポンス

```json
{
  "success": true,
  "lists": [
    {
      "dir_seq": "234567",
      "node": "1{2On5",
      "name": "invoices",
      "use_link": "Y",
      "datetime": "2021-08-24 12:24:58"
    },
    {
      "dir_seq": "234568",
      "node": "1{2000",
      "name": "Doc",
      "datetime": "2021-08-22 07:18:02"
    },
    {
      "dir_seq": "234569",
      "node": "1{2001",
      "name": "Photo",
      "datetime": "2021-08-12 10:54:53"
    }
  ]
}
```

無事アップロード用フォルダのnode情報が取れました。

## はまりどころ
APIには2点大きなはまりどころがあります。

まず**Bodyの形式ですがjsonでなくフォーム形式となっています**。
jsonでcallしてもダメなので注意が必要です。ファイルもbase64のデータをフィールドに入れるわけではなくWebフォームでアップロードするのと同じ形式で送る必要があります。

もう一つはアクセストークンのキーです。アクセストークンはリクエストボディでなくヘッダーに入れる必要があるのですがドキュメントで言えば**keyが小文字アンダースコア区切り** (access_token)になっています。
古典的CGIでは大文字ハイフン区切りです。

rubyで最も人気のあるHTTPクライアントのライブラリーはfaradayですが、内部的にヘッダーのkeyを大文字ハイフン区切りにノーマライズしてサーバーに送るためそのままでは通りません。別のクライアントライブラリーのhttpclientではそういった挙動がないので通りました。

いくつか試してみたのですがDirect Cloud BoxのAPIは大文字小文字は区別しないもののハイフンは有効な値ではありませんでした。HTTPのプロトコルを記載したRFCでも大文字小文字はケースインセンシティブ、アンダースコアは有効な値なのでHTTPプロトコル違反ではないのですが慣例からは外れるのでv2ではどちらも通るのを期待したいところです。

## アップロード
アップロードフォルダにファイルをアップロードします。

```ruby
def get_access_token
  url = API_BASE_URL + '/openapi/jauth/token?lang=eng'
  payload = {
              service: ENV['DCB_SERVICE'],
              service_key: ENV['DCB_SERVICE_KEY'],
              code: ENV['DCB_COMPANY_CODE'],
              id: ENV['DCB_SUPERUSER_CODE'],
              password: ENV['DCB_PASSWORD']
            }
  response = Faraday.post(url) do |req|
    req.headers["Content-Type"] = "application/x-www-form-urlencoded"
    req.body = payload.to_query
  end
  JSON.parse(response.body)["access_token"]
end

def upload_file
  token = get_access_token
  header = {
              "access_token": token
            }

  url = API_BASE_URL + "/openapp/v1/files/upload/#{CGI.escape(ENV['DCB_DIR_NODE'])}"
  data = params.require(:invoice).permit(:blob)
  file_path = data[:blob].tempfile.path
  clnt = HTTPClient.new
  file_seq = ''
  File.open(file_path) do |file|
    payload = { "Filedata" => file }
    response = clnt.post(url, payload, header)
    file_seq = JSON.parse(response.body)["file_seq"].to_s
  end
  file_seq
end

file_seq = upload_file
```

## プレビューurl
アップロードしたファイルをプレビューで確認してみます。
ファイルのプロパティーにはnodeとは別にfile_seqという概念があるのですがそれを使う必要があります。

```ruby
def get_viewer_url(file_seq)
  token = get_access_token
  url = API_BASE_URL + '/openapp/v1/viewer/create'
  payload = { file_seq: file_seq }
  header = {
              "Content-Type": "application/x-www-form-urlencoded",
              "access_token": token
            }

  clnt = HTTPClient.new
  response = clnt.post(url, payload, header)
  JSON.parse(response.body)["url"]
end

get_viewer_url(file_seq)
```

無事ファイルを閲覧できました。

## その他
他にも `POST /openapp/v1/files/search/{node}`　でファイルの検索ができるようです。

単なるファイルの出し入れ以上の機能があるのは面白いですね。