From ff37b57718c6e41c83c78646ab71115c339d8e2b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 21 Oct 2026 09:42:39 +0000 Subject: [PATCH] policy/v2, servertest: add group source split across rules captures SaaS judges a tests or sshTests group source by one rule naming the group: per-member rules fail a true accept and pass a false deny. Headscale checks each member through its own rules. --- .../policy/v2/policytester_compat_test.go | 2 + hscontrol/policy/v2/sshtester_compat_test.go | 3 + .../policytest-src-split-across-rules.hujson | 188 +++++++++++++++++ .../sshtest-group-split-actions-accept.hujson | 192 ++++++++++++++++++ .../sshtest-group-split-actions-check.hujson | 192 ++++++++++++++++++ .../sshtest-group-split-actions-deny.hujson | 186 +++++++++++++++++ hscontrol/servertest/policy_test.go | 3 + 7 files changed, 766 insertions(+) create mode 100644 hscontrol/policy/v2/testdata/policytest_results/policytest-src-split-across-rules.hujson create mode 100644 hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-accept.hujson create mode 100644 hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-check.hujson create mode 100644 hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-deny.hujson diff --git a/hscontrol/policy/v2/policytester_compat_test.go b/hscontrol/policy/v2/policytester_compat_test.go index 56fc0d1bd..58fdae639 100644 --- a/hscontrol/policy/v2/policytester_compat_test.go +++ b/hscontrol/policy/v2/policytester_compat_test.go @@ -43,6 +43,8 @@ var knownPolicyTesterDivergences = map[string]string{ //nolint:gosec // strings "policytest-dst-unknown-group/breakdown": testUnknownAlias, "policytest-src-unknown-group/breakdown": testUnknownAlias, "policytest-src-unknown-user-email/breakdown": testUnknownAlias, + + "policytest-src-split-across-rules/verdict": "TEST_SRC_ONE_RULE: SaaS needs one rule to cover a group source; headscale lets each member pass through its own rule", } const ( diff --git a/hscontrol/policy/v2/sshtester_compat_test.go b/hscontrol/policy/v2/sshtester_compat_test.go index 3e2e0ff31..0ad18b5e0 100644 --- a/hscontrol/policy/v2/sshtester_compat_test.go +++ b/hscontrol/policy/v2/sshtester_compat_test.go @@ -16,6 +16,9 @@ import ( // headscale and upstream disagree, keyed /. var knownSSHTesterDivergences = map[string]string{ "sshtest-malformed-dst-bare-ipv6/verdict": "bare-IPv6 sshTests dst: upstream parse-accepts then engine-rejects; headscale accepts (IPv4 mirror passes both sides)", + + "sshtest-group-split-actions-accept/verdict": "SSHTEST_SRC_ONE_RULE: SaaS needs one accept rule to cover a group source; headscale lets each member pass through its own accept or check rule", + "sshtest-group-split-actions-deny/verdict": "SSHTEST_SRC_ONE_RULE: SaaS denies a group source no single rule covers; headscale fails the deny because each member is let in", } func TestSSHTesterCompat(t *testing.T) { diff --git a/hscontrol/policy/v2/testdata/policytest_results/policytest-src-split-across-rules.hujson b/hscontrol/policy/v2/testdata/policytest_results/policytest-src-split-across-rules.hujson new file mode 100644 index 000000000..259567632 --- /dev/null +++ b/hscontrol/policy/v2/testdata/policytest_results/policytest-src-split-across-rules.hujson @@ -0,0 +1,188 @@ +// policytest-src-split-across-rules +// +// tests src split across rules +// +// Steps: 1 +// Captured at: 2026-10-17T15:35:13Z +// tool version: tscap/e3511f1 +// schema version: 1 +{ + "schema_version": 1, + "test_id": "policytest-src-split-across-rules", + "description": "tests src split across rules", + "category": "policytest", + "captured_at": "2026-10-17T15:35:13.947754671Z", + "tool_version": "tscap/e3511f1", + "tailnet": "odin@example.com", + "error": true, + "input": { + "api_response_code": 400, + "api_response_body": {"message": "test(s) failed", "data": [{"user": "group:g", "errors": [ + "[acl test error]: address \"tag:server:22\" (protocol \"tcp\"): want: Accept, got: Drop" + ]}]}, + "tailnet": { + "dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}}, + "settings": {} + }, + "scenario_hujson": " \n \n \n{\n\t\"id\": \"policytest-src-split-across-rules\",\n\t\"category\": \"policytest\",\n\t\"description\": \"tests src split across rules\",\n\t\"topology\": \"../../_topologies/grant.hujson\",\n\t\"tailnet\": {\n\t\t\"dns\": {\"magic_dns\": false, \"nameservers\": [], \"search_paths\": [], \"split_dns\": {}},\n\t\t\"settings\": {}\n\t},\n\t\"options\": {\"capture\": \"none\"},\n\t\"policy\": {\n\t\t\"groups\": {\"group:g\": [\"thor@example.org\", \"odin@example.com\"]},\n\t\t\"tagOwners\": {\"tag:server\": [\"odin@example.com\"]},\n\t\t\"acls\": [{\n\t\t\t\"action\": \"accept\",\n\t\t\t\"src\": [\"thor@example.org\"],\n\t\t\t\"dst\": [\"tag:server:22\"]\n\t\t}, {\n\t\t\t\"action\": \"accept\",\n\t\t\t\"src\": [\"odin@example.com\"],\n\t\t\t\"dst\": [\"tag:server:22\"]\n\t\t}],\n\t\t\"tests\": [{\"src\": \"group:g\", \"accept\": [\"tag:server:22\"]}]\n\t}\n}\n", + "scenario_path": "scenarios/grant-matrix/grant/policytest-src-split-across-rules.hujson", + "full_policy": { + "acls": [{ + "action": "accept", + "dst": ["tag:server:22"], + "src": ["thor@example.org"] + }, { + "action": "accept", + "dst": ["tag:server:22"], + "src": ["odin@example.com"] + }], + "groups": {"group:g": ["thor@example.org", "odin@example.com"]}, + "tagOwners": {"tag:server": ["odin@example.com"]}, + "tests": [{"accept": ["tag:server:22"], "src": "group:g"}] + } + }, + "topology": {"users": [ + {"id": 1, "name": "odin", "email": "odin@example.com"}, + {"id": 2, "name": "thor", "email": "thor@example.org"}, + {"id": 3, "name": "freya", "email": "freya@example.com"} + ], "nodes": {"beedrill": { + "hostname": "beedrill", + "tags": ["tag:server"], + "ipv4": "100.64.0.16", + "ipv6": "fd7a:115c:a1e0::10", + "routable_ips": [], + "approved_routes": [] + }, "blastoise": { + "hostname": "blastoise", + "tags": ["tag:exit", "tag:router"], + "ipv4": "100.64.0.9", + "ipv6": "fd7a:115c:a1e0::9", + "routable_ips": ["10.33.0.0/16", "0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "bulbasaur": { + "hostname": "bulbasaur", + "tags": [], + "ipv4": "100.64.0.19", + "ipv6": "fd7a:115c:a1e0::13", + "user": "odin", + "routable_ips": [], + "approved_routes": [] + }, "charmander": { + "hostname": "charmander", + "tags": ["tag:exit"], + "ipv4": "100.64.0.4", + "ipv6": "fd7a:115c:a1e0::4", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "fearow": { + "hostname": "fearow", + "tags": ["tag:fearow"], + "ipv4": "100.64.0.12", + "ipv6": "fd7a:115c:a1e0::c", + "routable_ips": ["10.55.0.0/16"], + "approved_routes": [] + }, "ivysaur": { + "hostname": "ivysaur", + "tags": [], + "ipv4": "100.64.0.17", + "ipv6": "fd7a:115c:a1e0::11", + "user": "thor", + "routable_ips": [], + "approved_routes": [] + }, "kakuna": { + "hostname": "kakuna", + "tags": ["tag:prod"], + "ipv4": "100.64.0.15", + "ipv6": "fd7a:115c:a1e0::f", + "routable_ips": [], + "approved_routes": [] + }, "pidgeotto": { + "hostname": "pidgeotto", + "tags": ["tag:pidgeotto"], + "ipv4": "100.64.0.3", + "ipv6": "fd7a:115c:a1e0::3", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "pidgey": { + "hostname": "pidgey", + "tags": ["tag:pidgey"], + "ipv4": "100.64.0.2", + "ipv6": "fd7a:115c:a1e0::2", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "raticate": { + "hostname": "raticate", + "tags": ["tag:group-b"], + "ipv4": "100.64.0.6", + "ipv6": "fd7a:115c:a1e0::6", + "routable_ips": [], + "approved_routes": [] + }, "rattata": { + "hostname": "rattata", + "tags": ["tag:group-a"], + "ipv4": "100.64.0.5", + "ipv6": "fd7a:115c:a1e0::5", + "routable_ips": [], + "approved_routes": [] + }, "spearow": { + "hostname": "spearow", + "tags": ["tag:spearow"], + "ipv4": "100.64.0.11", + "ipv6": "fd7a:115c:a1e0::b", + "routable_ips": ["10.44.0.0/16"], + "approved_routes": [] + }, "squirtle": { + "hostname": "squirtle", + "tags": ["tag:router"], + "ipv4": "100.64.0.13", + "ipv6": "fd7a:115c:a1e0::d", + "routable_ips": ["10.33.0.0/16"], + "approved_routes": [] + }, "venusaur": { + "hostname": "venusaur", + "tags": [], + "ipv4": "100.64.0.18", + "ipv6": "fd7a:115c:a1e0::12", + "user": "freya", + "routable_ips": [], + "approved_routes": [] + }, "weedle": { + "hostname": "weedle", + "tags": ["tag:client"], + "ipv4": "100.64.0.14", + "ipv6": "fd7a:115c:a1e0::e", + "routable_ips": [], + "approved_routes": [] + }}}, + "captures": {}, + "steps": [{ + "name": "validate", + "at": "2026-10-17T15:35:19.779715243Z", + "result": "observed", + "actions": [{ + "kind": "api", + "principal": "admin", + "method": "POST", + "path": "/tailnet/-/acl/validate", + "header": {"Content-Type": "application/hujson"}, + "body": { + "acls": [{ + "action": "accept", + "dst": ["tag:server:22"], + "src": ["thor@example.org"] + }, { + "action": "accept", + "dst": ["tag:server:22"], + "src": ["odin@example.com"] + }], + "groups": {"group:g": ["thor@example.org", "odin@example.com"]}, + "tagOwners": {"tag:server": ["odin@example.com"]}, + "tests": [{"accept": ["tag:server:22"], "src": "group:g"}] + }, + "status": 200, + "response": {"data": [{"errors": [ + "[acl test error]: address \"tag:server:22\" (protocol \"tcp\"): want: Accept, got: Drop" + ], "user": "group:g"}], "message": "test(s) failed"} + }] + }] +} diff --git a/hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-accept.hujson b/hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-accept.hujson new file mode 100644 index 000000000..e6b9fffc7 --- /dev/null +++ b/hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-accept.hujson @@ -0,0 +1,192 @@ +// sshtest-group-split-actions-accept +// +// sshTests group across accept and check rules, accept +// +// Steps: 1 +// Captured at: 2026-10-17T15:38:51Z +// tool version: tscap/e3511f1 +// schema version: 1 +{ + "schema_version": 1, + "test_id": "sshtest-group-split-actions-accept", + "description": "sshTests group across accept and check rules, accept", + "category": "sshtest", + "captured_at": "2026-10-17T15:38:51.396983195Z", + "tool_version": "tscap/e3511f1", + "tailnet": "odin@example.com", + "error": true, + "input": { + "api_response_code": 400, + "api_response_body": {"message": "test(s) failed", "data": [{"user": "group:h", "errors": [ + "[ssh test error]: src \"group:h\" at \"100.64.0.0\", dst \"tag:server\" at \"100.64.0.1\", ssh user \"root\": want: \"accept\" got: \"deny\"" + ]}]}, + "tailnet": { + "dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}}, + "settings": {} + }, + "scenario_hujson": " \n \n \n \n{\n\t\"id\": \"sshtest-group-split-actions-accept\",\n\t\"category\": \"sshtest\",\n\t\"description\": \"sshTests group across accept and check rules, accept\",\n\t\"topology\": \"../../_topologies/grant.hujson\",\n\t\"tailnet\": {\n\t\t\"dns\": {\"magic_dns\": false, \"nameservers\": [], \"search_paths\": [], \"split_dns\": {}},\n\t\t\"settings\": {}\n\t},\n\t\"options\": {\"capture\": \"none\"},\n\t\"policy\": {\n\t\t\"groups\": {\"group:h\": [\"thor@example.org\", \"freya@example.com\"]},\n\t\t\"tagOwners\": {\"tag:server\": [\"odin@example.com\"]},\n\t\t\"ssh\": [{\n\t\t\t\"action\": \"accept\",\n\t\t\t\"src\": [\"thor@example.org\"],\n\t\t\t\"dst\": [\"tag:server\"],\n\t\t\t\"users\": [\"root\"]\n\t\t}, {\n\t\t\t\"action\": \"check\",\n\t\t\t\"src\": [\"freya@example.com\"],\n\t\t\t\"dst\": [\"tag:server\"],\n\t\t\t\"users\": [\"root\"]\n\t\t}],\n\t\t\"sshTests\": [{\"src\": \"group:h\", \"dst\": [\"tag:server\"], \"accept\": [\"root\"]}]\n\t}\n}\n", + "scenario_path": "scenarios/grant-matrix/grant/sshtest-group-split-actions-accept.hujson", + "full_policy": { + "groups": {"group:h": ["thor@example.org", "freya@example.com"]}, + "ssh": [{ + "action": "accept", + "dst": ["tag:server"], + "src": ["thor@example.org"], + "users": ["root"] + }, { + "action": "check", + "dst": ["tag:server"], + "src": ["freya@example.com"], + "users": ["root"] + }], + "sshTests": [{"accept": ["root"], "dst": ["tag:server"], "src": "group:h"}], + "tagOwners": {"tag:server": ["odin@example.com"]} + } + }, + "topology": {"users": [ + {"id": 1, "name": "odin", "email": "odin@example.com"}, + {"id": 2, "name": "thor", "email": "thor@example.org"}, + {"id": 3, "name": "freya", "email": "freya@example.com"} + ], "nodes": {"beedrill": { + "hostname": "beedrill", + "tags": ["tag:server"], + "ipv4": "100.64.0.16", + "ipv6": "fd7a:115c:a1e0::10", + "routable_ips": [], + "approved_routes": [] + }, "blastoise": { + "hostname": "blastoise", + "tags": ["tag:exit", "tag:router"], + "ipv4": "100.64.0.9", + "ipv6": "fd7a:115c:a1e0::9", + "routable_ips": ["10.33.0.0/16", "0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "bulbasaur": { + "hostname": "bulbasaur", + "tags": [], + "ipv4": "100.64.0.19", + "ipv6": "fd7a:115c:a1e0::13", + "user": "odin", + "routable_ips": [], + "approved_routes": [] + }, "charmander": { + "hostname": "charmander", + "tags": ["tag:exit"], + "ipv4": "100.64.0.4", + "ipv6": "fd7a:115c:a1e0::4", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "fearow": { + "hostname": "fearow", + "tags": ["tag:fearow"], + "ipv4": "100.64.0.12", + "ipv6": "fd7a:115c:a1e0::c", + "routable_ips": ["10.55.0.0/16"], + "approved_routes": [] + }, "ivysaur": { + "hostname": "ivysaur", + "tags": [], + "ipv4": "100.64.0.17", + "ipv6": "fd7a:115c:a1e0::11", + "user": "thor", + "routable_ips": [], + "approved_routes": [] + }, "kakuna": { + "hostname": "kakuna", + "tags": ["tag:prod"], + "ipv4": "100.64.0.15", + "ipv6": "fd7a:115c:a1e0::f", + "routable_ips": [], + "approved_routes": [] + }, "pidgeotto": { + "hostname": "pidgeotto", + "tags": ["tag:pidgeotto"], + "ipv4": "100.64.0.3", + "ipv6": "fd7a:115c:a1e0::3", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "pidgey": { + "hostname": "pidgey", + "tags": ["tag:pidgey"], + "ipv4": "100.64.0.2", + "ipv6": "fd7a:115c:a1e0::2", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "raticate": { + "hostname": "raticate", + "tags": ["tag:group-b"], + "ipv4": "100.64.0.6", + "ipv6": "fd7a:115c:a1e0::6", + "routable_ips": [], + "approved_routes": [] + }, "rattata": { + "hostname": "rattata", + "tags": ["tag:group-a"], + "ipv4": "100.64.0.5", + "ipv6": "fd7a:115c:a1e0::5", + "routable_ips": [], + "approved_routes": [] + }, "spearow": { + "hostname": "spearow", + "tags": ["tag:spearow"], + "ipv4": "100.64.0.11", + "ipv6": "fd7a:115c:a1e0::b", + "routable_ips": ["10.44.0.0/16"], + "approved_routes": [] + }, "squirtle": { + "hostname": "squirtle", + "tags": ["tag:router"], + "ipv4": "100.64.0.13", + "ipv6": "fd7a:115c:a1e0::d", + "routable_ips": ["10.33.0.0/16"], + "approved_routes": [] + }, "venusaur": { + "hostname": "venusaur", + "tags": [], + "ipv4": "100.64.0.18", + "ipv6": "fd7a:115c:a1e0::12", + "user": "freya", + "routable_ips": [], + "approved_routes": [] + }, "weedle": { + "hostname": "weedle", + "tags": ["tag:client"], + "ipv4": "100.64.0.14", + "ipv6": "fd7a:115c:a1e0::e", + "routable_ips": [], + "approved_routes": [] + }}}, + "captures": {}, + "steps": [{ + "name": "validate", + "at": "2026-10-17T15:38:54.859552188Z", + "result": "observed", + "actions": [{ + "kind": "api", + "principal": "admin", + "method": "POST", + "path": "/tailnet/-/acl/validate", + "header": {"Content-Type": "application/hujson"}, + "body": { + "groups": {"group:h": ["thor@example.org", "freya@example.com"]}, + "ssh": [{ + "action": "accept", + "dst": ["tag:server"], + "src": ["thor@example.org"], + "users": ["root"] + }, { + "action": "check", + "dst": ["tag:server"], + "src": ["freya@example.com"], + "users": ["root"] + }], + "sshTests": [{"accept": ["root"], "dst": ["tag:server"], "src": "group:h"}], + "tagOwners": {"tag:server": ["odin@example.com"]} + }, + "status": 200, + "response": {"data": [{"errors": [ + "[ssh test error]: src \"group:h\" at \"100.64.0.0\", dst \"tag:server\" at \"100.64.0.1\", ssh user \"root\": want: \"accept\" got: \"deny\"" + ], "user": "group:h"}], "message": "test(s) failed"} + }] + }] +} diff --git a/hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-check.hujson b/hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-check.hujson new file mode 100644 index 000000000..2b71f308a --- /dev/null +++ b/hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-check.hujson @@ -0,0 +1,192 @@ +// sshtest-group-split-actions-check +// +// sshTests group across accept and check rules, check +// +// Steps: 1 +// Captured at: 2026-10-17T15:38:58Z +// tool version: tscap/e3511f1 +// schema version: 1 +{ + "schema_version": 1, + "test_id": "sshtest-group-split-actions-check", + "description": "sshTests group across accept and check rules, check", + "category": "sshtest", + "captured_at": "2026-10-17T15:38:58.89973761Z", + "tool_version": "tscap/e3511f1", + "tailnet": "odin@example.com", + "error": true, + "input": { + "api_response_code": 400, + "api_response_body": {"message": "test(s) failed", "data": [{"user": "group:h", "errors": [ + "[ssh test error]: src \"group:h\" at \"100.64.0.0\", dst \"tag:server\" at \"100.64.0.1\", ssh user \"root\": want: \"check\" got: \"deny\"" + ]}]}, + "tailnet": { + "dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}}, + "settings": {} + }, + "scenario_hujson": " \n \n \n \n{\n\t\"id\": \"sshtest-group-split-actions-check\",\n\t\"category\": \"sshtest\",\n\t\"description\": \"sshTests group across accept and check rules, check\",\n\t\"topology\": \"../../_topologies/grant.hujson\",\n\t\"tailnet\": {\n\t\t\"dns\": {\"magic_dns\": false, \"nameservers\": [], \"search_paths\": [], \"split_dns\": {}},\n\t\t\"settings\": {}\n\t},\n\t\"options\": {\"capture\": \"none\"},\n\t\"policy\": {\n\t\t\"groups\": {\"group:h\": [\"thor@example.org\", \"freya@example.com\"]},\n\t\t\"tagOwners\": {\"tag:server\": [\"odin@example.com\"]},\n\t\t\"ssh\": [{\n\t\t\t\"action\": \"accept\",\n\t\t\t\"src\": [\"thor@example.org\"],\n\t\t\t\"dst\": [\"tag:server\"],\n\t\t\t\"users\": [\"root\"]\n\t\t}, {\n\t\t\t\"action\": \"check\",\n\t\t\t\"src\": [\"freya@example.com\"],\n\t\t\t\"dst\": [\"tag:server\"],\n\t\t\t\"users\": [\"root\"]\n\t\t}],\n\t\t\"sshTests\": [{\"src\": \"group:h\", \"dst\": [\"tag:server\"], \"check\": [\"root\"]}]\n\t}\n}\n", + "scenario_path": "scenarios/grant-matrix/grant/sshtest-group-split-actions-check.hujson", + "full_policy": { + "groups": {"group:h": ["thor@example.org", "freya@example.com"]}, + "ssh": [{ + "action": "accept", + "dst": ["tag:server"], + "src": ["thor@example.org"], + "users": ["root"] + }, { + "action": "check", + "dst": ["tag:server"], + "src": ["freya@example.com"], + "users": ["root"] + }], + "sshTests": [{"check": ["root"], "dst": ["tag:server"], "src": "group:h"}], + "tagOwners": {"tag:server": ["odin@example.com"]} + } + }, + "topology": {"users": [ + {"id": 1, "name": "odin", "email": "odin@example.com"}, + {"id": 2, "name": "thor", "email": "thor@example.org"}, + {"id": 3, "name": "freya", "email": "freya@example.com"} + ], "nodes": {"beedrill": { + "hostname": "beedrill", + "tags": ["tag:server"], + "ipv4": "100.64.0.16", + "ipv6": "fd7a:115c:a1e0::10", + "routable_ips": [], + "approved_routes": [] + }, "blastoise": { + "hostname": "blastoise", + "tags": ["tag:exit", "tag:router"], + "ipv4": "100.64.0.9", + "ipv6": "fd7a:115c:a1e0::9", + "routable_ips": ["10.33.0.0/16", "0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "bulbasaur": { + "hostname": "bulbasaur", + "tags": [], + "ipv4": "100.64.0.19", + "ipv6": "fd7a:115c:a1e0::13", + "user": "odin", + "routable_ips": [], + "approved_routes": [] + }, "charmander": { + "hostname": "charmander", + "tags": ["tag:exit"], + "ipv4": "100.64.0.4", + "ipv6": "fd7a:115c:a1e0::4", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "fearow": { + "hostname": "fearow", + "tags": ["tag:fearow"], + "ipv4": "100.64.0.12", + "ipv6": "fd7a:115c:a1e0::c", + "routable_ips": ["10.55.0.0/16"], + "approved_routes": [] + }, "ivysaur": { + "hostname": "ivysaur", + "tags": [], + "ipv4": "100.64.0.17", + "ipv6": "fd7a:115c:a1e0::11", + "user": "thor", + "routable_ips": [], + "approved_routes": [] + }, "kakuna": { + "hostname": "kakuna", + "tags": ["tag:prod"], + "ipv4": "100.64.0.15", + "ipv6": "fd7a:115c:a1e0::f", + "routable_ips": [], + "approved_routes": [] + }, "pidgeotto": { + "hostname": "pidgeotto", + "tags": ["tag:pidgeotto"], + "ipv4": "100.64.0.3", + "ipv6": "fd7a:115c:a1e0::3", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "pidgey": { + "hostname": "pidgey", + "tags": ["tag:pidgey"], + "ipv4": "100.64.0.2", + "ipv6": "fd7a:115c:a1e0::2", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "raticate": { + "hostname": "raticate", + "tags": ["tag:group-b"], + "ipv4": "100.64.0.6", + "ipv6": "fd7a:115c:a1e0::6", + "routable_ips": [], + "approved_routes": [] + }, "rattata": { + "hostname": "rattata", + "tags": ["tag:group-a"], + "ipv4": "100.64.0.5", + "ipv6": "fd7a:115c:a1e0::5", + "routable_ips": [], + "approved_routes": [] + }, "spearow": { + "hostname": "spearow", + "tags": ["tag:spearow"], + "ipv4": "100.64.0.11", + "ipv6": "fd7a:115c:a1e0::b", + "routable_ips": ["10.44.0.0/16"], + "approved_routes": [] + }, "squirtle": { + "hostname": "squirtle", + "tags": ["tag:router"], + "ipv4": "100.64.0.13", + "ipv6": "fd7a:115c:a1e0::d", + "routable_ips": ["10.33.0.0/16"], + "approved_routes": [] + }, "venusaur": { + "hostname": "venusaur", + "tags": [], + "ipv4": "100.64.0.18", + "ipv6": "fd7a:115c:a1e0::12", + "user": "freya", + "routable_ips": [], + "approved_routes": [] + }, "weedle": { + "hostname": "weedle", + "tags": ["tag:client"], + "ipv4": "100.64.0.14", + "ipv6": "fd7a:115c:a1e0::e", + "routable_ips": [], + "approved_routes": [] + }}}, + "captures": {}, + "steps": [{ + "name": "validate", + "at": "2026-10-17T15:39:02.819824553Z", + "result": "observed", + "actions": [{ + "kind": "api", + "principal": "admin", + "method": "POST", + "path": "/tailnet/-/acl/validate", + "header": {"Content-Type": "application/hujson"}, + "body": { + "groups": {"group:h": ["thor@example.org", "freya@example.com"]}, + "ssh": [{ + "action": "accept", + "dst": ["tag:server"], + "src": ["thor@example.org"], + "users": ["root"] + }, { + "action": "check", + "dst": ["tag:server"], + "src": ["freya@example.com"], + "users": ["root"] + }], + "sshTests": [{"check": ["root"], "dst": ["tag:server"], "src": "group:h"}], + "tagOwners": {"tag:server": ["odin@example.com"]} + }, + "status": 200, + "response": {"data": [{"errors": [ + "[ssh test error]: src \"group:h\" at \"100.64.0.0\", dst \"tag:server\" at \"100.64.0.1\", ssh user \"root\": want: \"check\" got: \"deny\"" + ], "user": "group:h"}], "message": "test(s) failed"} + }] + }] +} diff --git a/hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-deny.hujson b/hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-deny.hujson new file mode 100644 index 000000000..bbf6611ea --- /dev/null +++ b/hscontrol/policy/v2/testdata/sshtest_results/sshtest-group-split-actions-deny.hujson @@ -0,0 +1,186 @@ +// sshtest-group-split-actions-deny +// +// sshTests group across accept and check rules, deny +// +// Steps: 1 +// Captured at: 2026-10-17T15:39:06Z +// tool version: tscap/e3511f1 +// schema version: 1 +{ + "schema_version": 1, + "test_id": "sshtest-group-split-actions-deny", + "description": "sshTests group across accept and check rules, deny", + "category": "sshtest", + "captured_at": "2026-10-17T15:39:06.85864835Z", + "tool_version": "tscap/e3511f1", + "tailnet": "odin@example.com", + "input": { + "api_response_code": 200, + "tailnet": { + "dns": {"magic_dns": false, "nameservers": [], "search_paths": [], "split_dns": {}}, + "settings": {} + }, + "scenario_hujson": " \n \n \n \n{\n\t\"id\": \"sshtest-group-split-actions-deny\",\n\t\"category\": \"sshtest\",\n\t\"description\": \"sshTests group across accept and check rules, deny\",\n\t\"topology\": \"../../_topologies/grant.hujson\",\n\t\"tailnet\": {\n\t\t\"dns\": {\"magic_dns\": false, \"nameservers\": [], \"search_paths\": [], \"split_dns\": {}},\n\t\t\"settings\": {}\n\t},\n\t\"options\": {\"capture\": \"none\"},\n\t\"policy\": {\n\t\t\"groups\": {\"group:h\": [\"thor@example.org\", \"freya@example.com\"]},\n\t\t\"tagOwners\": {\"tag:server\": [\"odin@example.com\"]},\n\t\t\"ssh\": [{\n\t\t\t\"action\": \"accept\",\n\t\t\t\"src\": [\"thor@example.org\"],\n\t\t\t\"dst\": [\"tag:server\"],\n\t\t\t\"users\": [\"root\"]\n\t\t}, {\n\t\t\t\"action\": \"check\",\n\t\t\t\"src\": [\"freya@example.com\"],\n\t\t\t\"dst\": [\"tag:server\"],\n\t\t\t\"users\": [\"root\"]\n\t\t}],\n\t\t\"sshTests\": [{\"src\": \"group:h\", \"dst\": [\"tag:server\"], \"deny\": [\"root\"]}]\n\t}\n}\n", + "scenario_path": "scenarios/grant-matrix/grant/sshtest-group-split-actions-deny.hujson", + "full_policy": { + "groups": {"group:h": ["thor@example.org", "freya@example.com"]}, + "ssh": [{ + "action": "accept", + "dst": ["tag:server"], + "src": ["thor@example.org"], + "users": ["root"] + }, { + "action": "check", + "dst": ["tag:server"], + "src": ["freya@example.com"], + "users": ["root"] + }], + "sshTests": [{"deny": ["root"], "dst": ["tag:server"], "src": "group:h"}], + "tagOwners": {"tag:server": ["odin@example.com"]} + } + }, + "topology": {"users": [ + {"id": 1, "name": "odin", "email": "odin@example.com"}, + {"id": 2, "name": "thor", "email": "thor@example.org"}, + {"id": 3, "name": "freya", "email": "freya@example.com"} + ], "nodes": {"beedrill": { + "hostname": "beedrill", + "tags": ["tag:server"], + "ipv4": "100.64.0.16", + "ipv6": "fd7a:115c:a1e0::10", + "routable_ips": [], + "approved_routes": [] + }, "blastoise": { + "hostname": "blastoise", + "tags": ["tag:exit", "tag:router"], + "ipv4": "100.64.0.9", + "ipv6": "fd7a:115c:a1e0::9", + "routable_ips": ["10.33.0.0/16", "0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "bulbasaur": { + "hostname": "bulbasaur", + "tags": [], + "ipv4": "100.64.0.19", + "ipv6": "fd7a:115c:a1e0::13", + "user": "odin", + "routable_ips": [], + "approved_routes": [] + }, "charmander": { + "hostname": "charmander", + "tags": ["tag:exit"], + "ipv4": "100.64.0.4", + "ipv6": "fd7a:115c:a1e0::4", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "fearow": { + "hostname": "fearow", + "tags": ["tag:fearow"], + "ipv4": "100.64.0.12", + "ipv6": "fd7a:115c:a1e0::c", + "routable_ips": ["10.55.0.0/16"], + "approved_routes": [] + }, "ivysaur": { + "hostname": "ivysaur", + "tags": [], + "ipv4": "100.64.0.17", + "ipv6": "fd7a:115c:a1e0::11", + "user": "thor", + "routable_ips": [], + "approved_routes": [] + }, "kakuna": { + "hostname": "kakuna", + "tags": ["tag:prod"], + "ipv4": "100.64.0.15", + "ipv6": "fd7a:115c:a1e0::f", + "routable_ips": [], + "approved_routes": [] + }, "pidgeotto": { + "hostname": "pidgeotto", + "tags": ["tag:pidgeotto"], + "ipv4": "100.64.0.3", + "ipv6": "fd7a:115c:a1e0::3", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "pidgey": { + "hostname": "pidgey", + "tags": ["tag:pidgey"], + "ipv4": "100.64.0.2", + "ipv6": "fd7a:115c:a1e0::2", + "routable_ips": ["0.0.0.0/0", "::/0"], + "approved_routes": [] + }, "raticate": { + "hostname": "raticate", + "tags": ["tag:group-b"], + "ipv4": "100.64.0.6", + "ipv6": "fd7a:115c:a1e0::6", + "routable_ips": [], + "approved_routes": [] + }, "rattata": { + "hostname": "rattata", + "tags": ["tag:group-a"], + "ipv4": "100.64.0.5", + "ipv6": "fd7a:115c:a1e0::5", + "routable_ips": [], + "approved_routes": [] + }, "spearow": { + "hostname": "spearow", + "tags": ["tag:spearow"], + "ipv4": "100.64.0.11", + "ipv6": "fd7a:115c:a1e0::b", + "routable_ips": ["10.44.0.0/16"], + "approved_routes": [] + }, "squirtle": { + "hostname": "squirtle", + "tags": ["tag:router"], + "ipv4": "100.64.0.13", + "ipv6": "fd7a:115c:a1e0::d", + "routable_ips": ["10.33.0.0/16"], + "approved_routes": [] + }, "venusaur": { + "hostname": "venusaur", + "tags": [], + "ipv4": "100.64.0.18", + "ipv6": "fd7a:115c:a1e0::12", + "user": "freya", + "routable_ips": [], + "approved_routes": [] + }, "weedle": { + "hostname": "weedle", + "tags": ["tag:client"], + "ipv4": "100.64.0.14", + "ipv6": "fd7a:115c:a1e0::e", + "routable_ips": [], + "approved_routes": [] + }}}, + "captures": {}, + "steps": [{ + "name": "validate", + "at": "2026-10-17T15:39:12.817151014Z", + "result": "observed", + "actions": [{ + "kind": "api", + "principal": "admin", + "method": "POST", + "path": "/tailnet/-/acl/validate", + "header": {"Content-Type": "application/hujson"}, + "body": { + "groups": {"group:h": ["thor@example.org", "freya@example.com"]}, + "ssh": [{ + "action": "accept", + "dst": ["tag:server"], + "src": ["thor@example.org"], + "users": ["root"] + }, { + "action": "check", + "dst": ["tag:server"], + "src": ["freya@example.com"], + "users": ["root"] + }], + "sshTests": [{"deny": ["root"], "dst": ["tag:server"], "src": "group:h"}], + "tagOwners": {"tag:server": ["odin@example.com"]} + }, + "status": 200, + "response": {} + }] + }] +} diff --git a/hscontrol/servertest/policy_test.go b/hscontrol/servertest/policy_test.go index 644dad933..f0e7e215b 100644 --- a/hscontrol/servertest/policy_test.go +++ b/hscontrol/servertest/policy_test.go @@ -317,6 +317,9 @@ var rejectedPolicySkipReasons = map[string]string{ "ssh-e2/refused": noTailnetDomains, "ssh-malformed-user-localpart-multi-glob/refused": noTailnetDomains, "sshtest-malformed-dst-bare-ipv6/refused": "SSHTEST_BARE_IPV6_DST: headscale accepts a bare IPv6 sshTests destination", + + "policytest-src-split-across-rules/refused": "TEST_SRC_ONE_RULE: headscale lets each group member pass through its own rule", + "sshtest-group-split-actions-accept/refused": "SSHTEST_SRC_ONE_RULE: headscale lets each group member pass through its own accept or check rule", } const noTailnetDomains = "NO_TAILNET_DOMAINS: headscale does not check user:*@domain or localpart:*@domain against the tailnet's domains"