はじめに
組織の基幹LDAPサーバーをバックエンドとして、OpenID ConnectのIdPをdexidp/dexで構築しています。
これまではKubernetes(以下、k8s)で稼動させていながら、設定ファイルを参照する作りになっていたためクラスタリングできないままでした。
別記事でPostgreSQLを利用してクラスターとして再構成したのですが、この設定を変更するための適当なツールがないため自作することにしたので、その際のメモを残しておきます。
IdPサーバーをクラスタリングする際には暗号鍵などの検証に必要な情報をノード間で共有する必要があります。
いままでシングル・インスタンスで運用していた時には設定をファイルで管理すれば良く、再起動してもsqliteのファイルを永続化しておけば問題なかったのですが、クラスタリングすることで運用を見直す必要がでてきました。
設定を管理するためにgRPC APIを利用する必要がでてきたので、その方法についてまとめておきます。
Dex APIの基本的な動き
個別にclient-idを指定すれば、CURD操作は問題なくできるようです。管理するためには一括で設定内容をexport/importできれば便利なのですが、api/v2/api.protoをみる限りは、クライアント設定を一括でダウンロードする方法はなさそうです。
gRPC APIの説明は次の文書で説明されています。
設定内容は別に台帳的に管理しながら、一括で設定を反映させることはできますし、それらが正しいかどうかの確認もできますが、把握していない未知の情報がないか外部から手掛りなしに確認することはできません。
裏で変な設定が反映されていても気がつくにはPostgreSQLのテーブルを直接チェックするしか方法がないので、台帳的な情報と過不足ないことを確認するために棚卸し的作業を実施する際には注意が必要です。
必要そうな機能をまとめると次のようなメッセージを処理できれば問題なさそうですが、知らないうちに登録されたclient-idの存在をDex APIから把握することはできなさそうです。
一方で自分が把握
棚卸し的な対応については、次のようにPostgreSQLのテーブルを直接チェックすることで確認できそうです。
dexdb=# select id from client ORDER BY id;
id
--------------------
example-app
...
APIクライアントの基本的な構成
とりあえず以下の記事にあるサンプルコードをベースにdexidp/dexのgRPCポートへ接続するクライアントを作成してみます。
api.proto
の内容はまったく違うので、定義を確認しながら進めていきます。
ライブラリの初期設定
以下のようなGemfileを作成しています。
source 'https://rubygems.org'
gem "grpc"
gem "grpc-tools"
gem "json"
gem "yaml"
まず dexidp/dex に付属する api.proto
ファイルからライブラリコードを生成します。
$ 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)の証明書が配置されていることを期待しています。
#!/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の情報が得られます。
$ bundle exec ruby 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のようなので、しばらく様子をみます。
アプリケーションの構成
実現したい機能は次のようなものです。
- 設定ファイルを元に複数のクライアント設定をdexidp/dexに反映させる
- クライアントIDを指定して削除する
- クライアントIDを指定して情報を確認する
バルクで設定を反映させる際には、既存設定の有無を判定してCreateかUpdateかを選択する必要があります。
Createが失敗したらUpdateという戦略もありますが、丁寧に作っていく予定です。
ファイルフォーマット
設定ファイルは確認の時のJSON.pretty_generateの出力から次のようなフォーマットを想定しています。
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"
ライブラリファイル
## -*- 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
を使ったサンプルアプリケーションは次のようになりました。
#!/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
利用例は次のように確認できます。
$ 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
利用例
$ bundle exec ruby run.rb get dex-client | jq .
$ bundle exec ruby run.rb delete dex-client
$ bundle exec ruby run.rb apply -f config.yaml
さいごに
とりあえず管理者として設定を一括で反映させたり、変更することはできるようになりました。
最終的にはエンドユーザーにclient-idを払出せるように台帳を管理したいと思っていますが、それは次の課題として取り組みたいと思います。