🆔

軽量 LDAP サーバー LLDAP を試す

2024/04/18に公開

LLDAP について

LDAP はサーバー内で管理されるユーザーを効率よくクエリするためのプロトコルであり、これを利用してユーザーや組織を一元管理する機能を持つアプリケーションやソフトウェアは LDAP サーバーと呼ばれます。
LDAP サーバの中でも機能や特徴によっていくつか種類があります。オープンソースの中で代表的なものとしては以下。

  • OpenLDAP
  • FreeIPA
  • 389 Directory Server
  • Kanidm
  • LLDAP

この中で最も有名なのは OpenLDAP で、以下のようなメリット・デメリットがあります。

  • メリット
    • 多機能で独自機能のカスタム性が高い
    • 情報自体はある程度豊富
    • コンテナイメージや helm があるので環境構築は楽
    • 場合によっては schemes 等の config が必要
  • デメリット
    • 1 から学ぶ分には学習コストが高い
    • 情報自体は多いがまとまったドキュメントが少ない
    • 組み込みの UI がない。選択肢はいくつかあるがどれもレガシー感がある

一方で今回試す Light LDAP (LLDAP) は以下のようなメリットがあります。

  • Rust で実装されており軽量
  • schemes 等の config が必要ない
  • 簡素だが組み込み UI つき。
  • 作成できる LDAP の要素はユーザーとグループのみとシンプル。逆に利用者が考えるべき項目が少ないとも言える。

基本的に Light LDAP の名の通りシンプルに使える LDAP サーバーといった位置づけになっています。Github に他の LDAP サーバーとの比較もあるのでこちらも参照。
というわけで早速使ってみます。

環境構築

Github の With Docker に docker compose の例があるので簡単に構築できます。
設定項目は以下。

  • LDAP_BASE_DN
    • 今回の検証では LLDAP のホスト名を ldap.centre.com としたいので、それに合わせて LDAP_BASE_DNdc=ldap,dc=centre,dc=com に設定。
  • port
    • 3890 は LDAP に使用。
    • 6360 は LDAPS に使用(今回は未使用)。
    • 17170 は web UI に使用
  • LLDAP_VERBOSE=true
    • ログレベルに対応。true にすると debug
docker-compose.yml
services:
  lldap:
    image: lldap/lldap:stable
    container_name: lldap
    restart: always
    ports:
      - "3890:3890"
      - "6360:6360"
      - "17170:17170"
    volumes:
       - "./lldap_data:/data"
    environment:
      - LLDAP_VERBOSE=true
      - LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
      - LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
      - LLDAP_LDAP_BASE_DN=dc=ldap,dc=centre,dc=com

動作確認

コンテナ起動後、0.0.0.0:17170 で web UI にアクセスできます。
初期ユーザは username: admin, password: password でログインできます。
web UI は users/groups タブで作成済みのユーザ・グループ一覧を表示し、それぞれの create ボタンで新規に作成するだけの非常にシンプルな作りになってます。

試しにテスト用のユーザーとグループを作成します。

ldap 情報の検索に使われる ldapsearch コマンドで登録済みの情報を検索できるので、作ったユーザーとグループの内容を確認してみます。

$ ldapsearch -x -b "dc=ldap,dc=centre,dc=com" -D "uid=admin,ou=people,dc=ldap,dc=centre,dc=com" -w password -H ldap://0.0.0.0:3890

# test-lldap, people, ldap.centre.com
dn: uid=test-lldap,ou=people,dc=ldap,dc=centre,dc=com
objectclass: inetOrgPerson
objectclass: posixAccount
objectclass: mailAccount
objectclass: person
uid: test-lldap
mail: test-lldap@example.com
givenname: test
sn: lldap
cn: test-lldap
createtimestamp: 2024-04-17T09:49:03.158678344+00:00
entryuuid: e5eaf923-9c83-3e1a-8594-7c3ac65b2b8d

# test-lldp-group, groups, ldap.centre.com
dn: cn=test-lldp-group,ou=groups,dc=ldap,dc=centre,dc=com
objectclass: groupOfUniqueNames
uid: test-lldp-group
cn: test-lldp-group
entryuuid: 6e1dcd5f-f195-3957-9b15-07a2599a792b

ユーザーは ou=people, グループは ou=groups に設定され、その他の objectClass 等の attribute が自動で追加されていることがわかります。

ユースケース

これだけではつまらないので今まで使ってきた他サービスと統合してみます。

Authelia

