railiance-infra/tests/test_restricted_agent_account.py
codex 37ed8b012b
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
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
2026-10-20 23:50:14 +02:00

205 lines
9.4 KiB
Python

"""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"]
ctx["ssh_ca_host_restricted_groups"] = host.get("restricted_account_groups", {})
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_only_allowed_groups(self) -> None:
user = self._task("Ensure restricted agent account (locked password, only allowed supplementary groups)")[
"ansible.builtin.user"
]
# 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")
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"]
# 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))
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()