From c62b18755926d8180c46eeadf898a4695760cb1d Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 20 Oct 2026 13:46:10 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 605711@bnt-lap001 Assistant-Session: 1f5e2256-181f-49ed-b7b1-a1c34bf37a61 --- .forgejo/workflows/ci-smoke.yaml | 2 +- WORK-RECORDS.md | 4 + ansible/inventory/ssh_principals.yaml | 11 + ansible/playbooks/bootstrap-ssh-ca.yaml | 7 +- ansible/roles/ssh_ca_host/defaults/main.yml | 50 +++++ ansible/roles/ssh_ca_host/tasks/main.yml | 146 +++++++++++++ .../templates/restricted-agents-sshd.conf.j2 | 12 ++ goss/restricted-agent.yaml.j2 | 90 ++++++++ tests/fixtures/restricted_agent_container.sh | 46 +++++ tests/test_restricted_agent_account.py | 195 ++++++++++++++++++ ...4-custodian-agt-restricted-host-account.md | 143 +++++++++++++ 11 files changed, 704 insertions(+), 2 deletions(-) create mode 100644 ansible/roles/ssh_ca_host/defaults/main.yml create mode 100644 ansible/roles/ssh_ca_host/templates/restricted-agents-sshd.conf.j2 create mode 100644 goss/restricted-agent.yaml.j2 create mode 100755 tests/fixtures/restricted_agent_container.sh create mode 100644 tests/test_restricted_agent_account.py create mode 100644 workplans/RAIL-HO-WP-0014-custodian-agt-restricted-host-account.md diff --git a/.forgejo/workflows/ci-smoke.yaml b/.forgejo/workflows/ci-smoke.yaml index e63f319..577705b 100644 --- a/.forgejo/workflows/ci-smoke.yaml +++ b/.forgejo/workflows/ci-smoke.yaml @@ -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 diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 01f49a3..f413210 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/ansible/inventory/ssh_principals.yaml b/ansible/inventory/ssh_principals.yaml index 8b6c087..952d120 100644 --- a/ansible/inventory/ssh_principals.yaml +++ b/ansible/inventory/ssh_principals.yaml @@ -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 diff --git a/ansible/playbooks/bootstrap-ssh-ca.yaml b/ansible/playbooks/bootstrap-ssh-ca.yaml index efccfb9..3f77fd9 100644 --- a/ansible/playbooks/bootstrap-ssh-ca.yaml +++ b/ansible/playbooks/bootstrap-ssh-ca.yaml @@ -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 diff --git a/ansible/roles/ssh_ca_host/defaults/main.yml b/ansible/roles/ssh_ca_host/defaults/main.yml new file mode 100644 index 0000000..c2fd0b4 --- /dev/null +++ b/ansible/roles/ssh_ca_host/defaults/main.yml @@ -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..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 ". 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 diff --git a/ansible/roles/ssh_ca_host/tasks/main.yml b/ansible/roles/ssh_ca_host/tasks/main.yml index daae6ef..215a242 100644 --- a/ansible/roles/ssh_ca_host/tasks/main.yml +++ b/ansible/roles/ssh_ca_host/tasks/main.yml @@ -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..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 diff --git a/ansible/roles/ssh_ca_host/templates/restricted-agents-sshd.conf.j2 b/ansible/roles/ssh_ca_host/templates/restricted-agents-sshd.conf.j2 new file mode 100644 index 0000000..239d13d --- /dev/null +++ b/ansible/roles/ssh_ca_host/templates/restricted-agents-sshd.conf.j2 @@ -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 %} diff --git a/goss/restricted-agent.yaml.j2 b/goss/restricted-agent.yaml.j2 new file mode 100644 index 0000000..a13ddf5 --- /dev/null +++ b/goss/restricted-agent.yaml.j2 @@ -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..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 %} diff --git a/tests/fixtures/restricted_agent_container.sh b/tests/fixtures/restricted_agent_container.sh new file mode 100755 index 0000000..8b96dc9 --- /dev/null +++ b/tests/fixtures/restricted_agent_container.sh @@ -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" diff --git a/tests/test_restricted_agent_account.py b/tests/test_restricted_agent_account.py new file mode 100644 index 0000000..0be7382 --- /dev/null +++ b/tests/test_restricted_agent_account.py @@ -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() diff --git a/workplans/RAIL-HO-WP-0014-custodian-agt-restricted-host-account.md b/workplans/RAIL-HO-WP-0014-custodian-agt-restricted-host-account.md new file mode 100644 index 0000000..22e66cf --- /dev/null +++ b/workplans/RAIL-HO-WP-0014-custodian-agt-restricted-host-account.md @@ -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/`, + 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.