ユーザ管理の概念がある、かつ規模が大きい OSS では LDAP 連携機能が実装されているものが多く、ユーザやグループを LDAP サーバーで一元管理することが可能です。例えば 前回記事にした Authelia では デフォルトで LLDAP との統合機能があるためこちらを試してみます。

LLDAP の github
Authelia の Integration で設定例が記載されておりそれぞれ微妙に設定項目が異なっていますが、試したところ authelia の方の例でうまくいきました。
authelia の configuration.yml ファイルで LDAP サーバーの接続設定やユーザー・グループを検出するフィルター等を設定します。

configuration.yml
authentication_backend:
  #file:
  #  path: '/config/users_database.yml'
  #refresh_interval: 1m
  ldap:
    implementation: custom
    address: ldap://ldap.centre.com:3890
    timeout: 5s
    start_tls: false
    base_dn: "dc=ldap,dc=centre,dc=com"
    users_filter: "(&({username_attribute}={input})(objectClass=person))"
    additional_users_dn: "ou=people"
    additional_groups_dn: "ou=groups"
    attributes:
      username: "uid"
      display_name: 'displayName'
      mail: 'mail'
      group_name: 'cn'
      member_of: 'memberOf'
      distinguished_name: 'distinguishedName'
    groups_filter: "(member={dn})"
    user: "uid=admin,ou=people,dc=ldap,dc=centre,dc=com"
    password: 'password'

lldap 側の設定追加は不要です。
authelia コンテナを再起動後、 auth.example.com にアクセスし、先ほど作成した test-lldap ユーザーでログインできるようになります。これで前回の記事ではファイルで管理していたユーザーやグループ情報を LDAP 側で一元管理できるようになります。もちろん Gitea などの他サービスへのシングルサインオンもこのユーザーが利用できます。

ロールマッピング (Grafana)

ユーザー管理の概念がある OSS の中には固有のロールを用いてアクセス制御を行うアプリケーションもあります。例えばモニタリングで有名な Grafana では Admin, Editor, Viewer の 3 つのロールがあり、このロールに基づいてアクセス可能なリソースが制限されています。

https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/

このようなアプリケーションでは、LDAP 側のグループや組織に基づいてユーザー登録時に特定のロールにマッピングする機能に対応しているものもあります。Grafana では role mapping として、ユーザーが属する group 名に基づいて指定したロールを割り当てることができます。こちらも試してみます。

まず Grafana の Admin, Editor, Viewer に対応する以下のグループを LLDAP 側で作成し、それぞれのグループに対応するユーザーを作ってグループに追加します。

group user
grafana_admin common_admin
grafana_editor common_developer
grafana_viewer test_lldap

authelia は grafana との OIDC 統合例 があるのでこれを参考に client 設定を記述。

configuration.yml
identity_providers:
  oidc:
    jwks:
      - key_id: "authelia"
        algorithm: "RS256"
        use: "sig"
        key: |
          -----BEGIN PRIVATE KEY-----
          ...
          -----END PRIVATE KEY-----
    clients:
      - client_id: 'grafana'
        client_name: 'grafana'
        client_secret: '$pbkdf2-sha512$310000$vdSjQwKQ/ohm9ikh04bE8g$SuJdRpgPRCdLJSQzgWGctv2LE5/XVR6YCSA6ARUNhRHa8N7X4wJJnBwEp4B0ir9vJTKyeksYyOfWTQHjg7b2yQ'
        public: false
        authorization_policy: 'one_factor'
        redirect_uris:
          - 'https://grafana.centre.com:3100/login/generic_oauth'
        scopes:
          - "openid"
          - "profile"
          - "groups"
          - "email"
        require_pkce: true
        pkce_challenge_method: 'S256'
        userinfo_signed_response_alg: 'none'
        token_endpoint_auth_method: 'client_secret_basic'

次に docker で grafana を構築するための docker-compose.yml を用意。

docker-compose.yml
services:
  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    restart: always
    ports:
      - '3100:3100'
    volumes:
      - ./data:/var/lib/grafana'
      - ./grafana.ini:/etc/grafana/grafana.ini
      - ./grafana.centre.com.crt:/usr/share/grafana/grafana.centre.com.crt
      - ./grafana.centre.com.key:/usr/share/grafana/grafana.centre.com.key
    extra_hosts:
      - "auth.example.com:192.168.3.18"

grafana は grafana.ini に設定を記載する形式なのでこの中に Authelia の OIDC 設定を記載。authelia 固有の設定はないので Set up OAuth2 with Auth0 の設定を参考に各項目を埋めていく。
ロールマッピングに関しては role_attribute_path に LDAP 側の group 名を見て以下のようにマッピングするようフィルターを指定する。

  • ユーザーが admin を含むグループに属している場合は Admin ロールを付加。
  • ユーザーが editor を含むグループに属している場合は Editor ロールを付加。
  • それ以外は Viewer ロールを付加。
