RAIL-HO-WP-0014: explicit allowed supplementary groups for restricted agents (gexec-review)
All checks were successful
CI Smoke / source-contract (push) Successful in 14s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

The account task set groups: [] with append: false, so the next run would
have removed custodian-agt from gated-executor's exchange group. Allowed
groups are now an explicit per-account inventory list, refused if forbidden,
and asserted as the exact set by goss; container test covers an unlisted group.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 605711@bnt-lap001
Assistant-Session: 1f5e2256-181f-49ed-b7b1-a1c34bf37a61
This commit is contained in:
codex 2026-10-20 23:50:14 +02:00
parent 6c3a4164b7
commit 37ed8b012b
5 changed files with 56 additions and 6 deletions

View file

@ -28,3 +28,9 @@ ssh_principals:
# forwarding/tunnel/user RC). Each must also appear under users above. # forwarding/tunnel/user RC). Each must also appear under users above.
restricted_accounts: restricted_accounts:
- custodian-agt - custodian-agt
# The only supplementary groups a restricted account may hold. gexec-review
# lets the agent exchange proposals/approvals with gated-executor
# (GEXEC-WP-0001); it carries no privilege. Asserted as the exact set.
restricted_account_groups:
custodian-agt:
- gexec-review

View file

@ -79,6 +79,33 @@
else []) else [])
}} }}
- name: Resolve allowed supplementary groups of restricted agent accounts
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.set_fact:
ssh_ca_host_restricted_groups: >-
{{
(ssh_principals[inventory_hostname].restricted_account_groups | default({})
if ssh_principals is defined
and inventory_hostname in ssh_principals
else {})
}}
- name: Refuse forbidden groups in the allowed supplementary group list
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.assert:
that:
- (ssh_ca_host_restricted_groups.get(item, []) | intersect(ssh_ca_host_restricted_agent_forbidden_groups)) | length == 0
fail_msg: "Restricted account {{ item }} lists a forbidden group."
loop: "{{ ssh_ca_host_restricted_accounts }}"
- name: Ensure allowed supplementary groups exist
tags: [ssh_ca, restricted_agent_account]
ansible.builtin.group:
name: "{{ item }}"
system: true
state: present
loop: "{{ ssh_ca_host_restricted_groups.values() | flatten | unique }}"
- name: Require restricted accounts to be certificate-mapped and distinct from operators - name: Require restricted accounts to be certificate-mapped and distinct from operators
tags: [ssh_ca, restricted_agent_account] tags: [ssh_ca, restricted_agent_account]
ansible.builtin.assert: ansible.builtin.assert:
@ -99,12 +126,12 @@
state: present state: present
loop: "{{ ssh_ca_host_restricted_accounts }}" loop: "{{ ssh_ca_host_restricted_accounts }}"
- name: Ensure restricted agent account (locked password, no supplementary groups) - name: Ensure restricted agent account (locked password, only allowed supplementary groups)
tags: [ssh_ca, restricted_agent_account] tags: [ssh_ca, restricted_agent_account]
ansible.builtin.user: ansible.builtin.user:
name: "{{ item }}" name: "{{ item }}"
group: "{{ item }}" group: "{{ item }}"
groups: [] groups: "{{ ssh_ca_host_restricted_groups.get(item, []) }}"
append: false append: false
password: "!" password: "!"
update_password: always update_password: always

View file

@ -19,6 +19,9 @@ user:
# goss treats this as containment; "id -nG" below asserts the exact set. # goss treats this as containment; "id -nG" below asserts the exact set.
groups: groups:
- {{ account }} - {{ account }}
{% for group in ssh_ca_host_restricted_groups.get(account, []) %}
- {{ group }}
{% endfor %}
{% endfor %} {% endfor %}
file: file:
@ -65,7 +68,7 @@ command:
"id -nG {{ account }}": "id -nG {{ account }}":
exit-status: 0 exit-status: 0
stdout: stdout:
- "/^{{ account }}$/" - "/^{{ ([account] + ssh_ca_host_restricted_groups.get(account, [])) | join(' ') }}$/"
{% for group in ssh_ca_host_restricted_agent_forbidden_groups %} {% for group in ssh_ca_host_restricted_agent_forbidden_groups %}
- "!/\\b{{ group }}\\b/" - "!/\\b{{ group }}\\b/"
{% endfor %} {% endfor %}

View file

