プロビジョニングの道具としてのOpenLDAP back-sql


OpenLDAPのバックエンドのおすすめはBDBなのですが、多くの場所から認証データを収集し、適切な形に編集してLDAP形式で公開する、いわばアカウント・プロビジョニング的な用途には、SQLのバックエンド機能(back-sql)も魅力的に見えます。

巨大なデータをトランザクションで全件更新しつつ、パスワードの更新は可能な限りリアルタイムで別テーブルに収集し、より新しいパスワードをクライアントに渡す…ような仕組みは、(単なるイメージなのかもしれませんが)やはりSQL系のデータベースに一日の長があるのではないかと考えます。

そんな魅力的に見えるOpenLDAPのback-sqlですが、実際に試してみると次のような欠点があります。

  • パフォーマンスが悪い
  • 設定が複雑すぎてわけがわからない

まずは性能面です。小規模なデータの場合はそれほどでもないのですが、大規模なデータを食わせると途端に悲惨な性能になってしまいます。実際に数万件のデータを食わせたところ、1秒に5件ほどしか検索ができないという悲惨な状況になりました。これではいくらなんでも実用的ではありません。

設定が複雑なのは、LDAPのスキーマを、SQLのスキーマとデータ(メタデータと呼ぶ)の双方を使って表現するからです。テーブル構造は難しくありませんが、メタデータの方はたしかに難解です。その分、さまざまなカスタマイズも可能と言えますが、そのメリットがあるかどうかはケースによりますが微妙でしょう。

これら2つの論点について、軽く解説します。なお、ここで用いたバックエンドのSQLデータベースはPostgreSQLです。

OpenLDAP back-sqlの性能問題

大規模なデータを入れると悲惨な性能になると評判のback-sqlですが、実はサンプルなどを見てスキーマをこさえて、何も考えずに大量のデータを入れると性能が悪化するのは当たり前で、PostgreSQL用のサンプルもMySQL用のサンプルも、インデックスの定義が含まれていません。

では、ログイン用の検索に用いられるuidから検索するインデックスを作ってみるかということで、

のようにインデックスを張ってみるのですが、残念ながらほとんど性能は変わりません。

実はuidに限らず、多くのLDAPの属性は比較時に大文字小文字を区別しません。uidに関してはRFC1274で大文字小文字を区別しないことを規定されており、それを元にしたスキーマでは大文字小文字を区別しないのです。

そのため、上記のような単純なインデックスを張った場合は、インデックスは用いられずに性能は出ません。back-sqlはこのような比較を、SQLにおける比較演算子の両辺にupper()をかけて実行することで実現しています。

したがって、必要なインデックスは次のようなものです。

もし、uid以外にもよく大文字小文字の区別なく検索される属性があれば、それも同様にupper()を入れた形のインデックスを作る必要があります。

さらに、back-sqlにおけるデータ検索の事実上のキーとなるテーブルのldap_entriesのdnにもインデックスは必須です。これも大文字小文字を区別しないので、次のようなインデックスが必要です。

これらのインデックスの追加やさまざまなチューニングを行った結果、1秒に数百回の検索が可能なまでに性能は向上しました。さすがにBDBには及びませんが、負荷分散などを適切に実行すれば、それなりにヘビーなアクセスにも耐えられる性能だと思います(back-sqlの場合、同じデータを持つLDAPサーバを提供する際にOpenLDAPの複製機能は使えませんが、バックエンドのPostgreSQLのレプリケーションなどの別の手段で実現可能です)。

OpenLDAP back-sqlの設定が複雑な問題

OpenLDAP back-sqlの設定は複雑です。よく理解していないと全く設定できません。とは言っても、用途を限定すればそれほど難しくはありません。たとえばLDAPの主要機能として、Search, Add, Modify, Deleteがありますが、プロビジョニングに用いるにはSearchさえできれば後はできなくても構わないという考え方もあります。Add, Modify, DeleteはSQL経由で、LDAP経由でやるよりもはるかに柔軟な仕組みで可能ですし、LDAP経由で行いたいという需要はありません。

ここでは、学認のスキーマをPostgreSQL上に展開した例を解説します。

まず、これがなければ始まらないslapd.confです。LDAPスキーマは学認に必要なスキーマファイルを追加します。

モジュールはback_bdbを無効化した上で、back_sqlを有効化します。

最後のデータベースの定義は、学認に本来必要な定義とback_sqlのサンプルのミックスで大丈夫です。

OpenLDAPのback-sqlはODBC経由でDBサーバと接続します。PostgreSQLにunixODBCを使う場合は次のようにodbc.iniを設定します(私の試した環境では、iODBCではうまくback-sqlを動作させられませんでした)。

unixODBCのもう一つの設定ファイルであるodbcinst.iniは次のように設定します。これは通常のPostgreSQL用の設定そのものです。Driverのパスは環境に応じて改変してください。

次はいよいよDBのスキーマです。まずはOpenLDAP back-sql自体に必要なテーブルたちです。基本的に、back-sqlのPostgreSQL用サンプルのままですが、先述の通りldap_entriesのdnにupper()をかけたインデックスを追加しています。

そして、学認のスキーマをSQLに直して置きます。複数値を取ることのできる属性は別テーブルに分割します。具体的には、eduPersonAffiliation,  eduPersonScopedAffiliation, gakuninScopedPersonalUniqueCodeが該当します。

