kikumotoのメモ帳

インフラ・ミドル周りを中心に、興味をもったことを適当な感じで。twitter : @takakiku

Dify Community版で会話ログへのアクセスを制限する

本記事Qiita Dify Advent Calendar 202415日目の記事です。

前置き

会社で Dify Communitiy版(セルフホスト環境)を AWS で動かしています。
構成は Dify on AWS with CDK に限りなく近いですが

  • Terraform で構築
  • ElastiCache Valkey を利用(Redis から移行しました)

が大きな違いでしょうか。
あとは、社員限定アクセスとするため ALB で Google 認証いれてます。

と、こんな感じの環境で社内で活発に利用され始めました。
みなさんにどんどんアプリを作ってもらいたいので基本的に エディター 権限を付与しています。

そうすると、一部の部署から(若干)機密性のある資料をナレッジにいれて利用したいという要望がありました。
Dify上で、ナレッジへのアクセスはナレッジ単位で制御できる設定はあるので良いのですが、会話ログも他の人に見られたくないとのこと。
現状の Dify では エディター 権限あれば、他の人が作ったアプリの会話ログも見えてしまいます。

この記事では、この会話ログへのアクセスを

  • Difyのソースは変更せずに、オーナー・管理者・アプリ作成者に制限する仕組み

について書きます。
なお、一旦画面を使ってのアクセスのみについて対処するものです。

事前注意事項

本記事での方法は v0.13.2 の時点で動作できているものですが、将来にわたりその動作が保証されるものではありません。
また、本記事を参考に同等なものを作成し、それによって生じるいかなる結果も当方では責任はとれません。

仕組み

おおまかな仕組みは以下の通りです。

  • API の前段にリーバースプロクシとして Nginx(実際は OpenResty)を配置
  • /console/api/apps/<uuid>/chat-conversations へのアクセス時に Lua を動かす
  • Lua でDBにアクセスし、閲覧して良い人かどうかを判断する
  • OKなら API コンテナにリバースプロクシする
  • NGなら 403 Forbidden にする

構成をイメージ化すると以下のような感じです

以下で、もう少し詳しく書いていきます。

リバースプロクシ

今回、Lua を利用するのでOpenRestyコンテナを利用しています。 そこで、以下のような設定をしてあります。

    location ~ /console/api/apps/(?<app_id>[^/]+)/chat-conversations {
      access_by_lua_file '/usr/local/openresty/lua-scripts/auth.lua';

      proxy_pass http://localhost:5001;
      include proxy.conf;
    }

localhost:5001API コンテナのアクセス先になります。
ECSにおいて API コンテナと OpenResty コンテナを同一タスク内で定義しているので、このようなアクセス方法となっています。

また、ログ&アナウンス

  • /app/<uuid>/logs

というパスですが、その中の処理で実際の会話ログが取得されるようになっており、その際のアクセス先が

  • /console/api/apps/<uuid>/chat-conversations

というようなものなので、そこへのアクセスをきっかけに Lua が実行されるようになっています

Lua

Lua でやっていることはおおよそ以下の通りです(ソース公開は控えさせていただきますが以下を愚直に実装しているだけです)

  • Authorization ヘッダーをデコードして、user_id を得る。
  • リクエスURIに含まれるアプリケーションIDをキーにDBに問い合わせ、アプリケーションの作者の id を得る。
    • アプリケーション作者の場合は 許可 となる
  • user_id をキーにしてDBに問い合わせ、そのユーザーのロールを取得する。
    • ロールが owner or admin であれば、許可 となる
  • 上記に該当しない場合は 403 Forbidden となる

これらのポイントとなる情報を後は説明して終わりにします

user_id の取得

エンドポイントへのアクセス時に、

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNDRmNTVlOTUtMDA3OC00YTJhLWIzMjMtYWQzNDZmNDYzMjkyIiwiZXhwIjoxNzMwMzQ2MzkzLCJpc3MiOiJTRUxGX0hPU1RFRCIsInN1YiI6IkNvbnNvbGUgQVBJIFBhc3Nwb3J0In0.ymnvkzc96HnWX26CZ7i3vCAl9h8knLZNmTB6lMwGro0

のようなリクエストヘッダーが付与されます。