grafana.ini
[server]
protocol = https
http_port = 3100
domain = grafana.centre.com
root_url = %(protocol)s://%(domain)s:%(http_port)s/
cert_file = /usr/share/grafana/grafana.centre.com.crt
cert_key = /usr/share/grafana/grafana.centre.com.key

[security]
cookie_secure = true

[auth.generic_oauth]
enabled = true
name = Authelia
client_id = grafana
client_secret = grafana
scopes = openid profile email groups
empty_scopes = false
auth_url = https://auth.example.com/api/oidc/authorization
token_url = https://auth.example.com/api/oidc/token
api_url = https://auth.example.com/api/oidc/userinfo
role_attribute_path = (groups[?contains(@, 'admin') == `true`]) && 'Admin' || (groups[?contains(@, 'editor') == `true`]) && 'Editor' || 'Viewer'
role_attribute_strict = true
login_attribute_path = preferred_username
groups_attribute_path = groups
name_attribute_path = name
use_pkce = true
フィルターの決め方

grafana の role_attribute_path は ユーザーの SSO 時に authelia → grafana に渡される payload 内のフィールドに基づいて評価されます。authelia が渡す payload の中身は以下の方法で確認できます(これが正しい確認方法かは不明)。
まず role_attribute_path を指定せず grafana のログレベルを debug に設定して grafana を起動し、 authelia を使って SSO します。この時点では認証に成功してもロールにマッピングされませんが、grafana のログから Received id_token" raw_json というメッセージを探します。

logger=oauth.generic_oauth t=2024-04-16T16:30:04.990564104Z level=debug msg="Received id_token" raw_json="{"amr":["pwd"],"at_hash":"SuINR-_ghnlLcCsa6bcACw","aud":["grafana"],"auth_time":1713285001,"azp":"grafana","client_id":"grafana","email":"common_developer@example.com","email_verified":true,"exp":1713288604,"groups":["grafana_editor"],"iat":1713285004,"iss":"https://auth.example.com","jti":"afca52d5-06b0-488e-b77d-304eda66f607","name":"common_developer","preferred_username":"common_developer","rat":1713285001,"sub":"0abcf543-4a0c-4055-8909-fd05c2934199"}" data="Name: common_developer, Displayname: , Login: , Username: , Email: common_developer@example.com, Upn: , Attributes: map[]"

この中の raw_json が payload に対応しています。json に整形したものが以下。

payload
{
  "amr": [
    "pwd"
  ],
  "at_hash": "SuINR-_ghnlLcCsa6bcACw",
  "aud": [
    "grafana"
  ],
  "auth_time": 1713285001,
  "azp": "grafana",
  "client_id": "grafana",
  "email": "common_developer@example.com",
  "email_verified": true,
  "exp": 1713288604,
  "groups": [
    "grafana_editor"
  ],
  "iat": 1713285004,
  "iss": "https://auth.example.com",
  "jti": "afca52d5-06b0-488e-b77d-304eda66f607",
  "name": "common_developer",
  "preferred_username": "common_developer",
  "rat": 1713285001,
  "sub": "0abcf543-4a0c-4055-8909-fd05c2934199"
}

これより最上位のフィールドに groups があり、その下に list 形式でユーザが属するグループ (LLDAP 側のグループ)が設定されることがわかります。あとはこの条件にマッチするように JMESPath の書き方など見ながら expression を作成すれば ok。今回のグループをロールにマッピングする条件を満たす expression は以下のようになります。

role_attribute_path = (groups[?contains(@, 'admin') == `true`]) && 'Admin' || (groups[?contains(@, 'editor') == `true`]) && 'Editor' || 'Viewer'

grafana の web grafana.centre.com:3100 にアクセスすると通常の user/password のログインに加えて
SSO 用の Sign in with Authelia ボタンが追加されています。これを押すと Gitea の時と同様に authelia にリダイレクトされ、LDAP 側のユーザー情報で grafana にログインできます。

common_admin, common_developer, test_lldap ユーザーでそれぞれログインすると、LDAP 側のユーザー名やメールアドレスの情報が grafana 側に登録されます。初期ユーザの admin でログインして administration > Users and access > Users > Organization users を見ると、各ユーザーが登録され、想定通りユーザーのグループに基づいたロールがマッピングされていることが確認できます。

