RAIL-HO-WP-0014: restricted custodian-agt account for agt-custodian (CUST-WP-0073-T02)
All checks were successful
CI Smoke / source-contract (push) Successful in 12s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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:
codex 2026-10-20 13:46:10 +02:00
parent 8b7ade9f66
commit c62b187559
11 changed files with 704 additions and 2 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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 %}

View 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
View 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"

View 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()

View file

@ -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.