railiance-infra/ansible/roles/ssh_ca_host/tasks/main.yml
codex 37ed8b012b
All checks were successful
CI Smoke / source-contract (push) Successful in 14s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
RAIL-HO-WP-0014: explicit allowed supplementary groups for restricted agents (gexec-review)
The account task set groups: [] with append: false, so the next run would
have removed custodian-agt from gated-executor's exchange group. Allowed
groups are now an explicit per-account inventory list, refused if forbidden,
and asserted as the exact set by goss; container test covers an unlisted group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 605711@bnt-lap001
Assistant-Session: 1f5e2256-181f-49ed-b7b1-a1c34bf37a61
2026-10-20 23:50:14 +02:00

258 lines
8.5 KiB
YAML

---
- name: Require SSH CA public key path
ansible.builtin.assert:
that:
- ssh_ca_pubkey_path is defined
- ssh_ca_pubkey_path | length > 0
fail_msg: >-
Set ssh_ca_pubkey_path to the OpenBao SSH CA public key file
(from railiance-platform openbao-configure-ssh).
- name: Stat SSH CA public key source
ansible.builtin.stat:
path: "{{ ssh_ca_pubkey_path }}"
delegate_to: localhost
become: false
register: ssh_ca_pubkey_stat
- name: Fail when SSH CA public key is missing
ansible.builtin.fail:
msg: "SSH CA public key not found on controller: {{ ssh_ca_pubkey_path }}"
when: not ssh_ca_pubkey_stat.stat.exists
- name: Ensure SSH CA directory exists
ansible.builtin.file:
path: /etc/ssh/ca
state: directory
owner: root
group: root
mode: "0755"
- name: Install SSH user CA public key
ansible.builtin.copy:
src: "{{ ssh_ca_pubkey_path }}"
dest: /etc/ssh/ca/ca_user.pub
owner: root
group: root
mode: "0644"
notify: Restart sshd
- name: Configure SSH certificate trust
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/20-ssh-ca.conf
owner: root
group: root
mode: "0644"
content: |
TrustedUserCAKeys /etc/ssh/ca/ca_user.pub
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
notify: Restart sshd
- name: Ensure auth_principals directory exists
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.file:
path: /etc/ssh/auth_principals
state: directory
owner: root
group: root
mode: "0755"
- name: Resolve principals for this host
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.set_fact:
ssh_ca_host_principals: >-
{{
(ssh_principals[inventory_hostname].users
if ssh_principals is defined
and inventory_hostname in ssh_principals
else {})
}}
- name: Resolve restricted agent accounts for this host
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.set_fact:
ssh_ca_host_restricted_accounts: >-
{{
(ssh_principals[inventory_hostname].restricted_accounts | default([])
if ssh_principals is defined
and inventory_hostname in ssh_principals
else [])
}}
- name: Resolve allowed supplementary groups of restricted agent accounts
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.set_fact:
ssh_ca_host_restricted_groups: >-
{{
(ssh_principals[inventory_hostname].restricted_account_groups | default({})
if ssh_principals is defined
and inventory_hostname in ssh_principals
else {})
}}
- name: Refuse forbidden groups in the allowed supplementary group list
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.assert:
that:
- (ssh_ca_host_restricted_groups.get(item, []) | intersect(ssh_ca_host_restricted_agent_forbidden_groups)) | length == 0
fail_msg: "Restricted account {{ item }} lists a forbidden group."
loop: "{{ ssh_ca_host_restricted_accounts }}"
- name: Ensure allowed supplementary groups exist
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.group:
name: "{{ item }}"
system: true
state: present
loop: "{{ ssh_ca_host_restricted_groups.values() | flatten | unique }}"
- name: Require restricted accounts to be certificate-mapped and distinct from operators
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.assert:
that:
- item in ssh_ca_host_principals
- ssh_ca_host_principals[item] | length > 0
- item not in ['root', 'tegwick', (baseline_user.name | default('tegwick'))]
fail_msg: >-
Restricted account {{ item }} must have principals under
ssh_principals.{{ inventory_hostname }}.users and must not be root or
the baseline operator user.
loop: "{{ ssh_ca_host_restricted_accounts }}"
- name: Ensure restricted agent account primary group
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.group:
name: "{{ item }}"
state: present
loop: "{{ ssh_ca_host_restricted_accounts }}"
- name: Ensure restricted agent account (locked password, only allowed supplementary groups)
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.user:
name: "{{ item }}"
group: "{{ item }}"
groups: "{{ ssh_ca_host_restricted_groups.get(item, []) }}"
append: false
password: "!"
update_password: always
shell: "{{ ssh_ca_host_restricted_agent_shell }}"
home: "/home/{{ item }}"
create_home: true
state: present
loop: "{{ ssh_ca_host_restricted_accounts }}"
- name: Restrict restricted agent home to the account (0700)
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.file:
path: "/home/{{ item }}"
state: directory
owner: "{{ item }}"
group: "{{ item }}"
mode: "0700"
loop: "{{ ssh_ca_host_restricted_accounts }}"
# On a first check-mode run the account does not exist yet, so chown lookup fails.
ignore_errors: "{{ ansible_check_mode }}"
- name: Remove any authorized_keys for restricted agent accounts
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.file:
path: "/home/{{ item.0 }}/.ssh/{{ item.1 }}"
state: absent
loop: "{{ ssh_ca_host_restricted_accounts | product(['authorized_keys', 'authorized_keys2']) | list }}"
- name: Ensure no sudoers drop-in exists for restricted agent accounts
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.file:
path: "/etc/sudoers.d/{{ item }}"
state: absent
loop: "{{ ssh_ca_host_restricted_accounts }}"
- name: Configure sshd Match blocks for restricted agent accounts
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.template:
src: restricted-agents-sshd.conf.j2
dest: /etc/ssh/sshd_config.d/05-restricted-agents.conf
owner: root
group: root
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
when: ssh_ca_host_restricted_accounts | length > 0
notify: Restart sshd
- name: Deploy auth_principals files per user
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.copy:
dest: "/etc/ssh/auth_principals/{{ item.key }}"
owner: root
group: root
mode: "0644"
content: "{{ item.value | join('\n') }}\n"
loop: "{{ ssh_ca_host_principals | dict2items }}"
when: ssh_ca_host_principals | length > 0
notify: Restart sshd
- name: Warn when no principals configured for host
ansible.builtin.debug:
msg: >-
No principals in ssh_principals.yaml for {{ inventory_hostname }} —
CA trust installed; add users under hosts.<name>.users to enable cert login.
when: ssh_ca_host_principals | length == 0
- name: Validate the complete sshd configuration before any restart
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.command:
cmd: /usr/sbin/sshd -t
changed_when: false
when: ssh_ca_host_restricted_accounts | length > 0
- name: Ensure Goss config directory exists
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.file:
path: /etc/goss
state: directory
owner: root
group: root
mode: "0755"
when: ssh_ca_host_restricted_accounts | length > 0
- name: Install restricted agent Goss assertions
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.template:
src: "{{ playbook_dir }}/../../goss/restricted-agent.yaml.j2"
dest: /etc/goss/restricted-agent.yaml
owner: root
group: root
mode: "0644"
when: ssh_ca_host_restricted_accounts | length > 0
- name: Apply sshd changes before asserting the effective configuration
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.meta: flush_handlers
- name: Stat Goss binary for restricted agent assertions
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.stat:
path: /usr/local/bin/goss
register: ssh_ca_host_goss_bin
when: ssh_ca_host_restricted_accounts | length > 0
- name: Assert restricted agent account posture (Goss)
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.command:
cmd: /usr/local/bin/goss -g /etc/goss/restricted-agent.yaml validate --format tap
register: ssh_ca_host_restricted_goss
changed_when: false
failed_when: false
when:
- ssh_ca_host_restricted_accounts | length > 0
- not ansible_check_mode
- ssh_ca_host_goss_bin.stat.exists | default(false)
- name: Fail closed when restricted agent assertions do not pass
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.assert:
that:
- ssh_ca_host_restricted_goss.rc == 0
fail_msg: "{{ ssh_ca_host_restricted_goss.stdout_lines | select('match', '^not ok') | list }}"
success_msg: "restricted agent account assertions passed"
when: ssh_ca_host_restricted_goss is not skipped and ssh_ca_host_restricted_goss.rc is defined