Rubyでdexidp/dexのgRPC経由でAPIを操作してみた

# はじめに

組織の基幹LDAPサーバーをバックエンドとして、OpenID ConnectのIdPをdexidp/dexで構築しています。

https://github.com/dexidp/dex

これまではKubernetes(以下、k8s)で稼動させていながら、設定ファイルを参照する作りになっていたためクラスタリングできないままでした。

別記事でPostgreSQLを利用してクラスターとして再構成したのですが、この設定を変更するための適当なツールがないため自作することにしたので、その際のメモを残しておきます。

https://qiita.com/YasuhiroABE/items/97a8a1d96941cb810554

IdPサーバーをクラスタリングする際には暗号鍵などの検証に必要な情報をノード間で共有する必要があります。

いままでシングル・インスタンスで運用していた時には設定をファイルで管理すれば良く、再起動してもsqliteのファイルを永続化しておけば問題なかったのですが、クラスタリングすることで運用を見直す必要がでてきました。

設定を管理するためにgRPC APIを利用する必要がでてきたので、その方法についてまとめておきます。

# Dex APIの基本的な動き

個別にclient-idを指定すれば、CURD操作は問題なくできるようです。管理するためには一括で設定内容をexport/importできれば便利なのですが、[api/v2/api.proto](https://github.com/dexidp/dex/blob/master/api/v2/api.proto)をみる限りは、クライアント設定を一括でダウンロードする方法はなさそうです。

gRPC APIの説明は次の文書で説明されています。

https://dexidp.io/docs/configuration/api/

設定内容は別に台帳的に管理しながら、一括で設定を反映させることはできますし、それらが正しいかどうかの確認もできますが、把握していない未知の情報がないか外部から手掛りなしに確認することはできません。

裏で変な設定が反映されていても気がつくにはPostgreSQLのテーブルを直接チェックするしか方法がないので、台帳的な情報と過不足ないことを確認するために棚卸し的作業を実施する際には注意が必要です。

必要そうな機能をまとめると次のようなメッセージを処理できれば問題なさそうですが、知らないうちに登録されたclient-idの存在をDex APIから把握することはできなさそうです。

一方で自分が把握

```plantuml:
actor Client
Client -> DexAPI: add_client
Client -> DexAPI: get_client_by_id
Client -> DexAPI: update_client_by_id
Client -> DexAPI: delete_client_by_id

actor "Malicious Agent"
"Malicious Agent" -> DexAPI: add_unknown_client
note left: Client cannot detect malcious unknown client info.
```

棚卸し的な対応については、次のようにPostgreSQLのテーブルを直接チェックすることで確認できそうです。

```sql:PostgreSQLのテーブルからclient-idを把握する方法
dexdb=# select id from client ORDER BY id;
         id         
--------------------
 example-app
 ...
```

# APIクライアントの基本的な構成

とりあえず以下の記事にあるサンプルコードをベースにdexidp/dexのgRPCポートへ接続するクライアントを作成してみます。

https://qiita.com/dnnnn_yu/items/d011c701f7123400fb5a

https://docs.wantedly.dev/fields/the-system/grpc-in-ruby

``api.proto``の内容はまったく違うので、定義を確認しながら進めていきます。

## ライブラリの初期設定

以下のようなGemfileを作成しています。

```ruby:Gemfile
source 'https://rubygems.org'

gem "grpc"
gem "grpc-tools"

gem "json"
gem "yaml"
```

まず dexidp/dex に付属する ``api.proto`` ファイルからライブラリコードを生成します。

```bash:
$ mkdir -p lib/v2
$ curl https://raw.githubusercontent.com/dexidp/dex/v2.39.1/api/v2/api.proto |tee lib/v2/api.proto

$ bundle config set path lib
$ bundle install
$ bundle exec grpc_tools_ruby_protoc -I lib  --ruby_out=lib/ --grpc_out=lib/ lib/v2/api.proto
```

``grpc_tools_ruby_protoc``の実体は``lib/ruby/3.3.0/bin/grpc_tools_ruby_protoc``にあります。


## 検証用アプリの実装

スクリプトの前提として ``lib/v2/`` に ``api.proto``ファイルなどがあることの他に、``cert/ca.crt``にdexidp/dexサーバーのTLSに対応する認証局(CA)の証明書が配置されていることを期待しています。

```ruby:run.rb
#!/usr/bin/env ruby

require 'bundler/setup'
Bundler.require
$: << "./lib"
require_relative './lib/v2/api_services_pb.rb'

def main()
  root_data = File.read("cert/ca.crt")
  credentials = GRPC::Core::ChannelCredentials.new(root_data)
  stub = Api::Dex::Stub.new('dex.example.com:80', credentials)
  resp = stub.get_client(Api::GetClientReq.new(id: "dex-client"))
  puts JSON.pretty_generate(resp.to_h)
end

main()
```

このスクリプトを実行すると次のように既に登録されている、``"dex-client"`` IDの情報が得られます。

```bash: run.rbスクリプトの実行
$ bundle exec ruby run.rb
```

次のような出力が得られます。

```bash: run.rbの出力
{
  "client": {
    "id": "dex-client",
    "secret": "51a7de576285fc9f928269c143045778",
    "redirect_uris": [
      "http://localhost:8080/protected/redirect_uri",
      "http://localhost:8080/redirect_uri"
    ],
    "name": "Dex/IDP Client"
  }
}
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1737428434.186619   17799 init.cc:232] grpc_wait_for_shutdown_with_timeout() timed out.
```

この状態では接続が正常にcloseできていないので、余計なメッセージが出力されています。

これは認識されているissueのようなので、しばらく様子をみます。

https://github.com/google-gemini/generative-ai-python/issues/486

# アプリケーションの構成

実現したい機能は次のようなものです。

* 設定ファイルを元に複数のクライアント設定をdexidp/dexに反映させる
* クライアントIDを指定して削除する
* クライアントIDを指定して情報を確認する

バルクで設定を反映させる際には、既存設定の有無を判定してCreateかUpdateかを選択する必要があります。

Createが失敗したらUpdateという戦略もありますが、丁寧に作っていく予定です。

## ファイルフォーマット

設定ファイルは確認の時のJSON.pretty_generateの出力から次のようなフォーマットを想定しています。

```yaml:config.yamlのサンプル
type: mydex-grpc-client
host: dex.example.com
port: 80
ca_cert: cert/ca.crt
items:
  - id: "dex-client"
    secret: ""
    redirect_urls:
      - "http://localhost:8080/protected/redirect_uri"
      - "http://localhost:8080/redirect_uri"
    name: "dex/idp client"
  - id: "example-app"
    secret: "hoge"
    redirect_urls: []
    name: "example app"
```

## ライブラリファイル

```ruby:lib/mydex.rb
## -*- mode: ruby; coding: utf-8-unix -*-*

class MyDexClient
  @id = ""
  @secret = ""
  @redirect_uris = []
  @name = ""
end

class MyDex
  attr_reader :stub
  attr_reader :config
  def initialize(config)
    @config = config
    @stub = nil
    @stub = self.get_stub
  end

  def get_client(id)
    ret = nil
    begin
      ret = @stub.get_client(Api::GetClientReq.new(id: id))
    rescue => e
      STDERR.puts e
    end
    return ret
  end

  def create_client(client)
    ret = false
    begin
      api_client = Api::Client.new(id: client["id"],
                                   secret: client["secret"],
                                   redirect_uris: client["redirect_uris"],
                                   name: client["name"])
      resp = @stub.create_client(Api::CreateClientReq.new(client: api_client))
      p resp.to_h
      ret = resp.to_h[:already_exists] ? false : true
    rescue => e
      STDERR.puts e
      ret = false
    end
  end
  
  def update_client(client)
    ret = false
    begin
      resp = @stub.update_client(Api::UpdateClientReq.new(id: client["id"],
                                                   redirect_uris: client["redirect_uris"],
                                                   name: client["name"]))
      ret = true
    rescue => e
      STDERR.puts e
      ret = false
    end
    return ret
  end

  def delete_client(id)
    ret = false
    begin
      @stub.delete_client(Api::DeleteClientReq.new(id: id))
      ret = true
    rescue => e
      STDERR.puts e
      ret = false
    end
    return ret
  end

  private
  def get_stub
    root_data = File.read(@config["ca_cert"])
    credentials = GRPC::Core::ChannelCredentials.new(root_data)
    @stub = Api::Dex::Stub.new("#{@config["host"]}:#{@config["port"]}", credentials) if @stub.nil?
  end
end
```

## サンプルコード

``lib/mydex.rb``を使ったサンプルアプリケーションは次のようになりました。

```ruby:run.rb (改良版)
#!/usr/bin/env ruby

require 'bundler/setup'
Bundler.require
$: << "./lib"
require_relative './lib/v2/api_services_pb.rb'
require_relative './lib/mydex.rb'

def usage
  STDERR.puts <<~USAGE
    Usage:
      #{File.basename($0)} [command] [options]

    Description:
      A CLI tool to manage configurations and clients.
      You can apply settings from config.yaml or manipulate client information.

    Commands:
      apply
          Apply the configuration based on config.yaml.

      get CLIENT_ID
          Get the client information based on the specified CLIENT_ID.

      delete CLIENT_ID
          Delete the client based on the specified CLIENT_ID.

    Options:
      -h, --help
          Show this help message.
      -f YAML_FILE, --file=YAML_FILE
          Specify a configuration file.

    Examples:
      # Apply configuration:
      #{File.basename($0)} apply -f config.yaml

      # Get client with ID "dex-client":
      #{File.basename($0)} get dex-client

      # Delete client with ID "dex-client":
      #{File.basename($0)} delete dex-client
  USAGE
end

## Parse Options
require 'optparse'
options = {}
OptionParser.new do |opts|
  opts.banner = "Usage: #{File.basename($0)} [command] [options]"
  opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
    options[:verbose] = v
  end
  opts.on("-fYAML_FILE", "--file=YAML_FILE", "Load a configuration YAML file") do |f|
    options[:file] = f
  end
  opts.on("-h", "--help", "Show this message") do |h|
    options[:help] = h
  end
end.parse!

## Load Config
begin
  config = YAML.load_file(options[:file] || "config.yaml")
rescue => e
  puts e
  usage
  exit 1
end

## Main
if ARGV.length == 0 or options[:help]
  usage
  exit 1
end

client = MyDex.new(config)
if ARGV[0] == "apply"
  config["items"].each do |item|
    unless client.create_client(item)
      if client.update_client(item)
        STDERR.puts "Successfully updated the client"
      else
        STDERR.puts "Failed to update the client"
      end
    else
      STDERR.puts "Successfully create the client"
    end
  end
elsif ARGV[0] == "get" && ARGV.length == 2 && ! ARGV[1].to_s.empty?
  resp = client.get_client(ARGV[1].to_s)
  puts JSON.pretty_generate(resp.to_h)
elsif ARGV[0] == "delete" && ARGV.length == 2 && ! ARGV[1].to_s.empty?
  if client.delete_client(ARGV[1].to_s)
    puts "Successfully deleted the client"
  else
    puts "Failed to delete the client"
  end
else
  usage
  exit 1
end
```

利用例は次のように確認できます。

```bash:
$ bundle exec ruby run.rb
No such file or directory @ rb_sysopen - config.yaml

Usage:
  run.rb [command] [options]

Description:
  A CLI tool to manage configurations and clients.
  You can apply settings from config.yaml or manipulate client information.

Commands:
  apply
      Apply the configuration based on config.yaml.

  get CLIENT_ID
      Get the client information based on the specified CLIENT_ID.

  delete CLIENT_ID
      Delete the client based on the specified CLIENT_ID.

Options:
  -h, --help
      Show this help message.
  -f YAML_FILE, --file=YAML_FILE
      Specify a configuration file.

Examples:
  # Apply configuration:
  run.rb apply -f config.yaml

  # Get client with ID "dex-client":
  run.rb get dex-client

  # Delete client with ID "dex-client":
  run.rb delete dex-client
```

## 利用例

```bash:dex-clientの情報を出力
$ bundle exec ruby run.rb get dex-client | jq .
```

```bash:dex-clientを削除
$ bundle exec ruby run.rb delete dex-client
```

```bash:config.yamlに記載した内容を反映・変更
$ bundle exec ruby run.rb apply -f config.yaml
```


# さいごに

とりあえず管理者として設定を一括で反映させたり、変更することはできるようになりました。

最終的にはエンドユーザーにclient-idを払出せるように台帳を管理したいと思っていますが、それは次の課題として取り組みたいと思います。