@ -23,6 +23,9 @@ echo 'tegwick ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/tegwick
groupadd custodian-agt groupadd custodian-agt
useradd -m -g custodian-agt -s /bin/bash -d /home/custodian-agt custodian-agt useradd -m -g custodian-agt -s /bin/bash -d /home/custodian-agt custodian-agt
usermod -p '!' custodian-agt usermod -p '!' custodian-agt
# Mirror ssh_principals restricted_account_groups (gated-executor exchange group).
groupadd --system gexec-review
usermod -aG gexec-review custodian-agt
chmod 0700 /home/custodian-agt chmod 0700 /home/custodian-agt
echo agt-custodian > /etc/ssh/auth_principals/custodian-agt echo agt-custodian > /etc/ssh/auth_principals/custodian-agt
echo 'apiVersion: v1' > /etc/rancher/k3s/k3s.yaml echo 'apiVersion: v1' > /etc/rancher/k3s/k3s.yaml
@ -40,6 +43,7 @@ expect_fail() {
} }
chmod 0644 /etc/rancher/k3s/k3s.yaml; expect_fail "k3s.yaml world-readable"; chmod 0600 /etc/rancher/k3s/k3s.yaml 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 usermod -aG sudo custodian-agt; expect_fail "sudo group"; gpasswd -d custodian-agt sudo >/dev/null
groupadd unlisted-extra; usermod -aG unlisted-extra custodian-agt; expect_fail "unlisted supplementary group"; gpasswd -d custodian-agt unlisted-extra >/dev/null
echo 'custodian-agt ALL=(ALL) ALL' > /etc/sudoers.d/custodian-agt; expect_fail "sudoers entry"; rm /etc/sudoers.d/custodian-agt 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 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" expect_fail "forwarding re-enabled"

View file

@ -57,6 +57,7 @@ def railiance01_context() -> dict:
ctx = dict(_load(ROLE / "defaults" / "main.yml")) ctx = dict(_load(ROLE / "defaults" / "main.yml"))
ctx["ssh_ca_host_restricted_accounts"] = host["restricted_accounts"] ctx["ssh_ca_host_restricted_accounts"] = host["restricted_accounts"]
ctx["ssh_ca_host_principals"] = host["users"] ctx["ssh_ca_host_principals"] = host["users"]
ctx["ssh_ca_host_restricted_groups"] = host.get("restricted_account_groups", {})
return ctx return ctx
@ -101,15 +102,22 @@ class RestrictedAgentRoleTests(unittest.TestCase):
self.assertEqual(len(matches), 1, name) self.assertEqual(len(matches), 1, name)
return matches[0] return matches[0]
def test_account_has_locked_password_and_no_supplementary_groups(self) -> None: def test_account_has_locked_password_and_only_allowed_groups(self) -> None:
user = self._task("Ensure restricted agent account (locked password, no supplementary groups)")[ user = self._task("Ensure restricted agent account (locked password, only allowed supplementary groups)")[
"ansible.builtin.user" "ansible.builtin.user"
] ]
self.assertEqual(user["groups"], []) # Groups come only from the explicit per-account inventory list.
self.assertEqual(user["groups"], "{{ ssh_ca_host_restricted_groups.get(item, []) }}")
self.assertIs(user["append"], False) self.assertIs(user["append"], False)
self.assertEqual(user["password"], "!") self.assertEqual(user["password"], "!")
self.assertEqual(user["update_password"], "always") self.assertEqual(user["update_password"], "always")
def test_allowed_groups_are_explicit_and_not_forbidden(self) -> None:
groups = railiance01_context()["ssh_ca_host_restricted_groups"]
self.assertEqual(groups, {ACCOUNT: ["gexec-review"]})
for allowed in groups[ACCOUNT]:
self.assertNotIn(allowed, REQUIRED_FORBIDDEN_GROUPS)
def test_home_0700_no_sudoers_no_authorized_keys(self) -> None: 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"] home = self._task("Restrict restricted agent home to the account (0700)")["ansible.builtin.file"]
self.assertEqual(home["mode"], "0700") self.assertEqual(home["mode"], "0700")
@ -157,6 +165,8 @@ class RestrictedAgentRenderTests(unittest.TestCase):
commands = spec["command"] commands = spec["command"]
self.assertIn(f"sudo -n -l -U {ACCOUNT}", commands) self.assertIn(f"sudo -n -l -U {ACCOUNT}", commands)
idg = commands[f"id -nG {ACCOUNT}"]["stdout"] idg = commands[f"id -nG {ACCOUNT}"]["stdout"]
# Exact set: the account's own group plus gexec-review, nothing else.
self.assertIn(f"/^{ACCOUNT} gexec-review$/", idg)
for group in REQUIRED_FORBIDDEN_GROUPS: for group in REQUIRED_FORBIDDEN_GROUPS:
self.assertIn(f"!/\\b{group}\\b/", idg) self.assertIn(f"!/\\b{group}\\b/", idg)
self.assertTrue(any("k3s.yaml" in c and "runuser" in c for c in commands)) self.assertTrue(any("k3s.yaml" in c and "runuser" in c for c in commands))