なお、edupersonにはupdatedという名前のtimestampが入っていますが、これはパスワードの擬似リアルタイム更新のために利用しています(学認のスキーマ的には関係ありません)。

また、jaoに関しては標準のattribute-resolver.xmlではLDAPではなくstaticな文字列を参照しているので、必要ありません(その意味ではoも必要ないのですが、階層構造を守るために一応入れています)。

次にback-sqlでいうところの「メタデータ」の設定です(おそらくLDAPスキーマを表すデータという意味で、学認のメタデータとは関係ありません)。

まずはldap_oc_mappingsのデータです。3つのobjectClassである、organizationとorganizationalUnit、edupersonの定義がされています。nameはobjectClassの名称、keytblはデータの入るテーブル、keycolは検索するためのプライマリキーの入る列の名前です。先述の通り、LDAP経由で追加や削除を行うつもりはないので、その用途のcreate_proc, delete_proc, expect_returnは利用しません。

次に、ldap_attr_mappingsで属性のマッピングを行います。一見難しそうですが、それほどではありません。

oc_map_idは先ほど定義したldap_oc_mappingsでのobjectClassのidの参照で、先程は投入順にorganizationが1、organizationalUnitが2、eduPersonが3に相当します。

nameは属性名。

sel_expr, from_table, join_whereは属性の値をSQLで入手するためのSQL断片で、具体的には

で値を得ることができるように設定します。

add_proc, delete_proc, param_order, expect_returnはLDAP経由で追加や削除を行うつもりはないので必要ありません。

たとえば、affiliationのデータである

は、eduPersonAffiliationという名前の属性は、eduPerson(ldap_oc_mapping.idが3)というobjectClassに含まれる属性であり、値を得るには

で検索できるということを示しています。意外に簡単です。

そして、oやouなどの基本的なデータを最初に投入します。oc_map_idはldap_of_mappingsのid、parentは親にあたるldap_entriesのid(dn:o=hogehoge,dc=ac,c=JPはルートエントリなので、親は存在しないため0になる)、keyvalは、データを検索するためのキー番号です。

たとえば、dn:ou=user,o=hogehoge,dc=ac,c=JPを例に取ると、oc_map_idは2なので、objectClassはorganizationalUnit、parentは1なので、ldap_entriesのid=1である、dn:o=hogehoge,dc=ac,c=JPが親、keyvalが1なので、oraganizationalUnitの「keytbl=organizationalunit」「keycol=id」というデータと併せて、次のようなSQLで該当する行を検索できることを示しています。

あとは、ユーザのデータをSQLで突っ込んでいけば問題ありません。たとえば、次のようなLDIFのデータを格納する例を考えます。

まずはedupersonテーブルに必要なデータをINSERTします。updatedにはcurrent_timestampを入れておきます(後述)。

そしてserialで振られたidの番号を取得します。

最初のデータであれば、1が帰ってくるでしょう。

ldap_entriesにエントリを追加します。oc_map_idはedupersonなので「3」、parentは先ほど入れた「dn:ou=user,o=hogehoge,dc=ac,c=JP」のidなので「2」、keyvalは先ほどcurval()で取得した、edupersonテーブルにおけるデータのIDなので「1」です。

さらに、id番号「1」を使って、他テーブルに分離されているデータを挿入します。

これで、Shibbolethから参照可能な1レコードを持った、フル機能のLDAPサーバが完成しました。

パスワード更新の高速同期

せっかくのPostgreSQLのデータベースなので、トランザクションかけて全件更新を回すような作業も手軽にできるのは素敵です。アカウント情報の元になっているパスワードが変更された場合、できれば高い頻度で更新を行いたいのですが、今のデータ構造だと少々問題があります。

残念ながら「DELETE FROM eduperson」した後に新データのINSERTを回しているタイミングで、パスワードの更新を「UPDATE eduperson set userpassword=’新パスワード’」な感じでパスワード更新スクリプトを回すと、当然ながら全件DELETEで発生した表ロック待ちに入ってしまいます。

また、仮にロック待ちにならなかったとしても、全件更新が回り始めてから変更したパスワードの場合だと、トランザクションがコミットした時点で、古いパスワードに戻ってしまう可能性が考えられます。これはよくありません。

そこで、次のような方法を考えました。更新されたパスワードは、edupersonテーブルとは別の、updated_passwordテーブルに格納します。これならedupersonに表ロックがかかっても問題ありません(こちらの表も、過去一定期間に変更されたパスワードを、毎回トランザクションをかけて全件更新するのがお勧めです)。

その上で、次のようなPL/PgSQLの関数を書いて、テーブルedupersonのuserpasswordとテーブルupdated_passwordのuserpasswordのどちらが新しいかを判断して、より新しい方のパスワードハッシュを返すこととします(なお、eduperson.uidとupdated_password.uidの検索に関しては、UNIQUE制約により暗黙のインデックスが作成されるので、明示的なインデックス作成は必要ありません)。

そして、ldap_attr_mappingsの内容を書き換え、userPassword属性の値をこの関数を経由して返すように設定します。

これで、求める機能を実現できました。この仕組みを用いたパスワードの同期は非常に高速なので、ほぼリアルタイムに近い形で同期が可能です。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です