From 37ed8b012ba72870e95e003fb4c59dc690b46b0d Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 20 Oct 2026 23:50:14 +0200 Subject: [PATCH] RAIL-HO-WP-0014: explicit allowed supplementary groups for restricted agents (gexec-review) 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 605711@bnt-lap001 Assistant-Session: 1f5e2256-181f-49ed-b7b1-a1c34bf37a61 --- ansible/inventory/ssh_principals.yaml | 6 ++++ ansible/roles/ssh_ca_host/tasks/main.yml | 31 ++++++++++++++++++-- goss/restricted-agent.yaml.j2 | 5 +++- tests/fixtures/restricted_agent_container.sh | 4 +++ tests/test_restricted_agent_account.py | 16 ++++++++-- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/ansible/inventory/ssh_principals.yaml b/ansible/inventory/ssh_principals.yaml index 952d120..c07c4d2 100644 --- a/ansible/inventory/ssh_principals.yaml +++ b/ansible/inventory/ssh_principals.yaml @@ -28,3 +28,9 @@ ssh_principals: # forwarding/tunnel/user RC). Each must also appear under users above. restricted_accounts: - 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 diff --git a/ansible/roles/ssh_ca_host/tasks/main.yml b/ansible/roles/ssh_ca_host/tasks/main.yml index 215a242..5669347 100644 --- a/ansible/roles/ssh_ca_host/tasks/main.yml +++ b/ansible/roles/ssh_ca_host/tasks/main.yml @@ -79,6 +79,33 @@ 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 tags: [ssh_ca, restricted_agent_account] ansible.builtin.assert: @@ -99,12 +126,12 @@ state: present 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] ansible.builtin.user: name: "{{ item }}" group: "{{ item }}" - groups: [] + groups: "{{ ssh_ca_host_restricted_groups.get(item, []) }}" append: false password: "!" update_password: always diff --git a/goss/restricted-agent.yaml.j2 b/goss/restricted-agent.yaml.j2 index a13ddf5..9a26bd2 100644 --- a/goss/restricted-agent.yaml.j2 +++ b/goss/restricted-agent.yaml.j2 @@ -19,6 +19,9 @@ user: # goss treats this as containment; "id -nG" below asserts the exact set. groups: - {{ account }} +{% for group in ssh_ca_host_restricted_groups.get(account, []) %} + - {{ group }} +{% endfor %} {% endfor %} file: @@ -65,7 +68,7 @@ command: "id -nG {{ account }}": exit-status: 0 stdout: - - "/^{{ account }}$/" + - "/^{{ ([account] + ssh_ca_host_restricted_groups.get(account, [])) | join(' ') }}$/" {% for group in ssh_ca_host_restricted_agent_forbidden_groups %} - "!/\\b{{ group }}\\b/" {% endfor %} diff --git a/tests/fixtures/restricted_agent_container.sh b/tests/fixtures/restricted_agent_container.sh index 8b96dc9..bafd63b 100755 --- a/tests/fixtures/restricted_agent_container.sh +++ b/tests/fixtures/restricted_agent_container.sh @@ -23,6 +23,9 @@ 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 +# 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 echo agt-custodian > /etc/ssh/auth_principals/custodian-agt 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 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 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" diff --git a/tests/test_restricted_agent_account.py b/tests/test_restricted_agent_account.py index 0be7382..fcb2b0c 100644 --- a/tests/test_restricted_agent_account.py +++ b/tests/test_restricted_agent_account.py @@ -57,6 +57,7 @@ def railiance01_context() -> dict: ctx = dict(_load(ROLE / "defaults" / "main.yml")) ctx["ssh_ca_host_restricted_accounts"] = host["restricted_accounts"] ctx["ssh_ca_host_principals"] = host["users"] + ctx["ssh_ca_host_restricted_groups"] = host.get("restricted_account_groups", {}) return ctx @@ -101,15 +102,22 @@ class RestrictedAgentRoleTests(unittest.TestCase): 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)")[ + def test_account_has_locked_password_and_only_allowed_groups(self) -> None: + user = self._task("Ensure restricted agent account (locked password, only allowed supplementary groups)")[ "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.assertEqual(user["password"], "!") 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: home = self._task("Restrict restricted agent home to the account (0700)")["ansible.builtin.file"] self.assertEqual(home["mode"], "0700") @@ -157,6 +165,8 @@ class RestrictedAgentRenderTests(unittest.TestCase): commands = spec["command"] self.assertIn(f"sudo -n -l -U {ACCOUNT}", commands) 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: self.assertIn(f"!/\\b{group}\\b/", idg) self.assertTrue(any("k3s.yaml" in c and "runuser" in c for c in commands))