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 <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
6c3a4164b7
commit
37ed8b012b
5 changed files with 56 additions and 6 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
4
tests/fixtures/restricted_agent_container.sh
vendored
4
tests/fixtures/restricted_agent_container.sh
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue