This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
The following commit(s) were added to refs/heads/master by this push:
new a0d55a9ab add Ansible playbook for linux node enrollment with COmanage
LDAP and CILogon device-flow SSH auth
a0d55a9ab is described below
commit a0d55a9ab3c539e1ec0c4654ee9a8a7bfbefe759
Author: lahiruj <[email protected]>
AuthorDate: Fri Mar 20 18:08:43 2026 -0400
add Ansible playbook for linux node enrollment with COmanage LDAP and
CILogon device-flow SSH auth
---
deployment/account-provisioning/README.md | 151 ++++++++++
deployment/account-provisioning/enroll-node.yml | 334 +++++++++++++++++++++
.../account-provisioning/files/pam_oauth2_sshd.te | 45 +++
.../group_vars/all.yml.example | 90 ++++++
.../inventory/hosts.example.yml | 49 +++
.../templates/99-pam-oauth2-device.conf.j2 | 28 ++
.../templates/pam-oauth2-config.json.j2 | 46 +++
.../account-provisioning/templates/sssd.conf.j2 | 59 ++++
deployment/account-provisioning/verify.yml | 137 +++++++++
9 files changed, 939 insertions(+)
diff --git a/deployment/account-provisioning/README.md
b/deployment/account-provisioning/README.md
new file mode 100644
index 000000000..ecf580260
--- /dev/null
+++ b/deployment/account-provisioning/README.md
@@ -0,0 +1,151 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# HPC Node Enrollment — Account Provisioning
+
+Ansible playbook to enroll RHEL 9/10 nodes for COmanage-provisioned LDAP user
resolution and CILogon device-flow SSH authentication.
+
+## Architecture
+
+This setup uses two independent systems that each answer one question:
+
+| System | Question | Technology |
+|--------|----------|------------|
+| **SSSD/LDAP** | "Does this Unix account exist?" | SSSD →
COmanage-provisioned LDAP (posixAccount) |
+| **OIDC** | "Is this the right human?" | pam_oauth2_device → CILogon device
authorization flow |
+
+**Key design decisions:**
+
+- **Sub→uid mapping via LDAP, not PAM config.** The SSH username is always a
Unix username. The PAM module maps the CILogon `sub` to a local `uid` via an
LDAP lookup (`voPersonExternalID` → `uid`) to verify the authenticated identity
matches the login user. No static mapping or PAM-level account resolution is
needed.
+- **Authentication then authorization.** The device flow always starts. After
the user authenticates via CILogon, the PAM module's LDAP lookup verifies the
`sub` maps to the SSH login username. If the user does not exist in LDAP, this
lookup fails and login is denied.
+- **Revocation via COmanage.** When COmanage sets a person inactive, the LDAP
entry is removed, SSSD stops resolving the user, and SSH login fails
automatically. No SSH config changes needed.
+
+```
+SSH login flow:
+ 1. ssh jdoe@node
+ 2. PAM auth → pam_oauth2_device → CILogon device flow
+ 3. User completes browser auth
+ 4. PAM module queries LDAP: voPersonExternalID=<sub> → uid
+ 5. Returned uid matches "jdoe"? No → reject. Yes → continue.
+ 6. SSSD resolves "jdoe" → LDAP (posixAccount lookup)
+ 7. pam_mkhomedir creates /home/jdoe if first login
+```
+
+## Prerequisites
+
+1. **LDAP server** with COmanage-provisioned entries having both
`posixAccount` objectClass and `voPersonExternalID` attribute (from [voPerson
schema](https://refeds.org/specifications/voperson)). The PAM module maps
CILogon subjects to local usernames via the LDAP filter
`(&(objectClass=posixAccount)(voPersonExternalID=%s))`. See [LDAP Schema
Requirements](#ldap-schema-requirements) for details on required schemas.
+2. **CILogon OIDC client** — must be a **confidential client** (with a client
secret), not a public client.
+3. **Ansible** 2.14+ on your control node.
+4. **RHEL 9/10** target nodes with SSH and sudo access.
+5. Target nodes must be able to reach the LDAP server (port 636) and CILogon
(port 443).
+
+### LDAP Schema Requirements
+
+If you're setting up a fresh LDAP server for COmanage, these schemas must be
imported. COmanage's LDAP provisioning plugin writes entries using these
objectClasses.
+
+| Schema | Type | Purpose |
+|--------|------|---------|
+| **voPerson** | AUXILIARY | COmanage person attributes. Critical:
`voPersonExternalID` stores the CILogon subject URI used for OIDC sub→Unix uid
mapping |
+| **eduMember** | AUXILIARY | Group membership (`isMemberOf`, `hasMember`) |
+| **posixAccount** | STRUCTURAL | Unix account attributes (`uidNumber`,
`gidNumber`, `homeDirectory`, `loginShell`). Required for SSSD |
+| **posixGroup** | STRUCTURAL | Unix group attributes |
+| **inetOrgPerson** | STRUCTURAL | Base person object (`cn`, `sn`, `mail`,
`uid`) |
+
+## Quick Start
+
+```bash
+cd deployment/account-provisioning
+
+# 1. Configure
+cp inventory/hosts.example.yml inventory/hosts.yml
+cp group_vars/all.yml.example group_vars/all.yml
+# Edit both files with your values
+
+# 2. Encrypt secrets
+ansible-vault encrypt_string 'your-ldap-password' --name
'vault_ldap_bind_password' >> group_vars/all.yml
+ansible-vault encrypt_string 'your-cilogon-secret' --name
'vault_cilogon_client_secret' >> group_vars/all.yml
+
+# 3. Test LDAP connectivity first
+ansible-playbook -i inventory/hosts.yml enroll-node.yml --tags prereqs
--ask-vault-pass
+
+# 4. Full enrollment
+ansible-playbook -i inventory/hosts.yml enroll-node.yml --ask-vault-pass
+
+# 5. Verify
+ansible-playbook -i inventory/hosts.yml verify.yml --ask-vault-pass
+```
+
+All variables are documented with inline comments in
`group_vars/all.yml.example`.
+
+## What the Playbook Does
+
+The playbook has 4 tagged phases that can be run independently with `--tags`:
+
+| Tag | Phase | Key detail |
+|-----|-------|------------|
+| `prereqs` | Read-only LDAP connectivity check | Fails fast if LDAP is
unreachable. No files modified. |
+| `sssd` | SSSD + NSS + mkhomedir | Configures `auth_provider = none` — PAM
handles authentication, SSSD only resolves identity. Sets SELinux boolean
`authlogin_nsswitch_use_ldap`. |
+| `pam` | Build pam_oauth2_device, configure PAM stack, SELinux policy |
Builds from source (cyber-shuttle fork). Installs SELinux policy allowing
`sshd_t` to connect to CILogon on port 443 — without it, auth silently falls
through to password-auth. |
+| `sshd` | Keyboard-interactive auth config | Drops
`99-pam-oauth2-device.conf` into `sshd_config.d/` — the `99-` prefix ensures it
overrides RHEL defaults. |
+
+## Verification
+
+```bash
+# End-to-end SSH tests (verify.yml checks services/config, these test actual
login):
+ssh jdoe@<node> # should show device code prompt
+ssh fakeuser@<node> # device flow starts, but login fails after
auth (LDAP lookup finds no match)
+
+# Check effective sshd config:
+sudo sshd -T | egrep -i
'usepam|kbdinteractiveauthentication|passwordauthentication|challengeresponseauthentication'
+# Expected: all "yes"
+```
+
+## Troubleshooting
+
+### SSSD won't start
+- Check permissions: `ls -la /etc/sssd/sssd.conf` — must be `0600` owned by
root
+- Check logs: `journalctl -u sssd --since "5 minutes ago"`
+- Clear cache and restart: `rm -rf /var/lib/sss/db/* && systemctl restart sssd`
+
+### `getent passwd <user>` returns nothing
+- Verify LDAP connectivity: re-run `--tags prereqs`
+- Enable SSSD debug: add `debug_level = 6` under `[domain/...]` in sssd.conf,
restart, check `/var/log/sssd/sssd_<domain>.log`
+
+### SSH shows no device code prompt
+- Check effective config: `sudo sshd -T | grep kbdinteractive` — if it shows
`no`, another file in `/etc/ssh/sshd_config.d/` is overriding it. The `99-`
prefix on our config ensures it loads last; verify the file exists and restart
sshd.
+
+### SELinux blocking CILogon requests
+The PAM module runs inside sshd and makes HTTPS calls to CILogon. SELinux's
`sshd_t` context blocks this by default.
+
+**Symptoms:** `journalctl -u sshd` shows `curl failed HTTP POST` or
`Authentication failure`, and `sealert -a /var/log/audit/audit.log` shows
`SELinux is preventing /usr/sbin/sshd from name_connect access on the
tcp_socket port 443`.
+
+**Fix:** Re-run `--tags pam` to reinstall the SELinux policy. For manual
diagnosis:
+```bash
+sudo dnf install -y setroubleshoot-server
+sudo sealert -a /var/log/audit/audit.log
+```
+
+### CILogon auth fails with missing claims
+Your CILogon client is likely a **public** client. See
[Prerequisites](#prerequisites) — a confidential client with a client secret is
required.
+
+## Operational Notes
+
+- **LDAP outage:** SSSD caches credentials (`cache_credentials = true`), so
previously-resolved users can still log in when LDAP is temporarily unreachable.
+- **PAM config:** The playbook replaces `/etc/pam.d/sshd` entirely. Back up
the existing file before running on nodes with custom PAM configurations.
+- **pam_oauth2_device version:** The playbook builds from the latest commit of
the configured git repo. Pin to a specific tag by adding `version:` to the git
task in `enroll-node.yml` for production stability.
diff --git a/deployment/account-provisioning/enroll-node.yml
b/deployment/account-provisioning/enroll-node.yml
new file mode 100644
index 000000000..5a2075645
--- /dev/null
+++ b/deployment/account-provisioning/enroll-node.yml
@@ -0,0 +1,334 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+---
+# =============================================================================
+# Account Provisioning — Node Enrollment Playbook
+# =============================================================================
+#
+# Enrolls Linux nodes for COmanage-provisioned LDAP user resolution (SSSD)
+# and CILogon device-flow SSH authentication (pam_oauth2_device).
+#
+# Tested on: RHEL/Rocky 9, RHEL/Rocky 10
+#
+# Usage:
+# ansible-playbook -i inventory/hosts.yml enroll-node.yml #
full run
+# ansible-playbook -i inventory/hosts.yml enroll-node.yml --tags prereqs #
LDAP check only
+# ansible-playbook -i inventory/hosts.yml enroll-node.yml --tags sssd #
SSSD only
+# ansible-playbook -i inventory/hosts.yml enroll-node.yml --tags pam #
PAM module only
+# ansible-playbook -i inventory/hosts.yml enroll-node.yml --tags sshd #
SSHD config only
+#
+# Requires: Ansible 2.14+, LDAP server with posixAccount entries
+
+- name: Enroll node for COmanage LDAP + CILogon SSH authentication
+ hosts: target_nodes
+ become: true
+ gather_facts: true
+
+ # =========================================================================
+ # prereqs — Verify LDAP connectivity (fail-fast, read-only)
+ # =========================================================================
+ tasks:
+ - name: "[prereqs] Install openldap-clients for LDAP checks"
+ dnf:
+ name: openldap-clients
+ state: present
+ tags: [prereqs]
+
+ - name: "[prereqs] Check LDAP TLS connectivity"
+ shell: >
+ echo "" | openssl s_client
+ -connect {{ ldap_uri | regex_replace('^ldaps://', '') }}:636
+ -servername {{ ldap_uri | regex_replace('^ldaps://', '') }}
+ 2>/dev/null | openssl x509 -noout -subject -dates
+ register: ldap_tls_check
+ changed_when: false
+ failed_when: ldap_tls_check.rc != 0
+ tags: [prereqs]
+
+ - name: "[prereqs] TLS certificate info"
+ debug:
+ msg: "{{ ldap_tls_check.stdout_lines }}"
+ tags: [prereqs]
+
+ - name: "[prereqs] Verify LDAP bind credentials"
+ command: >
+ ldapwhoami -x
+ -H {{ ldap_uri }}:636
+ -D "{{ ldap_bind_dn }}"
+ -w "{{ ldap_bind_password }}"
+ register: ldap_bind_check
+ changed_when: false
+ failed_when: ldap_bind_check.rc != 0
+ no_log: true
+ tags: [prereqs]
+
+ - name: "[prereqs] Verify LDAP search base is reachable"
+ command: >
+ ldapsearch -LLL -x
+ -H {{ ldap_uri }}:636
+ -D "{{ ldap_bind_dn }}"
+ -w "{{ ldap_bind_password }}"
+ -b "{{ ldap_search_base }}"
+ -s base "(objectClass=*)" dn
+ register: ldap_base_check
+ changed_when: false
+ failed_when: ldap_base_check.rc != 0
+ no_log: true
+ tags: [prereqs]
+
+ - name: "[prereqs] LDAP connectivity verified"
+ debug:
+ msg: >-
+ LDAP server {{ ldap_uri }} is reachable, bind succeeded, search base
exists.
+ Target OS: {{ ansible_distribution }} {{
ansible_distribution_version }}
+ tags: [prereqs]
+
+ # =========================================================================
+ # sssd — Install and configure SSSD for LDAP user resolution
+ # =========================================================================
+
+ - name: "[sssd] Install SSSD and LDAP client packages"
+ dnf:
+ name:
+ - sssd
+ - sssd-ldap
+ - oddjob
+ - oddjob-mkhomedir
+ - openldap-clients
+ state: present
+ tags: [sssd]
+
+ - name: "[sssd] Template /etc/sssd/sssd.conf"
+ template:
+ src: templates/sssd.conf.j2
+ dest: /etc/sssd/sssd.conf
+ owner: root
+ group: root
+ mode: "0600"
+ notify: restart sssd
+ tags: [sssd]
+
+ - name: "[sssd] Configure authselect for SSSD with mkhomedir"
+ command: authselect select sssd with-mkhomedir --force
+ register: authselect_result
+ changed_when: "'already' not in authselect_result.stdout | default('')"
+ tags: [sssd]
+
+ - name: "[sssd] authselect result"
+ debug:
+ msg: "{{ authselect_result.stdout_lines | default(['done']) }}"
+ tags: [sssd]
+
+ - name: "[sssd] Set SELinux boolean for LDAP NSS lookups"
+ seboolean:
+ name: authlogin_nsswitch_use_ldap
+ state: true
+ persistent: true
+ tags: [sssd]
+
+ - name: "[sssd] Enable and start oddjobd"
+ systemd:
+ name: oddjobd
+ enabled: true
+ state: started
+ tags: [sssd]
+
+ - name: "[sssd] Enable and start sssd"
+ systemd:
+ name: sssd
+ enabled: true
+ state: started
+ tags: [sssd]
+
+ - name: "[sssd] Flush SSSD cache to force fresh LDAP lookup"
+ command: sss_cache -E
+ changed_when: false
+ tags: [sssd]
+
+ - name: "[sssd] Validate — getent passwd {{ test_username }}"
+ command: "getent passwd {{ test_username }}"
+ register: sssd_validate
+ changed_when: false
+ failed_when: sssd_validate.rc != 0
+ tags: [sssd]
+
+ - name: "[sssd] Validation result"
+ debug:
+ msg: "{{ sssd_validate.stdout }}"
+ tags: [sssd]
+
+ # =========================================================================
+ # pam — Build and install pam_oauth2_device
+ # =========================================================================
+
+ - name: "[pam] Install build dependencies"
+ dnf:
+ name:
+ - "@Development Tools"
+ - pam-devel
+ - libcurl-devel
+ - openldap-devel
+ - pkgconfig
+ - git
+ state: present
+ tags: [pam]
+
+ - name: "[pam] Clone pam_oauth2_device repository"
+ git:
+ repo: "{{ pam_oauth2_device_repo }}"
+ dest: "{{ pam_oauth2_device_src_dir }}"
+ force: true
+ tags: [pam]
+
+ - name: "[pam] Build pam_oauth2_device"
+ make:
+ chdir: "{{ pam_oauth2_device_src_dir }}"
+ tags: [pam]
+
+ - name: "[pam] Install pam_oauth2_device.so"
+ copy:
+ src: "{{ pam_oauth2_device_src_dir }}/pam_oauth2_device.so"
+ dest: /usr/lib64/security/pam_oauth2_device.so
+ remote_src: true
+ owner: root
+ group: root
+ mode: "0755"
+ tags: [pam]
+
+ - name: "[pam] Create /etc/pam_oauth2_device directory"
+ file:
+ path: /etc/pam_oauth2_device
+ state: directory
+ owner: root
+ group: root
+ mode: "0755"
+ tags: [pam]
+
+ - name: "[pam] Template pam_oauth2_device config.json"
+ template:
+ src: templates/pam-oauth2-config.json.j2
+ dest: /etc/pam_oauth2_device/config.json
+ owner: root
+ group: root
+ mode: "0600"
+ tags: [pam]
+
+ - name: "[pam] Configure PAM stack for sshd"
+ copy:
+ dest: /etc/pam.d/sshd
+ owner: root
+ group: root
+ mode: "0644"
+ content: |
+ #%PAM-1.0
+ auth required pam_sepermit.so
+ auth sufficient pam_oauth2_device.so
/etc/pam_oauth2_device/config.json
+ auth substack password-auth
+ auth include postlogin
+ account required pam_sepermit.so
+ account required pam_nologin.so
+ account include password-auth
+ password include password-auth
+ # pam_selinux.so close should be the first session rule
+ session required pam_selinux.so close
+ session required pam_loginuid.so
+ # pam_selinux.so open should only be followed by sessions to be
executed in the user context
+ session required pam_selinux.so open env_params
+ session required pam_namespace.so
+ session optional pam_keyinit.so force revoke
+ session optional pam_motd.so
+ session include password-auth
+ session include postlogin
+ notify: restart sshd
+ tags: [pam]
+
+ - name: "[pam] Install SELinux policy development tools"
+ dnf:
+ name:
+ - checkpolicy
+ - policycoreutils
+ state: present
+ tags: [pam]
+
+ - name: "[pam] Copy SELinux type enforcement policy for sshd HTTPS"
+ copy:
+ src: files/pam_oauth2_sshd.te
+ dest: /tmp/pam_oauth2_sshd.te
+ mode: "0644"
+ tags: [pam]
+
+ - name: "[pam] Check if pam_oauth2_sshd SELinux module is already loaded"
+ shell: semodule -l | grep -q pam_oauth2_sshd
+ register: selinux_module_check
+ changed_when: false
+ failed_when: false
+ tags: [pam]
+
+ - name: "[pam] Compile and install SELinux policy for sshd HTTPS (CILogon)"
+ shell: |
+ checkmodule -M -m -o /tmp/pam_oauth2_sshd.mod /tmp/pam_oauth2_sshd.te
+ semodule_package -o /tmp/pam_oauth2_sshd.pp -m /tmp/pam_oauth2_sshd.mod
+ semodule -i /tmp/pam_oauth2_sshd.pp
+ when: selinux_module_check.rc != 0
+ tags: [pam]
+
+ # =========================================================================
+ # sshd — Configure SSHD for keyboard-interactive auth
+ # =========================================================================
+
+ - name: "[sshd] Template sshd_config.d override"
+ template:
+ src: templates/99-pam-oauth2-device.conf.j2
+ dest: /etc/ssh/sshd_config.d/99-pam-oauth2-device.conf
+ owner: root
+ group: root
+ mode: "0600"
+ notify: restart sshd
+ tags: [sshd]
+
+ - name: "[sshd] Validate sshd configuration"
+ command: /usr/sbin/sshd -t
+ changed_when: false
+ tags: [sshd]
+
+ - name: "[sshd] Verify effective sshd settings"
+ shell: >
+ sshd -T | grep -iE
+
'usepam|kbdinteractiveauthentication|passwordauthentication|challengeresponseauthentication'
+ register: sshd_effective
+ changed_when: false
+ tags: [sshd]
+
+ - name: "[sshd] Effective sshd configuration"
+ debug:
+ msg: "{{ sshd_effective.stdout_lines }}"
+ tags: [sshd]
+
+ # ===========================================================================
+ # Handlers
+ # ===========================================================================
+ handlers:
+ - name: restart sssd
+ systemd:
+ name: sssd
+ state: restarted
+
+ - name: restart sshd
+ systemd:
+ name: sshd
+ state: restarted
diff --git a/deployment/account-provisioning/files/pam_oauth2_sshd.te
b/deployment/account-provisioning/files/pam_oauth2_sshd.te
new file mode 100644
index 000000000..706b1b91a
--- /dev/null
+++ b/deployment/account-provisioning/files/pam_oauth2_sshd.te
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# SELinux type enforcement policy: allow sshd to make HTTPS connections
+#
+# Problem: pam_oauth2_device runs inside sshd (sshd_t context) and uses
+# libcurl to contact CILogon over HTTPS (port 443). SELinux's default
+# policy for sshd_t does not permit outbound TCP connections to http_port_t.
+# Without this module, auth silently falls through to password-auth.
+#
+# Discovered from AVC denial:
+# avc: denied { name_connect } for comm="sshd" dest=443
+# scontext=system_u:system_r:sshd_t:s0
+# tcontext=system_u:object_r:http_port_t:s0
+# tclass=tcp_socket
+#
+# Build:
+# checkmodule -M -m -o pam_oauth2_sshd.mod pam_oauth2_sshd.te
+# semodule_package -o pam_oauth2_sshd.pp -m pam_oauth2_sshd.mod
+# semodule -i pam_oauth2_sshd.pp
+
+module pam_oauth2_sshd 1.0;
+
+require {
+ type sshd_t;
+ type http_port_t;
+ class tcp_socket name_connect;
+}
+
+#============= sshd_t ==============
+allow sshd_t http_port_t:tcp_socket name_connect;
diff --git a/deployment/account-provisioning/group_vars/all.yml.example
b/deployment/account-provisioning/group_vars/all.yml.example
new file mode 100644
index 000000000..9618de85a
--- /dev/null
+++ b/deployment/account-provisioning/group_vars/all.yml.example
@@ -0,0 +1,90 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+---
+# =============================================================================
+# Account Provisioning — Configuration Variables
+# =============================================================================
+# Copy this file to group_vars/all.yml and fill in your values.
+# Secrets should use Ansible Vault: ansible-vault encrypt_string 'secret'
+
+# -----------------------------------------------------------------------------
+# LDAP — COmanage-provisioned LDAP server
+# -----------------------------------------------------------------------------
+
+# LDAPS URI (must be ldaps:// for TLS)
+ldap_uri: "ldaps://xxx.xxx"
+
+# Base DN where posixAccount entries live
+ldap_search_base: "ou=people,o=xxx,o=CO,dc=xxx,dc=xxx,dc=xxx"
+
+# Bind DN for SSSD to authenticate to LDAP
+ldap_bind_dn: "uid=registry_user,ou=system,o=xxx,o=CO,dc=xxx,dc=xxx,dc=xxx"
+
+# Bind password — use ansible-vault to encrypt this value
+# Example: ansible-vault encrypt_string 'your-password' --name
'ldap_bind_password'
+ldap_bind_password: "{{ vault_ldap_bind_password }}"
+
+# TLS CA certificate bundle on the target node
+ldap_tls_cacert: "/etc/pki/tls/certs/ca-bundle.crt"
+
+# -----------------------------------------------------------------------------
+# CILogon OAuth2 — Device authorization flow
+# -----------------------------------------------------------------------------
+
+# OIDC client credentials (must be a confidential client, not public)
+cilogon_client_id: "cilogon:/client_id/..."
+cilogon_client_secret: "{{ vault_cilogon_client_secret }}"
+
+# OAuth2 scopes — must include userinfo for username_attribute lookup
+cilogon_scope: "openid profile email org.cilogon.userinfo"
+
+# -----------------------------------------------------------------------------
+# SSSD
+# -----------------------------------------------------------------------------
+
+# Domain name used in sssd.conf [domain/<name>]
+sssd_domain_name: "xxx"
+
+# -----------------------------------------------------------------------------
+# pam_oauth2_device
+# -----------------------------------------------------------------------------
+
+# Git repo to clone for building pam_oauth2_device
+pam_oauth2_device_repo:
"https://github.com/cyber-shuttle/pam_oauth2_device.git"
+
+# Where to clone the source on the target
+pam_oauth2_device_src_dir: "/usr/local/src/pam_oauth2_device"
+
+# LDAP settings for pam_oauth2_device user lookup
+# This lets the PAM module verify the SSH username exists in LDAP
+pam_oauth2_ldap_host: "{{ ldap_uri }}:636"
+pam_oauth2_ldap_basedn: "{{ ldap_search_base }}"
+pam_oauth2_ldap_user: "{{ ldap_bind_dn }}"
+pam_oauth2_ldap_passwd: "{{ ldap_bind_password }}"
+pam_oauth2_ldap_filter: "(&(objectClass=posixAccount)(voPersonExternalID=%s))"
+pam_oauth2_ldap_attr: "uid"
+
+# Enable debug logging in pam_oauth2_device (set false for production)
+pam_oauth2_debug: false
+
+# -----------------------------------------------------------------------------
+# Validation
+# -----------------------------------------------------------------------------
+
+# A known LDAP username to verify SSSD resolution works
+test_username: "{{ test_username }}"
diff --git a/deployment/account-provisioning/inventory/hosts.example.yml
b/deployment/account-provisioning/inventory/hosts.example.yml
new file mode 100644
index 000000000..45328c075
--- /dev/null
+++ b/deployment/account-provisioning/inventory/hosts.example.yml
@@ -0,0 +1,49 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+---
+# =============================================================================
+# Account Provisioning — Inventory
+# =============================================================================
+# Copy to inventory/hosts.yml and update with your target nodes.
+#
+# Usage:
+# ansible-playbook -i inventory/hosts.yml playbook.yml
+
+all:
+ children:
+ target_nodes:
+ hosts:
+ # Single node example
+ login-node-1:
+ ansible_host: 10.0.1.50
+ ansible_user: user
+ # ansible_ssh_private_key_file: ~/.ssh/my-key.pem
+
+ # Additional nodes — uncomment to add
+ # login-node-2:
+ # ansible_host: 10.0.1.51
+ # ansible_user: user
+
+ # login-node-3:
+ # ansible_host: 10.0.2.10
+ # ansible_user: user
+
+ vars:
+ # All target_nodes share these defaults (can be overridden per host)
+ ansible_become: true
+ ansible_become_method: sudo
diff --git
a/deployment/account-provisioning/templates/99-pam-oauth2-device.conf.j2
b/deployment/account-provisioning/templates/99-pam-oauth2-device.conf.j2
new file mode 100644
index 000000000..f3baae21d
--- /dev/null
+++ b/deployment/account-provisioning/templates/99-pam-oauth2-device.conf.j2
@@ -0,0 +1,28 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Managed by Ansible — do not edit manually
+# RHEL9 sshd_config.d override for pam_oauth2_device (CILogon device flow)
+#
+# RHEL9 ships default configs in /etc/ssh/sshd_config.d/ that may disable
+# KbdInteractiveAuthentication. This file loads last (99-) to override them.
+
+Match all
+ UsePAM yes
+ ChallengeResponseAuthentication yes
+ KbdInteractiveAuthentication yes
+ PasswordAuthentication yes
diff --git
a/deployment/account-provisioning/templates/pam-oauth2-config.json.j2
b/deployment/account-provisioning/templates/pam-oauth2-config.json.j2
new file mode 100644
index 000000000..8ff3a0dad
--- /dev/null
+++ b/deployment/account-provisioning/templates/pam-oauth2-config.json.j2
@@ -0,0 +1,46 @@
+{# Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License. #}
+{
+{% if pam_oauth2_debug | default(false) %}
+ "client_debug": true,
+{% endif %}
+ "oauth": {
+ "client": {
+ "id": "{{ cilogon_client_id }}",
+ "secret": "{{ cilogon_client_secret }}"
+ },
+ "device_authorization_endpoint":
"https://cilogon.org/oauth2/device_authorization",
+ "device_endpoint":
"https://cilogon.org/oauth2/device_authorization",
+ "token_endpoint": "https://cilogon.org/oauth2/token",
+ "userinfo_endpoint": "https://cilogon.org/oauth2/userinfo",
+ "verification_uri": "https://cilogon.org/device/",
+ "scope": "{{ cilogon_scope }}",
+ "require_mfa": false,
+ "username_attribute": "sub",
+ "local_username_suffix": ""
+ },
+ "ldap": {
+ "host": "{{ pam_oauth2_ldap_host }}",
+ "basedn": "{{ pam_oauth2_ldap_basedn }}",
+ "user": "{{ pam_oauth2_ldap_user }}",
+ "passwd": "{{ pam_oauth2_ldap_passwd }}",
+ "filter": "{{ pam_oauth2_ldap_filter }}",
+ "attr": "{{ pam_oauth2_ldap_attr }}"
+ },
+ "qr": { "show": false, "error_correction_level": 1 },
+ "tls": { "ca_bundle": "{{ ldap_tls_cacert }}" }
+}
diff --git a/deployment/account-provisioning/templates/sssd.conf.j2
b/deployment/account-provisioning/templates/sssd.conf.j2
new file mode 100644
index 000000000..234bf2810
--- /dev/null
+++ b/deployment/account-provisioning/templates/sssd.conf.j2
@@ -0,0 +1,59 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Managed by Ansible — do not edit manually
+# SSSD configuration for COmanage-provisioned LDAP user resolution
+
+[sssd]
+services = nss, pam
+domains = {{ sssd_domain_name }}
+config_file_version = 2
+
+[nss]
+filter_groups = root
+filter_users = root
+reconnection_retries = 3
+
+[pam]
+
+[domain/{{ sssd_domain_name }}]
+id_provider = ldap
+auth_provider = none
+access_provider = permit
+
+ldap_uri = {{ ldap_uri }}
+ldap_search_base = {{ ldap_search_base }}
+
+ldap_default_bind_dn = {{ ldap_bind_dn }}
+ldap_default_authtok_type = password
+ldap_default_authtok = {{ ldap_bind_password }}
+
+ldap_schema = rfc2307
+ldap_user_object_class = posixAccount
+ldap_group_object_class = posixGroup
+
+ldap_user_name = uid
+ldap_user_uid_number = uidNumber
+ldap_user_gid_number = gidNumber
+ldap_user_home_directory = homeDirectory
+ldap_user_shell = loginShell
+
+ldap_tls_reqcert = demand
+ldap_tls_cacert = {{ ldap_tls_cacert }}
+
+cache_credentials = true
+enumerate = false
diff --git a/deployment/account-provisioning/verify.yml
b/deployment/account-provisioning/verify.yml
new file mode 100644
index 000000000..f6a71cb03
--- /dev/null
+++ b/deployment/account-provisioning/verify.yml
@@ -0,0 +1,137 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+---
+# =============================================================================
+# Account Provisioning — Verification Playbook
+# =============================================================================
+#
+# Safe to run at any time. Makes no changes to the system.
+#
+# Usage:
+# ansible-playbook -i inventory/hosts.yml verify.yml
+
+- name: Verify node enrollment
+ hosts: target_nodes
+ become: true
+ gather_facts: false
+
+ tasks:
+ - name: "[verify] getent passwd {{ test_username }}"
+ command: "getent passwd {{ test_username }}"
+ register: verify_getent
+ changed_when: false
+ failed_when: false
+
+ - name: "[verify] getent result"
+ debug:
+ msg: "{{ verify_getent.stdout | default('FAILED — user not found') }}"
+
+ - name: "[verify] id {{ test_username }}"
+ command: "id {{ test_username }}"
+ register: verify_id
+ changed_when: false
+ failed_when: false
+
+ - name: "[verify] id result"
+ debug:
+ msg: "{{ verify_id.stdout | default('FAILED — user not found') }}"
+
+ - name: "[verify] SSSD service status"
+ command: systemctl is-active sssd
+ register: verify_sssd
+ changed_when: false
+ failed_when: false
+
+ - name: "[verify] SSSD status"
+ debug:
+ msg: "sssd is {{ verify_sssd.stdout | default('unknown') }}"
+
+ - name: "[verify] oddjobd service status"
+ command: systemctl is-active oddjobd
+ register: verify_oddjobd
+ changed_when: false
+ failed_when: false
+
+ - name: "[verify] oddjobd status"
+ debug:
+ msg: "oddjobd is {{ verify_oddjobd.stdout | default('unknown') }}"
+
+ - name: "[verify] sshd service status"
+ command: systemctl is-active sshd
+ register: verify_sshd
+ changed_when: false
+ failed_when: false
+
+ - name: "[verify] sshd status"
+ debug:
+ msg: "sshd is {{ verify_sshd.stdout | default('unknown') }}"
+
+ - name: "[verify] Effective sshd auth settings"
+ shell: >
+ sshd -T | grep -iE
+
'usepam|kbdinteractiveauthentication|passwordauthentication|challengeresponseauthentication|authenticationmethods'
+ register: verify_sshd_config
+ changed_when: false
+ failed_when: false
+
+ - name: "[verify] sshd effective config"
+ debug:
+ msg: "{{ verify_sshd_config.stdout_lines | default(['unable to read'])
}}"
+
+ - name: "[verify] pam_oauth2_device.so installed"
+ stat:
+ path: /usr/lib64/security/pam_oauth2_device.so
+ register: verify_pam_so
+
+ - name: "[verify] pam_oauth2_device.so"
+ debug:
+ msg: "{{ 'INSTALLED' if verify_pam_so.stat.exists else 'MISSING' }}"
+
+ - name: "[verify] pam_oauth2_device config.json exists"
+ stat:
+ path: /etc/pam_oauth2_device/config.json
+ register: verify_pam_config
+
+ - name: "[verify] config.json"
+ debug:
+ msg: "{{ 'EXISTS' if verify_pam_config.stat.exists else 'MISSING' }}"
+
+ - name: "[verify] Recent SSSD logs"
+ shell: 'journalctl -u sssd --since "5 minutes ago" --no-pager -l
2>/dev/null | tail -20'
+ register: verify_sssd_logs
+ changed_when: false
+ failed_when: false
+
+ - name: "[verify] SSSD journal (last 5 min)"
+ debug:
+ msg: "{{ verify_sssd_logs.stdout_lines | default(['no recent logs'])
}}"
+
+ # =========================================================================
+ # Summary
+ # =========================================================================
+ - name: "[verify] Enrollment summary"
+ debug:
+ msg:
+ - "=== Enrollment Verification Summary ==="
+ - "getent passwd {{ test_username }}: {{ 'PASS' if verify_getent.rc
== 0 else 'FAIL' }}"
+ - "id {{ test_username }}: {{ 'PASS' if verify_id.rc ==
0 else 'FAIL' }}"
+ - "sssd service: {{ verify_sssd.stdout |
default('UNKNOWN') }}"
+ - "oddjobd service: {{ verify_oddjobd.stdout |
default('UNKNOWN') }}"
+ - "sshd service: {{ verify_sshd.stdout |
default('UNKNOWN') }}"
+ - "pam_oauth2_device.so: {{ 'INSTALLED' if
verify_pam_so.stat.exists else 'MISSING' }}"
+ - "pam_oauth2_device config.json: {{ 'EXISTS' if
verify_pam_config.stat.exists else 'MISSING' }}"