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から検索するインデックスを作ってみるかということで、
1 |
CREATE INDEX eduperson_uid_idx ON eduperson (uid); |
のようにインデックスを張ってみるのですが、残念ながらほとんど性能は変わりません。
実はuidに限らず、多くのLDAPの属性は比較時に大文字小文字を区別しません。uidに関してはRFC1274で大文字小文字を区別しないことを規定されており、それを元にしたスキーマでは大文字小文字を区別しないのです。
そのため、上記のような単純なインデックスを張った場合は、インデックスは用いられずに性能は出ません。back-sqlはこのような比較を、SQLにおける比較演算子の両辺にupper()をかけて実行することで実現しています。
したがって、必要なインデックスは次のようなものです。
1 |
CREATE INDEX eduperson_upper_uid_idx ON eduperson USING btree (upper(uid)); |
もし、uid以外にもよく大文字小文字の区別なく検索される属性があれば、それも同様にupper()を入れた形のインデックスを作る必要があります。
さらに、back-sqlにおけるデータ検索の事実上のキーとなるテーブルのldap_entriesのdnにもインデックスは必須です。これも大文字小文字を区別しないので、次のようなインデックスが必要です。
1 |
CREATE INDEX ldap_entries_dn_upper_idx ON ldap_entries USING btree (upper(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スキーマは学認に必要なスキーマファイルを追加します。
1 2 3 4 5 |
include /usr/local/etc/openldap/schema/core.schema include /usr/local/etc/openldap/schema/cosine.schema include /usr/local/etc/openldap/schema/inetorgperson.schema include /usr/local/etc/openldap/schema/eduperson.schema include /usr/local/etc/openldap/schema/gakuninperson.schema |
モジュールはback_bdbを無効化した上で、back_sqlを有効化します。
1 2 3 4 5 |
modulepath /usr/local/libexec/openldap #moduleload back_bdb moduleload back_sql # moduleload back_hdb # moduleload back_ldap |
最後のデータベースの定義は、学認に本来必要な定義とback_sqlのサンプルのミックスで大丈夫です。
1 2 3 4 5 6 7 8 9 10 11 12 |
database sql suffix "o=hogehoge,dc=ac,c=JP" rootdn "cn=root,o=hogehoge,dc=ac,c=JP" rootpw 管理者のパスワードを設定 dbname ODBCのデータソース名 dbuser データベースユーザ名 dbpasswd データベースパスワード insentry_stmt "insert into ldap_entries (id,dn,oc_map_id,parent,keyval) values ((select max(id)+1 from ldap_entries),?,?,?,?)" upper_func "upper" strcast_func "text" concat_pattern "?||?" has_ldapinfo_dn_ru no |
OpenLDAPのback-sqlはODBC経由でDBサーバと接続します。PostgreSQLにunixODBCを使う場合は次のようにodbc.iniを設定します(私の試した環境では、iODBCではうまくback-sqlを動作させられませんでした)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
[データソース名] Description = PostgreSQL LDAP DBC Driver = PostgreSQL Trace = No TraceFile = /var/log/odbc-pgldap.log Database = データベース名 Servername = データベースサーバ名 Username = データベースユーザ名 Password = データベースパスワード Port = 5432 Protocol = 8.3 ReadOnly = No RowVersioning = No ShowSystemTables = No ShowOidColumn = No FakeOidIndex = No ConnSettings = Debug = 0 CommLog = 0 |
unixODBCのもう一つの設定ファイルであるodbcinst.iniは次のように設定します。これは通常のPostgreSQL用の設定そのものです。Driverのパスは環境に応じて改変してください。
1 2 3 4 5 |
[PostgreSQL] Description=ODBC for PostgreSQL Driver=/usr/local/lib/psqlodbcw.so CommLog=0 UsageCount=2 |
次はいよいよDBのスキーマです。まずはOpenLDAP back-sql自体に必要なテーブルたちです。基本的に、back-sqlのPostgreSQL用サンプルのままですが、先述の通りldap_entriesのdnにupper()をかけたインデックスを追加しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
CREATE TABLE ldap_attr_mappings ( id serial NOT NULL PRIMARY KEY, oc_map_id integer NOT NULL, name varchar(255) NOT NULL, sel_expr varchar(255) NOT NULL, sel_expr_u varchar(255), from_tbls varchar(255) NOT NULL, join_where varchar(255), add_proc varchar(255), delete_proc varchar(255), param_order integer NOT NULL, expect_return integer NOT NULL ); CREATE TABLE ldap_entries ( id serial NOT NULL PRIMARY KEY, dn varchar(255) NOT NULL, oc_map_id integer NOT NULL, parent integer NOT NULL, keyval integer NOT NULL ); CREATE INDEX ldap_entries_dn_upper_idx ON ldap_entries USING btree (upper(dn)); CREATE TABLE ldap_entry_objclasses ( entry_id integer NOT NULL references ldap_entries(id), oc_name varchar(64) ); CREATE TABLE ldap_oc_mappings ( id serial NOT NULL PRIMARY KEY, name varchar(64) NOT NULL, keytbl varchar(64) NOT NULL, keycol varchar(64) NOT NULL, create_proc varchar(255), delete_proc varchar(255), expect_return integer NOT NULL ); |
そして、学認のスキーマをSQLに直して置きます。複数値を取ることのできる属性は別テーブルに分割します。具体的には、eduPersonAffiliation, eduPersonScopedAffiliation, gakuninScopedPersonalUniqueCodeが該当します。
なお、edupersonにはupdatedという名前のtimestampが入っていますが、これはパスワードの擬似リアルタイム更新のために利用しています(学認のスキーマ的には関係ありません)。
また、jaoに関しては標準のattribute-resolver.xmlではLDAPではなくstaticな文字列を参照しているので、必要ありません(その意味ではoも必要ないのですが、階層構造を守るために一応入れています)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
CREATE TABLE organization ( id serial NOT NULL PRIMARY KEY, o varchar(80) UNIQUE NOT NULL, description varchar(255) ); CREATE TABLE organizationalunit ( id serial NOT NULL PRIMARY KEY, ou varchar(80) UNIQUE NOT NULL, description varchar(255) ); CREATE TABLE eduperson ( id serial NOT NULL PRIMARY KEY, uid varchar(255) UNIQUE NOT NULL, ou varchar(80) NOT NULL, cn varchar(128), sn varchar(80), givenname varchar(80), displayname varchar(168), userpassword varchar(80), mail varchar(255), edupersonprincipalname varchar(255), jasn varchar(80), jagivenname varchar(80), jadisplayname varchar(168), jaou varchar(80), updated timestamp with time zone ); CREATE INDEX eduperson_uid_upper_idx ON eduperson USING btree (upper(uid)); CREATE TABLE affiliation ( eduperson_id integer NOT NULL references eduperson(id), edupersonaffiliation varchar(16), UNIQUE(eduperson_id, edupersonaffiliation) ); CREATE TABLE scopedaffiliation ( eduperson_id integer NOT NULL references eduperson(id), edupersonscopedaffiliation varchar(64), UNIQUE(eduperson_id, edupersonscopedaffiliation) ); CREATE TABLE gakuninscopedpersonaluniquecode ( eduperson_id integer NOT NULL references eduperson(id), eduperson_gakuninscopedpersonaluniquecode varchar(64), UNIQUE(eduperson_id, eduperson_gakuninscopedpersonaluniquecode) ); |
次にback-sqlでいうところの「メタデータ」の設定です(おそらくLDAPスキーマを表すデータという意味で、学認のメタデータとは関係ありません)。
まずはldap_oc_mappingsのデータです。3つのobjectClassである、organizationとorganizationalUnit、edupersonの定義がされています。nameはobjectClassの名称、keytblはデータの入るテーブル、keycolは検索するためのプライマリキーの入る列の名前です。先述の通り、LDAP経由で追加や削除を行うつもりはないので、その用途のcreate_proc, delete_proc, expect_returnは利用しません。
1 2 3 4 5 6 |
INSERT INTO ldap_oc_mappings (name, keytbl, keycol, create_proc, delete_proc, expect_return) VALUES ('organization', 'organization', 'id', NULL, NULL, 0), ('organizationalUnit', 'organizationalunit', 'id', NULL, NULL, 0), ('eduPerson', 'eduperson', 'id', NULL, NULL, 0); |
次に、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断片で、具体的には
1 |
SELECT sel_expr FROM from_table WHERE join_where |
で値を得ることができるように設定します。
add_proc, delete_proc, param_order, expect_returnはLDAP経由で追加や削除を行うつもりはないので必要ありません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
INSERT INTO ldap_attr_mappings (oc_map_id, name, sel_expr, from_tbls, join_where, add_proc, delete_proc, param_order, expect_return) VALUES (1, 'o', 'organization.o', 'organization', NULL, NULL, NULL, 3, 0), (2, 'ou', 'organizationalunit.ou', 'organizationalunit', NULL, NULL, NULL, 3, 0), (3, 'uid', 'eduperson.uid', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'ou', 'eduperson.ou', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'cn', 'eduperson.cn', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'sn', 'eduperson.sn', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'givenName', 'eduperson.givenname', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'displayName', 'eduperson.displayname', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'userPassword', 'eduperson.userpassword', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'mail', 'eduperson.mail', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'eduPersonPrincipalName', 'eduperson.edupersonprincipalname', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'eduPersonAffiliation', 'affiliation.edupersonaffiliation', 'eduperson,affiliation', 'eduperson.id=affiliation.eduperson_id', NULL, NULL, 3, 0), (3, 'eduPersonScopedAffiliation', 'scopedaffiliation.edupersonscopedaffiliation', 'eduperson,scopedaffiliation', 'eduperson.id=scopedaffiliation.eduperson_id', NULL, NULL, 3, 0), (3, 'jasn', 'eduperson.jasn', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'jaGivenName', 'eduperson.jagivenname', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'jaDisplayName', 'eduperson.jadisplayname', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'jaou', 'eduperson.jaou', 'eduperson', NULL, NULL, NULL, 3, 0), (3, 'gakuninScopedPersonalUniqueCode', 'gakuninscopedpersonaluniquecode.eduperson_gakuninscopedpersonaluniquecode', 'eduperson,gakuninscopedpersonaluniquecode', 'eduperson.id=gakuninscopedpersonauniquecode.eduperson_id', NULL, NULL, 3, 0); |
たとえば、affiliationのデータである
1 2 |
(3, 'eduPersonAffiliation', 'affiliation.edupersonaffiliation', 'eduperson,affiliation', 'eduperson.id=affiliation.eduperson_id', NULL, NULL, 3, 0) |
は、eduPersonAffiliationという名前の属性は、eduPerson(ldap_oc_mapping.idが3)というobjectClassに含まれる属性であり、値を得るには
1 2 3 4 5 6 |
SELECT affiliation.edupersonaffiliation FROM eduperson,affiliation WHERE eduperson.id=affiliation.eduperson_id |
で検索できるということを示しています。意外に簡単です。
そして、oやouなどの基本的なデータを最初に投入します。oc_map_idはldap_of_mappingsのid、parentは親にあたるldap_entriesのid(dn:o=hogehoge,dc=ac,c=JPはルートエントリなので、親は存在しないため0になる)、keyvalは、データを検索するためのキー番号です。
1 2 3 4 5 6 7 |
INSERT INTO ldap_entries (dn, oc_map_id, parent, keyval) VALUES ('o=hogehoge,dc=ac,c=JP', 1, 0, 1); INSERT INTO organization (o) VALUES ('hogehoge'); INSERT INTO ldap_entries (dn, oc_map_id, parent, keyval) VALUES ('ou=user,o=hogehoge,dc=ac,c=JP', 2, 1, 1); INSERT INTO organizationalunit (ou) VALUES ('user'); |
たとえば、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で該当する行を検索できることを示しています。
1 |
SELECT * FROM organizationalunit WHERE id=1 |
あとは、ユーザのデータをSQLで突っ込んでいけば問題ありません。たとえば、次のようなLDIFのデータを格納する例を考えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
dn: uid=taro@hogehoge.ac.jp,ou=user,o=hogehoge,dc=ac,c=JP objectClass: eduPerson cn: taro@hogehoge.ac.jp ou: user uid: taro@hogehoge.ac.jp userPassword: {SSHA}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX eduPersonPrincipalName: akjVC3fhewhf4838hsAFAjvg@hogehoge.ac.jp mail: taro@hogehoge.ac.jp sn: Yamada givenName: Taro displayName: Taro Yamada eduPersonAffiliation: member eduPersonAffiliation: student eduPersonScopedAffiliation: member@hogehoge.ac.jp eduPersonScopedAffiliation: student@hogehoge.ac.jp jaou: ユーザ jasn: 山田 jaGivenName: 太郎 jaDisplayName: 山田太郎 gakuninScopedPersonalUniqueCode: student:12345678@hogehoge.ac.jp |
まずはedupersonテーブルに必要なデータをINSERTします。updatedにはcurrent_timestampを入れておきます(後述)。
1 2 3 4 5 6 7 8 |
INSERT INTO eduperson (uid, ou, cn, sn, givenname, displayname, userpassword, mail, edupersonprincipalname, jasn, jagivenname, jadisplayname, jaou, updated) VALUES ('taro@hogehoge.ac.jp', 'user', 'taro@hogehoge.ac.jp', 'Yamada', 'Taro', 'Taro Yamada', '{SSHA}XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'taro@hogehoge.ac.jp', 'akjVC3fhewhf4838hsAFAjvg@hogehoge.ac.jp', '山田', '太郎', '山田太郎', 'ユーザ', current_timestamp); |
そしてserialで振られたidの番号を取得します。
1 |
SELECT currval('eduperson_id_seq') |
最初のデータであれば、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」です。
1 2 3 4 |
INSERT INTO ldap_entries(dn, oc_map_id, parent, keyval) VALUES ('uid=taro@hogehoge.ac.jp,ou=user,o=hogehoge,dc=ac,c=JP', 3, 2, 1); |
さらに、id番号「1」を使って、他テーブルに分離されているデータを挿入します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
INSERT INTO affiliation(eduperson_id, edupersonaffiliation) VALUES (1, 'student'), (1, 'member'); INSERT INTO scopedaffiliation(eduperson_id, edupersonaffiliation) VALUES (1, 'student@hogehoge.ac.jp'), (1, 'member@hogehoge.ac.jp'); INSERT INTO gakuninscopedpersonaluniquecode (eduperson_id, eduperson_gakuninscopedpersonaluniquecode) VALUES (1, 'student:12345678@hogehoge.ac.jp'); |
これで、Shibbolethから参照可能な1レコードを持った、フル機能のLDAPサーバが完成しました。
パスワード更新の高速同期
せっかくのPostgreSQLのデータベースなので、トランザクションかけて全件更新を回すような作業も手軽にできるのは素敵です。アカウント情報の元になっているパスワードが変更された場合、できれば高い頻度で更新を行いたいのですが、今のデータ構造だと少々問題があります。
残念ながら「DELETE FROM eduperson」した後に新データのINSERTを回しているタイミングで、パスワードの更新を「UPDATE eduperson set userpassword=’新パスワード’」な感じでパスワード更新スクリプトを回すと、当然ながら全件DELETEで発生した表ロック待ちに入ってしまいます。
また、仮にロック待ちにならなかったとしても、全件更新が回り始めてから変更したパスワードの場合だと、トランザクションがコミットした時点で、古いパスワードに戻ってしまう可能性が考えられます。これはよくありません。
そこで、次のような方法を考えました。更新されたパスワードは、edupersonテーブルとは別の、updated_passwordテーブルに格納します。これならedupersonに表ロックがかかっても問題ありません(こちらの表も、過去一定期間に変更されたパスワードを、毎回トランザクションをかけて全件更新するのがお勧めです)。
1 2 3 4 5 |
CREATE TABLE updated_password ( uid varchar(255) UNIQUE NOT NULL, userpassword varchar(80), updated timestamp with time zone ); |
その上で、次のようなPL/PgSQLの関数を書いて、テーブルedupersonのuserpasswordとテーブルupdated_passwordのuserpasswordのどちらが新しいかを判断して、より新しい方のパスワードハッシュを返すこととします(なお、eduperson.uidとupdated_password.uidの検索に関しては、UNIQUE制約により暗黙のインデックスが作成されるので、明示的なインデックス作成は必要ありません)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
CREATE OR REPLACE FUNCTION newer_password(xuid varchar) RETURNS varchar LANGUAGE plpgsql AS $$ DECLARE passwd varchar(80); basepw record; newpw record; BEGIN passwd := ''; SELECT userpassword, updated INTO basepw FROM eduperson WHERE uid = xuid; IF FOUND THEN passwd := basepw.userpassword; SELECT userpassword, updated INTO newpw FROM updated_password WHERE uid = xuid; IF FOUND THEN IF basepw.updated < newpw.updated THEN passwd := newpw.userpassword; END IF; END IF; END IF; RETURN passwd; END; $$; |
そして、ldap_attr_mappingsの内容を書き換え、userPassword属性の値をこの関数を経由して返すように設定します。
1 2 3 |
UPDATE ldap_attr_mappings SET sel_expr = 'newer_password(eduperson.uid)' WHERE name='userPassword'; |
これで、求める機能を実現できました。この仕組みを用いたパスワードの同期は非常に高速なので、ほぼリアルタイムに近い形で同期が可能です。