前回の記事でみたように authelia ではユーザーのグループに基づいたアクセスコントロールが設定できますが、Role mapping に対応しているアプリケーションではアプリケーション側のロールでユーザーの permissions を管理することもできます。実際どちらで管理するのが良いかは LDAP 内のユーザー・グループの規模や運用方針にもよりますが、アクセスコントロールをいちいち設定する手間が省けるのでロールを使う方が管理は楽になりそうです。

Vault との連携

以前の記事で紹介した secret management の vault も LDAP と連携できます。LDAP との連携機能は主に 2 種類あります。

  • Vault の認証に LDAP 側のユーザー情報を使用する。
  • LDAP に作成したユーザーのパスワードを Vault で管理する(ローテートする)

1 つ目に関しては LDAP auth method を使用した方法となります。hashicorp の日本語 workshop があるので詳細はこちらを参照。

2 つ目は LDAP Secret Engine を使って LDAP に作成したユーザーのパスワードを定期的にローテートすることで漏洩時のリスクを軽減する方法となっています。以下にチュートリアルがあるので試してみます。
https://developer.hashicorp.com/vault/tutorials/secrets-management/openldap

検証のため dev mode で vault を起動する docker-compose.yml を用意。

docker-compose.yml
services:
  vault:
    container_name: vault
    image: hashicorp/vault
    ports:
      - 8200:8200
    cap_add:
      - IPC_LOCK
    command: server -dev -dev-root-token-id="00000000-0000-0000-0000-000000000000"
    extra_hosts:
      - "ldap.centre.com:192.168.3.18"
    environment:
      VAULT_DEV_ROOT_TOKEN_ID: '00000000-0000-0000-0000-000000000000'
      VAULT_TOKEN: "0"

vault CLI の接続先を設定。

export VAULT_ADDR="http://192.168.3.18:8200"
export VAULT_TOKEN="00000000-0000-0000-0000-000000000000"

ドキュメントでは各種 policy の設定を行ってますが、今回は root token で接続するため不要です。他の認証方法では適切に設定する必要があります。
まずは LDAP secret engine を有効化。

$ vault secrets enable ldap
Success! Enabled the ldap secrets engine at: ldap/

binddn は LDAP の初期ユーザー admin の dn と パスワードを指定。

$ vault write ldap/config \
    binddn=uid=admin,ou=people,dc=ldap,dc=centre,dc=com \
    bindpass=password \
    url=ldap://192.168.3.18:3890

Success! Data written to: ldap/config

パスワードのローテーションを行う LDAP 側ユーザーと Vault 側ロールのマッピングを行う。ここでは対象ユーザーは test-lldap、ロール名は test_lldap_role とします。rotation_period はパスワードのローテーション間隔となります。ここでは検証のため 1 分に設定。

$ vault write ldap/static-role/test-lldap-role \
    dn='uid=test-lldap,ou=people,dc=ldap,dc=centre,dc=com' \
    username='test-lldap' \
    rotation_period="1m"

Success! Data written to: ldap/static-role/test-lldap-role

これで設定が完了し、1 分毎に test-lldap のパスワードが自動でローテーションするようになりました。
このユーザーでログインするためにパスワードを取得するには vault read ldap/static-cred/test-lldap-role を実行します。

$  vault read ldap/static-cred/test-lldap-role
Key                    Value
---                    -----
dn                     uid=test-lldap,ou=people,dc=ldap,dc=centre,dc=com
last_password          n/a
last_vault_rotation    2024-04-17T16:37:34.796780852Z
password               LBWXDoW4QlbUbvGEKzo7xIotnh9bilEkaRQmhEaNxlONWJMd5N1yPF58g2kbFoej
rotation_period        1m
ttl                    2s
username               test-lldap

返り値の password が一時的なパスワードとなっています。このユーザーとパスワードを bind に指定して ldapsearch を実行すると search result が取得できるため、ログインに成功していることが確認できます。

$ ldapsearch -x -b "dc=ldap,dc=centre,dc=com" -D "uid=test-lldap,ou=people,dc=ldap,dc=centre,dc=com" -w LBWXDoW4QlbUbvGEKzo7xIotnh9bilEkaRQmhEaNxlONWJMd5N1yPF58g2kbFoej -H ldap://0.0.0.0:3890

...

# search result
search: 2
result: 0 Success

# numResponses: 3
# numEntries: 2

パスワードの有効期限 = rotation_period (= 1 分) なので、少し時間が経った後に同じコマンドを実行すると認証に失敗します。