このトークンは https://github.com/langgenius/dify/blob/0.13.2/api/extensions/ext_login.py#L23-L37 にあるように

    decoded = PassportService().verify(auth_token)
    user_id = decoded.get("user_id")

にてデコードされて user_id を取得できるものです

そしてデコード自体は https://github.com/langgenius/dify/blob/0.13.2/api/libs/passport.py#L16 にあるように

jwt.decode(token, self.sk, algorithms=["HS256"])

JWTライブラリを使ってデコードされています

self.sk環境変数で与えている SECRET_KEY なので、同等なデコードの処理を Lua で行うことにより user-id を取得できるということです

アプリケーション作成者

アプリケーションの情報は apps テーブルにあります

                                            Table "public.apps"
         Column          |            Type             | Collation | Nullable |           Default
-------------------------+-----------------------------+-----------+----------+-----------------------------
 id                      | uuid                        |           | not null | uuid_generate_v4()
 tenant_id               | uuid                        |           | not null |
 name                    | character varying(255)      |           | not null |
 mode                    | character varying(255)      |           | not null |
 icon                    | character varying(255)      |           |          |
 icon_background         | character varying(255)      |           |          |
 app_model_config_id     | uuid                        |           |          |
 status                  | character varying(255)      |           | not null | 'normal'::character varying
 enable_site             | boolean                     |           | not null |
 enable_api              | boolean                     |           | not null |
 api_rpm                 | integer                     |           | not null | 0
 api_rph                 | integer                     |           | not null | 0
 is_demo                 | boolean                     |           | not null | false
 is_public               | boolean                     |           | not null | false
 created_at              | timestamp without time zone |           | not null | CURRENT_TIMESTAMP(0)
 updated_at              | timestamp without time zone |           | not null | CURRENT_TIMESTAMP(0)
 is_universal            | boolean                     |           | not null | false
 workflow_id             | uuid                        |           |          |
 description             | text                        |           | not null | ''::character varying
 tracing                 | text                        |           |          |
 max_active_requests     | integer                     |           |          |
 icon_type               | character varying(255)      |           |          |
 created_by              | uuid                        |           |          |
 updated_by              | uuid                        |           |          |
 use_icon_as_answer_icon | boolean                     |           | not null | false
Indexes:
    "app_pkey" PRIMARY KEY, btree (id)
    "app_tenant_id_idx" btree (tenant_id)
Referenced by:
    TABLE "tool_published_apps" CONSTRAINT "tool_published_apps_app_id_fkey" FOREIGN KEY (app_id) REFERENCES apps(id)

URLのpathに含まれる UUID がこのテーブルの id の値なので、そこから create_by カラムを見れば作者の user_id がわかります。 そして、認証情報に含まれる user_id と比較すれば、アクセスしている人がアプリケーションの作者かどうか判断できるということです。

オーナー、管理者

ロールは tenant_account_joins で設定されているようです

                              Table "public.tenant_account_joins"
   Column   |            Type             | Collation | Nullable |           Default
------------+-----------------------------+-----------+----------+-----------------------------
 id         | uuid                        |           | not null | uuid_generate_v4()
 tenant_id  | uuid                        |           | not null |
 account_id | uuid                        |           | not null |
 role       | character varying(16)       |           | not null | 'normal'::character varying
 invited_by | uuid                        |           |          |
 created_at | timestamp without time zone |           | not null | CURRENT_TIMESTAMP(0)
 updated_at | timestamp without time zone |           | not null | CURRENT_TIMESTAMP(0)
 current    | boolean                     |           | not null | false
Indexes:
    "tenant_account_join_pkey" PRIMARY KEY, btree (id)
    "tenant_account_join_account_id_idx" btree (account_id)
    "tenant_account_join_tenant_id_idx" btree (tenant_id)
    "unique_tenant_account_join" UNIQUE CONSTRAINT, btree (tenant_id, account_id)

account_id が user_id なので、これからアクセスしている人のロールを取得できます

まとめ

Dify Community版において、Dify本体を修正せずに、会話ログの閲覧を制限する仕組みを作ってみました

  • OpenResty + Lua を採用
  • リクエストに含まれる認証情報をもとに Dify DB に問い合わせて、閲覧権限の許可を判断する

という仕組みでした

以上、最後までご覧いただきありがとうございました