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: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
apt-get update
|
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
|
- name: Validate S1 source contracts
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
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-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-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-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-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-T02 | done | — | workplans/ADHOC-2026-08-22.md |
|
||||||
| task | RAIL-HO-WP-ADHOC-2026-08-22-T03 | 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-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-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-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
|
- agt-interhub-bootstrap
|
||||||
- adm-full
|
- adm-full
|
||||||
- atm-backup-daily
|
- 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
|
# cd ~/railiance-infra
|
||||||
# make bootstrap-ssh-ca SSH_CA_PUBKEY=/tmp/openbao-ssh-ca.pub
|
# 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
|
become: true
|
||||||
vars_files:
|
vars_files:
|
||||||
- ../inventory/ssh_principals.yaml
|
- ../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
|
notify: Restart sshd
|
||||||
|
|
||||||
- name: Ensure auth_principals directory exists
|
- name: Ensure auth_principals directory exists
|
||||||
|
tags: [ssh_ca, restricted_agent_account]
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: /etc/ssh/auth_principals
|
path: /etc/ssh/auth_principals
|
||||||
state: directory
|
state: directory
|
||||||
|
|
@ -57,6 +58,7 @@
|
||||||
mode: "0755"
|
mode: "0755"
|
||||||
|
|
||||||
- name: Resolve principals for this host
|
- name: Resolve principals for this host
|
||||||
|
tags: [ssh_ca, restricted_agent_account]
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
ssh_ca_host_principals: >-
|
ssh_ca_host_principals: >-
|
||||||
{{
|
{{
|
||||||
|
|
@ -66,7 +68,92 @@
|
||||||
else {})
|
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
|
- name: Deploy auth_principals files per user
|
||||||
|
tags: [ssh_ca, restricted_agent_account]
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: "/etc/ssh/auth_principals/{{ item.key }}"
|
dest: "/etc/ssh/auth_principals/{{ item.key }}"
|
||||||
owner: root
|
owner: root
|
||||||
|
|
@ -83,3 +170,62 @@
|
||||||
No principals in ssh_principals.yaml for {{ inventory_hostname }} —
|
No principals in ssh_principals.yaml for {{ inventory_hostname }} —
|
||||||
CA trust installed; add users under hosts.<name>.users to enable cert login.
|
CA trust installed; add users under hosts.<name>.users to enable cert login.
|
||||||
when: ssh_ca_host_principals | length == 0
|
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