$ ldapsearch -x -b "dc=ldap,dc=centre,dc=com" -D "uid=test-lldap,ou=people,dc=ldap,dc=centre,dc=com" -w LBWXDoW4QlbUbvGEKzo7xIotnh9bilEkaRQmhEaNxlONWJMd5N1yPF58g2kbFoej -H ldap://0.0.0.0:3890
ldap_bind: Invalid credentials (49)

内部的には rotation_period の時間毎に ldap/config に指定した bind user で LDAP 側に接続し、対象ユーザーのパスワードを更新している動作となっています。LLDAP で verbose ログを有効にしてログを辿っていくことでこのリクエストを確認できます。

LLDAP のログ
INFO     LDAP session [ 166ms | 0.04% / 100.00% ]
INFO     ┝━ LDAP request [ 90.7ms | 0.10% / 54.54% ]
DEBUG    │  ┝━ 🐛 [debug]:  | msg: LdapMsg { msgid: 1, op: BindRequest(LdapBindRequest { dn: "uid=admin,ou=people,dc=ldap,dc=centre,dc=com", cred: LdapBindCred::Simple }), ctrl: [] }
DEBUG    │  ┝━ do_bind [ 90.6ms | 0.04% / 54.44% ] dn: uid=admin,ou=people,dc=ldap,dc=centre,dc=com    # ldap/config に設定した bind user (=admin) で bind している
DEBUG    │  │  ┝━ bind [ 90.3ms | 0.01% / 54.31% ]
...
DEBUG    │  │  ┕━ 🐛 [debug]: Success!
DEBUG    │  ┕━ 🐛 [debug]:  | response: BindResponse(LdapBindResponse { res: LdapResult { code: Success, matcheddn: "", message: "", referral: [] }, saslcreds: None })
INFO     ┕━ LDAP request [ 75.6ms | 45.22% / 45.42% ]
DEBUG       ┝━ 🐛 [debug]:  | msg: LdapMsg { msgid: 2, op: ModifyRequest(LdapModifyRequest { dn: "uid=test-lldap,ou=people,dc=ldap,dc=centre,dc=com", changes: [LdapModify { operation: Replace, modification: LdapPartialAttribute { atype: "userPassword", vals: ["********"] } }] }), ctrl: [] }    # test-lldap に対してパスワード (userPassword) を更新するリクエスト
DEBUG       ┝━ get_user_groups [ 137µs | 0.08% ] user_id: "test-lldap"
DEBUG       │  ┕━ 🐛 [debug]:  | return: {GroupDetails { group_id: GroupId(10), display_name: "grafana_viewer", creation_date: 2024-04-17T14:31:56.051949721, uuid: Uuid("60b422cc-657a-3762-a93a-6942a61a8785"), attributes: [] }}
DEBUG       ┝━ registration_start [ 54.3µs | 0.03% ]
DEBUG       ┝━ registration_finish [ 137µs | 0.08% ]
DEBUG       ┕━ 🐛 [debug]:  | response: ModifyResponse(LdapResult { code: Success, matcheddn: "", message: "", referral: [] })    # リクエストが完了

以上で LDAP Secret Engine で LDAP ユーザーのパスワードをローテートする動作が確認できました。
実際のところ開発環境などのユーザーで vault read ldap/static-cred/test-lldap-role でパスワードを取得してログイン、というのを何度も繰り返すのは面倒なので、頻繁に使用するユーザーであればここまで厳密に管理する必要もなさそうな印象はあります(パスワードを適切に管理しているという前提ですが)。ただ本番環境などセキュリティが重視される環境では、rotation_period を適切に設定した上でローテートしてパスワード漏洩時のリスクを下げるというのは有用だと感じました。

おわりに

LLDAP を使って OIDC と連携する動作などを確認しました。
環境構築が簡単で UI から手軽にユーザーやグループを追加できるので、非常に大規模な環境だったり複雑な運用を行わないのであれば機能としては十分そうです。一方で LDAP の ou を任意に設定する、ou の入れ子構造を設定する、ユーザーやグループに独自の attribute を指定するといったことはできないので、それらが必要なユースケースでは他の LDAP サーバーの使用を考える必要があります。
また、参照すべき情報がコンパクトにまとまっていて学習コストが少ない点も良さそうです。例えば OpenLDAP のドキュメント は現代からしてみればレガシー感があり今から読み進めるのはなかなかつらい面がありますが、LLDAP は Github の README の内容ぐらいしか読むべきところがなく(あとは config ファイルの設定項目 ぐらい)、すぐに環境構築して使えるのがメリットとなっています。本番環境での運用ほど本格的に使いこなす必要がなくさくっと試したい場合におすすめです。

Discussion