RAIL-HO-WP-0014: restricted custodian-agt account for agt-custodian (CUST-WP-0073-T02)
Map SSH certificate principal agt-custodian to a dedicated custodian-agt account on Railiance01 only, converged by ssh_ca_host (tag restricted_agent_account): locked password, home 0700, no supplementary groups, no sudoers entry, no authorized_keys, and an sshd Match drop-in (05-restricted-agents.conf) with publickey only and all forwarding, tunnel and user RC off. A separate goss spec asserts the posture, including that the account cannot read the k3s kubeconfig and the sshd -T effective config. Offline tests (optional real-sshd container check). Existing tegwick mappings unchanged; their retirement is RAIL-HO-WP-0014-T03. 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
This commit is contained in:
parent
8b7ade9f66
commit
c62b187559
11 changed files with 704 additions and 2 deletions
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
run: |
|
||||
set -eu
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends age make python3 python3-yaml
|
||||
apt-get install -y --no-install-recommends age make python3 python3-yaml python3-jinja2
|
||||
- name: Validate S1 source contracts
|
||||
run: |
|
||||
set -eu
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
| workplan | RAIL-HO-WP-0011 | blocked | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
|
||||
| workplan | RAIL-HO-WP-0012 | blocked | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md |
|
||||
| workplan | RAIL-HO-WP-0013 | blocked | — | workplans/RAIL-HO-WP-0013-host-time-baseline.md |
|
||||
| workplan | RAIL-HO-WP-0014 | active | — | workplans/RAIL-HO-WP-0014-custodian-agt-restricted-host-account.md |
|
||||
| task | RAIL-HO-WP-ADHOC-2026-08-22-T01 | done | — | workplans/ADHOC-2026-08-22.md |
|
||||
| task | RAIL-HO-WP-ADHOC-2026-08-22-T02 | done | — | workplans/ADHOC-2026-08-22.md |
|
||||
| task | RAIL-HO-WP-ADHOC-2026-08-22-T03 | done | — | workplans/ADHOC-2026-08-22.md |
|
||||
|
|
@ -62,3 +63,6 @@
|
|||
| task | RAIL-HO-WP-0013-T02 | wait | — | workplans/RAIL-HO-WP-0013-host-time-baseline.md |
|
||||
| task | RAIL-HO-WP-0013-T03 | wait | — | workplans/RAIL-HO-WP-0013-host-time-baseline.md |
|
||||
| task | RAIL-HO-WP-0013-T04 | wait | — | workplans/RAIL-HO-WP-0013-host-time-baseline.md |
|
||||
| task | RAIL-HO-WP-0014-T01 | done | — | workplans/RAIL-HO-WP-0014-custodian-agt-restricted-host-account.md |
|
||||
| task | RAIL-HO-WP-0014-T02 | wait | — | workplans/RAIL-HO-WP-0014-custodian-agt-restricted-host-account.md |
|
||||
| task | RAIL-HO-WP-0014-T03 | wait | — | workplans/RAIL-HO-WP-0014-custodian-agt-restricted-host-account.md |
|
||||
|
|
|
|||
|
|
@ -17,3 +17,14 @@ ssh_principals:
|
|||
- agt-interhub-bootstrap
|
||||
- adm-full
|
||||
- atm-backup-daily
|
||||
# CUST-WP-0073-T02 / RAIL-HO-WP-0014: supervised interactive agent.
|
||||
# agt-custodian maps ONLY to this restricted account and ONLY on
|
||||
# Railiance01; never add it to tegwick or another host.
|
||||
custodian-agt:
|
||||
- agt-custodian
|
||||
# Accounts converged by ssh_ca_host as restricted agent accounts:
|
||||
# locked password, home 0700, no supplementary groups, no sudoers entry,
|
||||
# no authorized_keys, and an sshd Match block (certificate only, no
|
||||
# forwarding/tunnel/user RC). Each must also appear under users above.
|
||||
restricted_accounts:
|
||||
- custodian-agt
|
||||
|
|
|
|||
|
|
@ -10,8 +10,13 @@
|
|||
#
|
||||
# cd ~/railiance-infra
|
||||
# make bootstrap-ssh-ca SSH_CA_PUBKEY=/tmp/openbao-ssh-ca.pub
|
||||
#
|
||||
# Restricted agent accounts only (RAIL-HO-WP-0014; no CA key needed):
|
||||
# cd ansible && ansible-playbook playbooks/bootstrap-ssh-ca.yaml \
|
||||
# --limit Railiance01 --tags restricted_agent_account --check --diff
|
||||
|
||||
- hosts: all
|
||||
- name: Deploy SSH CA trust, auth_principals and restricted agent accounts
|
||||
hosts: all
|
||||
become: true
|
||||
vars_files:
|
||||
- ../inventory/ssh_principals.yaml
|
||||
|
|
|
|||
50
ansible/roles/ssh_ca_host/defaults/main.yml
Normal file
50
ansible/roles/ssh_ca_host/defaults/main.yml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
# Restricted agent accounts (CUST-WP-0073-T02, RAIL-HO-WP-0014).
|
||||
#
|
||||
# Hosts list them in inventory/ssh_principals.yaml under
|
||||
# ssh_principals.<host>.restricted_accounts. Each is converged with a locked
|
||||
# password, a 0700 home, no supplementary groups, no sudoers drop-in, no
|
||||
# authorized_keys, and the sshd Match options below.
|
||||
|
||||
# Options rendered inside "Match User <account>". Only keywords that sshd
|
||||
# accepts inside a Match block belong here. PermitUserEnvironment is
|
||||
# global-only in OpenSSH; it is left at its default ("no") and asserted by
|
||||
# goss/restricted-agent.yaml.j2 instead.
|
||||
ssh_ca_host_restricted_agent_sshd_options:
|
||||
PubkeyAuthentication: "yes"
|
||||
AuthenticationMethods: publickey
|
||||
PasswordAuthentication: "no"
|
||||
KbdInteractiveAuthentication: "no"
|
||||
HostbasedAuthentication: "no"
|
||||
GSSAPIAuthentication: "no"
|
||||
AuthorizedKeysFile: none
|
||||
DisableForwarding: "yes"
|
||||
AllowAgentForwarding: "no"
|
||||
AllowTcpForwarding: "no"
|
||||
AllowStreamLocalForwarding: "no"
|
||||
X11Forwarding: "no"
|
||||
GatewayPorts: "no"
|
||||
PermitListen: none
|
||||
PermitOpen: none
|
||||
PermitTunnel: "no"
|
||||
PermitUserRC: "no"
|
||||
|
||||
# Groups a restricted agent account must never hold. The account is created
|
||||
# with an empty supplementary group list; this list is asserted after convergence.
|
||||
ssh_ca_host_restricted_agent_forbidden_groups:
|
||||
- root
|
||||
- sudo
|
||||
- admin
|
||||
- wheel
|
||||
- adm
|
||||
- docker
|
||||
- lxd
|
||||
- systemd-journal
|
||||
- k3s
|
||||
|
||||
# Kubeconfig paths the account must not be able to read.
|
||||
ssh_ca_host_restricted_agent_unreadable_paths:
|
||||
- /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
# Shell for the supervised interactive session.
|
||||
ssh_ca_host_restricted_agent_shell: /bin/bash
|
||||
|
|
@ -49,6 +49,7 @@
|
|||
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
|
||||
|
|
@ -57,6 +58,7 @@
|
|||
mode: "0755"
|
||||
|
||||
- name: Resolve principals for this host
|
||||
tags: [ssh_ca, restricted_agent_account]
|
||||
ansible.builtin.set_fact:
|
||||
ssh_ca_host_principals: >-
|
||||
{{
|
||||
|
|
@ -66,7 +68,92 @@
|
|||
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: 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, no supplementary groups)
|
||||
tags: [ssh_ca, restricted_agent_account]
|
||||
ansible.builtin.user:
|
||||
name: "{{ item }}"
|
||||
group: "{{ item }}"
|
||||
groups: []
|
||||
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
|
||||
|
|
@ -83,3 +170,62 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# Managed by railiance-infra ssh_ca_host (CUST-WP-0073-T02, RAIL-HO-WP-0014).
|
||||
# Restricted agent accounts: SSH certificate (AuthorizedPrincipalsFile) only,
|
||||
# no authorized_keys, no forwarding, no tunnel, no user RC.
|
||||
# This file sorts before 10-hardening.conf so that, under sshd's
|
||||
# first-obtained-value rule, these values win for the matched users. A Match
|
||||
# block in an Include'd file ends at the end of that file (OpenSSH >= 8.2).
|
||||
{% for account in ssh_ca_host_restricted_accounts %}
|
||||
Match User {{ account }}
|
||||
{% for option in ssh_ca_host_restricted_agent_sshd_options | dict2items %}
|
||||
{{ option.key }} {{ option.value }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
90
goss/restricted-agent.yaml.j2
Normal file
90
goss/restricted-agent.yaml.j2
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Restricted agent account assertions (CUST-WP-0073-T02, RAIL-HO-WP-0014).
|
||||
# Rendered by ansible/roles/ssh_ca_host to /etc/goss/restricted-agent.yaml on
|
||||
# hosts that declare ssh_principals.<host>.restricted_accounts. Read-only:
|
||||
# goss -g /etc/goss/restricted-agent.yaml validate --format tap
|
||||
# Kept separate from baseline.yaml.j2 so verify.yaml's baseline hash is unchanged.
|
||||
|
||||
group:
|
||||
{% for account in ssh_ca_host_restricted_accounts %}
|
||||
{{ account }}:
|
||||
exists: true
|
||||
{% endfor %}
|
||||
|
||||
user:
|
||||
{% for account in ssh_ca_host_restricted_accounts %}
|
||||
{{ account }}:
|
||||
exists: true
|
||||
home: /home/{{ account }}
|
||||
shell: {{ ssh_ca_host_restricted_agent_shell }}
|
||||
# goss treats this as containment; "id -nG" below asserts the exact set.
|
||||
groups:
|
||||
- {{ account }}
|
||||
{% endfor %}
|
||||
|
||||
file:
|
||||
{% for account in ssh_ca_host_restricted_accounts %}
|
||||
/home/{{ account }}:
|
||||
exists: true
|
||||
filetype: directory
|
||||
owner: {{ account }}
|
||||
mode: "0700"
|
||||
/home/{{ account }}/.ssh/authorized_keys:
|
||||
exists: false
|
||||
/home/{{ account }}/.ssh/authorized_keys2:
|
||||
exists: false
|
||||
/etc/sudoers.d/{{ account }}:
|
||||
exists: false
|
||||
/etc/ssh/auth_principals/{{ account }}:
|
||||
exists: true
|
||||
owner: root
|
||||
mode: "0644"
|
||||
contents:
|
||||
{% for principal in ssh_ca_host_principals[account] %}
|
||||
- "/^{{ principal }}$/"
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
/etc/ssh/sshd_config.d/05-restricted-agents.conf:
|
||||
exists: true
|
||||
owner: root
|
||||
mode: "0644"
|
||||
|
||||
command:
|
||||
{% for account in ssh_ca_host_restricted_accounts %}
|
||||
"passwd -S {{ account }}":
|
||||
exit-status: 0
|
||||
stdout:
|
||||
- "/^{{ account }} L /"
|
||||
"sudo -n -l -U {{ account }}":
|
||||
exit-status: 0
|
||||
stdout:
|
||||
- "/is not allowed to run sudo/"
|
||||
"grep -rlE '^[^#]*\\b{{ account }}\\b' /etc/sudoers /etc/sudoers.d || true":
|
||||
exit-status: 0
|
||||
stdout:
|
||||
- "!/./"
|
||||
"id -nG {{ account }}":
|
||||
exit-status: 0
|
||||
stdout:
|
||||
- "/^{{ account }}$/"
|
||||
{% for group in ssh_ca_host_restricted_agent_forbidden_groups %}
|
||||
- "!/\\b{{ group }}\\b/"
|
||||
{% endfor %}
|
||||
{% for path in ssh_ca_host_restricted_agent_unreadable_paths %}
|
||||
"runuser -u {{ account }} -- test -r {{ path }}; echo readable=$?":
|
||||
exit-status: 0
|
||||
stdout:
|
||||
# test -r exits 1 when unreadable; 0 would mean the account can read it.
|
||||
- "/readable=1/"
|
||||
{% endfor %}
|
||||
"runuser -u {{ account }} -- sh -c 'for f in /etc/rancher/k3s/*.yaml; do test -r \"$f\" && echo READABLE \"$f\"; done; true'":
|
||||
exit-status: 0
|
||||
stdout:
|
||||
- "!/READABLE/"
|
||||
"/usr/sbin/sshd -T -C user={{ account }},host=goss.invalid,addr=192.0.2.1":
|
||||
exit-status: 0
|
||||
stdout:
|
||||
{% for option in ssh_ca_host_restricted_agent_sshd_options | dict2items %}
|
||||
- "/^{{ option.key | lower }} {{ option.value | lower }}$/"
|
||||
{% endfor %}
|
||||
- "/^permituserenvironment no$/"
|
||||
{% endfor %}
|
||||
46
tests/fixtures/restricted_agent_container.sh
vendored
Executable file
46
tests/fixtures/restricted_agent_container.sh
vendored
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env bash
|
||||
# Offline container check for the restricted agent account (RAIL-HO-WP-0014).
|
||||
# Runs inside a throwaway ubuntu:24.04 container; never contacts a managed host.
|
||||
# /in must contain the rendered 05-restricted-agents.conf and restricted-agent.yaml.
|
||||
set -euo pipefail
|
||||
apt-get update -qq >/dev/null
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server sudo curl ca-certificates >/dev/null 2>&1
|
||||
curl -sSL -o /usr/local/bin/goss https://github.com/goss-org/goss/releases/download/v0.4.9/goss-linux-amd64
|
||||
chmod +x /usr/local/bin/goss
|
||||
mkdir -p /run/sshd /etc/ssh/ca /etc/ssh/auth_principals /etc/rancher/k3s
|
||||
ssh-keygen -q -t ed25519 -N '' -f /tmp/ca && cp /tmp/ca.pub /etc/ssh/ca/ca_user.pub
|
||||
# Mirror the live drop-ins written by the base and ssh_ca_host roles.
|
||||
printf 'PasswordAuthentication no\nPermitRootLogin no\nPubkeyAuthentication yes\nChallengeResponseAuthentication no\n' \
|
||||
> /etc/ssh/sshd_config.d/10-hardening.conf
|
||||
printf 'TrustedUserCAKeys /etc/ssh/ca/ca_user.pub\nAuthorizedPrincipalsFile /etc/ssh/auth_principals/%%u\n' \
|
||||
> /etc/ssh/sshd_config.d/20-ssh-ca.conf
|
||||
/usr/sbin/sshd -t -f /in/05-restricted-agents.conf && echo "drop-in validate ok"
|
||||
cp /in/05-restricted-agents.conf /etc/ssh/sshd_config.d/
|
||||
/usr/sbin/sshd -t && echo "full config ok"
|
||||
# Equivalent of the role's account tasks.
|
||||
useradd -m -s /bin/bash tegwick
|
||||
echo 'tegwick ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/tegwick
|
||||
groupadd custodian-agt
|
||||
useradd -m -g custodian-agt -s /bin/bash -d /home/custodian-agt custodian-agt
|
||||
usermod -p '!' custodian-agt
|
||||
chmod 0700 /home/custodian-agt
|
||||
echo agt-custodian > /etc/ssh/auth_principals/custodian-agt
|
||||
echo 'apiVersion: v1' > /etc/rancher/k3s/k3s.yaml
|
||||
chmod 0600 /etc/rancher/k3s/k3s.yaml
|
||||
echo "--- Match scope: operator account keeps its own settings"
|
||||
/usr/sbin/sshd -T -C user=tegwick,host=x,addr=192.0.2.1 | grep -E '^(authorizedkeysfile|authenticationmethods|trustedusercakeys) '
|
||||
echo "--- goss positive"
|
||||
goss -g /in/restricted-agent.yaml validate --format tap > /tmp/pos
|
||||
tail -1 /tmp/pos
|
||||
expect_fail() {
|
||||
if goss -g /in/restricted-agent.yaml validate --format tap > /tmp/neg; then
|
||||
echo "UNEXPECTED PASS: $1"; exit 1
|
||||
fi
|
||||
echo "--- negative ($1) fails as expected:"; grep '^not ok' /tmp/neg | cut -c1-110
|
||||
}
|
||||
chmod 0644 /etc/rancher/k3s/k3s.yaml; expect_fail "k3s.yaml world-readable"; chmod 0600 /etc/rancher/k3s/k3s.yaml
|
||||
usermod -aG sudo custodian-agt; expect_fail "sudo group"; gpasswd -d custodian-agt sudo >/dev/null
|
||||
echo 'custodian-agt ALL=(ALL) ALL' > /etc/sudoers.d/custodian-agt; expect_fail "sudoers entry"; rm /etc/sudoers.d/custodian-agt
|
||||
sed -i 's/X11Forwarding no/X11Forwarding yes/; s/DisableForwarding yes/DisableForwarding no/' /etc/ssh/sshd_config.d/05-restricted-agents.conf
|
||||
expect_fail "forwarding re-enabled"
|
||||
echo "ALL CONTAINER CHECKS PASSED"
|
||||
195
tests/test_restricted_agent_account.py
Normal file
195
tests/test_restricted_agent_account.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Offline contract for the restricted agent account (CUST-WP-0073-T02, RAIL-HO-WP-0014).
|
||||
|
||||
Static checks always run. The container check renders the role's sshd drop-in
|
||||
and Goss spec and validates them against a real OpenSSH 9.6 in a throwaway
|
||||
ubuntu:24.04 container; enable it with RAILIANCE_CONTAINER_TESTS=1. Nothing
|
||||
here contacts a managed host.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ROLE = ROOT / "ansible" / "roles" / "ssh_ca_host"
|
||||
ACCOUNT = "custodian-agt"
|
||||
PRINCIPAL = "agt-custodian"
|
||||
FORBIDDEN_SSHD = {
|
||||
"AllowAgentForwarding": "no",
|
||||
"AllowTcpForwarding": "no",
|
||||
"X11Forwarding": "no",
|
||||
"PermitTunnel": "no",
|
||||
"PermitUserRC": "no",
|
||||
"DisableForwarding": "yes",
|
||||
"AuthorizedKeysFile": "none",
|
||||
"AuthenticationMethods": "publickey",
|
||||
"PasswordAuthentication": "no",
|
||||
"KbdInteractiveAuthentication": "no",
|
||||
}
|
||||
REQUIRED_FORBIDDEN_GROUPS = {"sudo", "adm", "docker", "systemd-journal", "root"}
|
||||
|
||||
|
||||
def _load(path: Path):
|
||||
return yaml.safe_load(path.read_text())
|
||||
|
||||
|
||||
def render(template: Path, **ctx) -> str:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(ROOT)),
|
||||
keep_trailing_newline=True,
|
||||
trim_blocks=True,
|
||||
undefined=jinja2.StrictUndefined,
|
||||
)
|
||||
env.filters["dict2items"] = lambda d: [{"key": k, "value": v} for k, v in d.items()]
|
||||
return env.get_template(str(template.relative_to(ROOT))).render(**ctx)
|
||||
|
||||
|
||||
def railiance01_context() -> dict:
|
||||
principals = _load(ROOT / "ansible" / "inventory" / "ssh_principals.yaml")["ssh_principals"]
|
||||
host = principals["Railiance01"]
|
||||
ctx = dict(_load(ROLE / "defaults" / "main.yml"))
|
||||
ctx["ssh_ca_host_restricted_accounts"] = host["restricted_accounts"]
|
||||
ctx["ssh_ca_host_principals"] = host["users"]
|
||||
return ctx
|
||||
|
||||
|
||||
class RestrictedAgentInventoryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.principals = _load(ROOT / "ansible" / "inventory" / "ssh_principals.yaml")["ssh_principals"]
|
||||
|
||||
def test_principal_maps_only_to_restricted_account_on_railiance01(self) -> None:
|
||||
holders = [
|
||||
(host, user)
|
||||
for host, data in self.principals.items()
|
||||
for user, plist in (data.get("users") or {}).items()
|
||||
if PRINCIPAL in plist
|
||||
]
|
||||
self.assertEqual(holders, [("Railiance01", ACCOUNT)])
|
||||
self.assertEqual(self.principals["Railiance01"]["users"][ACCOUNT], [PRINCIPAL])
|
||||
|
||||
def test_restricted_accounts_are_declared_and_mapped(self) -> None:
|
||||
for host, data in self.principals.items():
|
||||
for account in data.get("restricted_accounts") or []:
|
||||
self.assertIn(account, data["users"], f"{host}:{account} lacks principals")
|
||||
self.assertNotIn(account, {"root", "tegwick"})
|
||||
self.assertNotIn("restricted_accounts", self.principals["CoulombCore"])
|
||||
|
||||
def test_existing_operator_mappings_unchanged(self) -> None:
|
||||
# Retirement of the generic agt-* principals from tegwick is a tracked
|
||||
# follow-up (RAIL-HO-WP-0014-T03), not part of this change.
|
||||
for host in ("CoulombCore", "Railiance01"):
|
||||
self.assertEqual(
|
||||
self.principals[host]["users"]["tegwick"],
|
||||
["agt-task-bridge", "agt-interhub-bootstrap", "adm-full", "atm-backup-daily"],
|
||||
)
|
||||
|
||||
|
||||
class RestrictedAgentRoleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tasks = _load(ROLE / "tasks" / "main.yml")
|
||||
self.defaults = _load(ROLE / "defaults" / "main.yml")
|
||||
|
||||
def _task(self, name: str) -> dict:
|
||||
matches = [t for t in self.tasks if t["name"] == name]
|
||||
self.assertEqual(len(matches), 1, name)
|
||||
return matches[0]
|
||||
|
||||
def test_account_has_locked_password_and_no_supplementary_groups(self) -> None:
|
||||
user = self._task("Ensure restricted agent account (locked password, no supplementary groups)")[
|
||||
"ansible.builtin.user"
|
||||
]
|
||||
self.assertEqual(user["groups"], [])
|
||||
self.assertIs(user["append"], False)
|
||||
self.assertEqual(user["password"], "!")
|
||||
self.assertEqual(user["update_password"], "always")
|
||||
|
||||
def test_home_0700_no_sudoers_no_authorized_keys(self) -> None:
|
||||
home = self._task("Restrict restricted agent home to the account (0700)")["ansible.builtin.file"]
|
||||
self.assertEqual(home["mode"], "0700")
|
||||
sudoers = self._task("Ensure no sudoers drop-in exists for restricted agent accounts")[
|
||||
"ansible.builtin.file"
|
||||
]
|
||||
self.assertEqual(sudoers["state"], "absent")
|
||||
keys = self._task("Remove any authorized_keys for restricted agent accounts")
|
||||
self.assertEqual(keys["ansible.builtin.file"]["state"], "absent")
|
||||
self.assertIn("authorized_keys", keys["loop"])
|
||||
|
||||
def test_sshd_dropin_is_validated_and_sorts_before_hardening(self) -> None:
|
||||
tmpl = self._task("Configure sshd Match blocks for restricted agent accounts")["ansible.builtin.template"]
|
||||
self.assertEqual(tmpl["validate"], "/usr/sbin/sshd -t -f %s")
|
||||
self.assertLess(Path(tmpl["dest"]).name, "10-hardening.conf")
|
||||
|
||||
def test_every_restricted_task_is_reachable_by_tag(self) -> None:
|
||||
for task in self.tasks:
|
||||
if "restricted" in task["name"].lower() or task["name"].startswith("Deploy auth_principals"):
|
||||
self.assertIn("restricted_agent_account", task.get("tags", []), task["name"])
|
||||
|
||||
def test_forbidden_groups_and_unreadable_kubeconfig_declared(self) -> None:
|
||||
self.assertTrue(REQUIRED_FORBIDDEN_GROUPS <= set(self.defaults["ssh_ca_host_restricted_agent_forbidden_groups"]))
|
||||
self.assertIn("/etc/rancher/k3s/k3s.yaml", self.defaults["ssh_ca_host_restricted_agent_unreadable_paths"])
|
||||
|
||||
|
||||
class RestrictedAgentRenderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ctx = railiance01_context()
|
||||
|
||||
def test_match_block_turns_forbidden_options_off(self) -> None:
|
||||
text = render(ROLE / "templates" / "restricted-agents-sshd.conf.j2", **self.ctx)
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip() and not line.startswith("#")]
|
||||
self.assertEqual(lines[0], f"Match User {ACCOUNT}")
|
||||
options = dict(line.split(None, 1) for line in lines[1:])
|
||||
for key, value in FORBIDDEN_SSHD.items():
|
||||
self.assertEqual(options.get(key), value, key)
|
||||
self.assertNotIn("PermitUserEnvironment", options) # global-only keyword
|
||||
|
||||
def test_goss_spec_asserts_required_posture(self) -> None:
|
||||
spec = yaml.safe_load(render(ROOT / "goss" / "restricted-agent.yaml.j2", **self.ctx))
|
||||
self.assertFalse(spec["file"][f"/etc/sudoers.d/{ACCOUNT}"]["exists"])
|
||||
self.assertFalse(spec["file"][f"/home/{ACCOUNT}/.ssh/authorized_keys"]["exists"])
|
||||
self.assertEqual(spec["file"][f"/home/{ACCOUNT}"]["mode"], "0700")
|
||||
commands = spec["command"]
|
||||
self.assertIn(f"sudo -n -l -U {ACCOUNT}", commands)
|
||||
idg = commands[f"id -nG {ACCOUNT}"]["stdout"]
|
||||
for group in REQUIRED_FORBIDDEN_GROUPS:
|
||||
self.assertIn(f"!/\\b{group}\\b/", idg)
|
||||
self.assertTrue(any("k3s.yaml" in c and "runuser" in c for c in commands))
|
||||
sshd = next(v for c, v in commands.items() if c.startswith(f"/usr/sbin/sshd -T -C user={ACCOUNT},"))
|
||||
for key, value in FORBIDDEN_SSHD.items():
|
||||
self.assertIn(f"/^{key.lower()} {value.lower()}$/", sshd["stdout"])
|
||||
self.assertIn("/^permituserenvironment no$/", sshd["stdout"])
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("RAILIANCE_CONTAINER_TESTS") == "1", "set RAILIANCE_CONTAINER_TESTS=1")
|
||||
class RestrictedAgentContainerTests(unittest.TestCase):
|
||||
def test_real_sshd_and_goss_in_throwaway_container(self) -> None:
|
||||
docker = shutil.which("docker")
|
||||
if docker is None:
|
||||
self.skipTest("docker not available")
|
||||
ctx = railiance01_context()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
Path(tmp, "05-restricted-agents.conf").write_text(
|
||||
render(ROLE / "templates" / "restricted-agents-sshd.conf.j2", **ctx)
|
||||
)
|
||||
Path(tmp, "restricted-agent.yaml").write_text(render(ROOT / "goss" / "restricted-agent.yaml.j2", **ctx))
|
||||
shutil.copy(ROOT / "tests" / "fixtures" / "restricted_agent_container.sh", tmp)
|
||||
os.chmod(tmp, 0o755)
|
||||
result = subprocess.run(
|
||||
[docker, "run", "--rm", "-v", f"{tmp}:/in:ro", "ubuntu:24.04", "bash", "/in/restricted_agent_container.sh"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout[-3000:] + result.stderr[-2000:])
|
||||
self.assertIn("ALL CONTAINER CHECKS PASSED", result.stdout)
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
---
|
||||
id: RAIL-HO-WP-0014
|
||||
type: workplan
|
||||
title: "Restricted custodian-agt host account for agt-custodian on Railiance01"
|
||||
domain: financials
|
||||
repo: railiance-infra
|
||||
status: active
|
||||
flavor: implementation
|
||||
owner: claude
|
||||
topic_slug: railiance
|
||||
created: "2026-10-20"
|
||||
updated: "2026-10-20"
|
||||
related_workplans:
|
||||
- CUST-WP-0073
|
||||
- RCLUSTER-WP-0019
|
||||
- RPF-WP-0062
|
||||
- WARDEN-WP-0045
|
||||
---
|
||||
|
||||
Owner work record for **CUST-WP-0073-T02** (host principal mapping). Founder
|
||||
decisions of 2026-10-20 supersede the r1 candidate
|
||||
(`the-custodian/docs/changes/CUST-WP-0073/credential-boundary-r1/host-identity.candidate.yaml`,
|
||||
which named the principal `agt-custodian-codex`):
|
||||
|
||||
- Dedicated host account `custodian-agt`, **Railiance01 only**.
|
||||
- SSH certificate principal `agt-custodian`, issued short-lived by ops-warden
|
||||
(WARDEN-WP-0045). Never mapped to `tegwick`.
|
||||
- Account: password locked, home 0700, no supplementary or privileged groups
|
||||
(no sudo, adm, docker, systemd-journal, lxd), no sudoers entry, no
|
||||
authorized_keys (`AuthorizedKeysFile none`), sshd Match block: publickey
|
||||
(certificate) only, no agent/TCP/stream-local/X11 forwarding, no tunnel, no
|
||||
user RC. `PermitUserEnvironment` is global-only in OpenSSH; it stays at the
|
||||
default `no` and is asserted, not set in the Match block.
|
||||
- Kube access is a short-lived ServiceAccount token for
|
||||
`custodian-agents/custodian-agent` (RPF-WP-0062), placed by the supervisor.
|
||||
The account must not read `/etc/rancher/k3s/*.yaml` (RCLUSTER-WP-0019 moves
|
||||
it to 0600).
|
||||
|
||||
## Implement through ssh_ca_host and the principal inventory
|
||||
|
||||
```task
|
||||
id: RAIL-HO-WP-0014-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
- `ansible/inventory/ssh_principals.yaml`: `Railiance01.users.custodian-agt:
|
||||
[agt-custodian]` and `Railiance01.restricted_accounts: [custodian-agt]`.
|
||||
Existing `tegwick` mappings are unchanged.
|
||||
- `ansible/roles/ssh_ca_host`: tasks (tag `restricted_agent_account`) create
|
||||
the group/account (`password: "!"`, `groups: []`, `append: false`), set home
|
||||
0700, remove `authorized_keys{,2}`, ensure no `/etc/sudoers.d/<account>`,
|
||||
render `/etc/ssh/sshd_config.d/05-restricted-agents.conf` (validated with
|
||||
`sshd -t -f`, then full `sshd -t`), install `/etc/goss/restricted-agent.yaml`
|
||||
and run it after the sshd restart, failing closed on any `not ok`.
|
||||
Defaults: `ansible/roles/ssh_ca_host/defaults/main.yml`.
|
||||
- `goss/restricted-agent.yaml.j2`: no sudo (`sudo -n -l -U`, sudoers grep),
|
||||
exact group set (`id -nG`) without privileged groups, locked password,
|
||||
kubeconfig unreadable as the account, `sshd -T -C user=custodian-agt,...`
|
||||
effective options. Separate from `baseline.yaml.j2` so `verify.yaml`'s
|
||||
baseline hash is unchanged.
|
||||
- `tests/test_restricted_agent_account.py`: offline static/render contract;
|
||||
`RAILIANCE_CONTAINER_TESTS=1` runs real OpenSSH 9.6 + goss 0.4.9 in a
|
||||
throwaway ubuntu:24.04 container, including negative cases (world-readable
|
||||
kubeconfig, sudo group, sudoers entry, forwarding re-enabled) and proof that
|
||||
the Match block in the 05 drop-in does not leak into `tegwick`.
|
||||
|
||||
Offline validation 2026-10-20: unittest 65 tests OK (container test OK when
|
||||
enabled); `ansible-playbook --syntax-check` OK; `ansible-lint` on the role and
|
||||
playbook passes; `make validate-baseline validate-inventory
|
||||
validate-handoff-readonly` OK. No host was contacted.
|
||||
|
||||
## Attended apply on Railiance01
|
||||
|
||||
```task
|
||||
id: RAIL-HO-WP-0014-T02
|
||||
status: wait
|
||||
priority: high
|
||||
needs_human: true
|
||||
depends_on: [RCLUSTER-WP-0019-T03]
|
||||
blocking_reason: "Attended founder apply on Railiance01; the closing goss check only passes once RCLUSTER-WP-0019 has made the k3s kubeconfig 0600."
|
||||
```
|
||||
|
||||
From `~/railiance-infra/ansible`, as the operator (admin certificate), limited
|
||||
to Railiance01 and the `restricted_agent_account` tag (no CA key needed):
|
||||
|
||||
```bash
|
||||
ansible-playbook playbooks/bootstrap-ssh-ca.yaml --limit Railiance01 \
|
||||
--tags restricted_agent_account --check --diff # review first
|
||||
ansible-playbook playbooks/bootstrap-ssh-ca.yaml --limit Railiance01 \
|
||||
--tags restricted_agent_account --diff # apply
|
||||
```
|
||||
|
||||
Expected changes: new group/user `custodian-agt`, home mode, new
|
||||
`/etc/ssh/auth_principals/custodian-agt`, new
|
||||
`/etc/ssh/sshd_config.d/05-restricted-agents.conf`, new
|
||||
`/etc/goss/restricted-agent.yaml`, one sshd restart. Existing
|
||||
`/etc/ssh/auth_principals/tegwick` must show no diff. The closing Goss task
|
||||
fails closed (listing each `not ok`) if anything, including kubeconfig
|
||||
readability, is wrong. Keep the operator session open until a fresh
|
||||
`ssh tegwick@railiance01` succeeds after the restart.
|
||||
|
||||
Rollback (attended):
|
||||
|
||||
```bash
|
||||
sudo rm /etc/ssh/sshd_config.d/05-restricted-agents.conf /etc/ssh/auth_principals/custodian-agt
|
||||
sudo sshd -t && sudo systemctl restart ssh
|
||||
sudo userdel -r custodian-agt # also removes the custodian-agt group
|
||||
sudo rm -f /etc/goss/restricted-agent.yaml
|
||||
```
|
||||
|
||||
then revert the inventory commit so convergence does not recreate it.
|
||||
|
||||
## Retire generic agt-* principals from tegwick
|
||||
|
||||
```task
|
||||
id: RAIL-HO-WP-0014-T03
|
||||
status: wait
|
||||
priority: medium
|
||||
needs_human: true
|
||||
depends_on: [RAIL-HO-WP-0014-T02]
|
||||
blocking_reason: "Needs each consumer moved to its own restricted account (or retired) and an attended principals rollout on both hosts."
|
||||
```
|
||||
|
||||
`agt-task-bridge` and `agt-interhub-bootstrap` are mapped into `tegwick` on
|
||||
CoulombCore and Railiance01, and `tegwick` has NOPASSWD sudo and can read the
|
||||
k3s admin kubeconfig, so either certificate is effectively root. Known
|
||||
consumers (2026-10-20, from ops-warden sources):
|
||||
|
||||
- `agt-task-bridge`: ops-warden actor `agt-state-hub-bridge` (ops-bridge tunnel
|
||||
to the State Hub backend, 24 h TTL; `ops-warden/wiki/ActorInventoryPatterns.md`,
|
||||
`examples/inventory.seed.yaml`, `wiki/playbooks/ops-bridge-tunnel-cert.md`,
|
||||
`tests/test_tunnel_cert_readiness.py`).
|
||||
- `agt-interhub-bootstrap`: ops-warden actor `agt-codex-interhub-bootstrap`
|
||||
(attended Inter-Hub bootstrap, 2 h TTL;
|
||||
`ops-warden/wiki/InterHubBootstrapAccessLane.md`). The Inter-Hub lane was
|
||||
retired with CORE-WP-0007, so this may be removable outright.
|
||||
|
||||
Follow-up: give the tunnel actor its own `restricted_accounts` entry
|
||||
(tunnel-only: `PermitOpen` limited to the forwarded target, no shell via
|
||||
`ForceCommand`/`PermitTTY no`), confirm the Inter-Hub actor is unused, then
|
||||
remove both from `tegwick` and update `test_existing_operator_mappings_unchanged`
|
||||
and ops-warden's `scripts/check_principals_drift.py` expectations together.
|
||||
Loading…
Add table
Add a link
Reference in a new issue