integration: reject failing sshTests at headscale policy set

This commit is contained in:
Kristoffer Dalby
2026-05-13 08:16:28 +00:00
parent 5b7e9bb9c8
commit 5bed3b29b9
+125
View File
@@ -5,6 +5,7 @@ import (
"testing"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/require"
@@ -164,3 +165,127 @@ func TestPolicyCheckCommand(t *testing.T) {
})
}
}
// TestSSHTestsRejectFailingPolicy asserts that `headscale policy set`
// rejects a policy whose sshTests evaluate to a failure, surfaces the
// engine's "test(s) failed" sentinel on stderr, and leaves the previously
// stored policy untouched. The good policy admits user1@ → autogroup:member
// as root; the bad policy reuses the same SSH rule but asserts user2@ can
// SSH, which the rule denies. autogroup:member is used as the dst so the
// test does not need a separately-tagged node: every scenario node is a
// member, so dst resolution finds real nodes to evaluate against. SaaS
// returns the same literal "test(s) failed" body for both ACL tests and
// SSH tests; headscale matches that surface.
func TestSSHTestsRejectFailingPolicy(t *testing.T) {
IntegrationSkip(t)
const (
user1 = "user1@"
user2 = "user2@"
)
// Good policy: SSH rule and sshTests agree — user1@ may SSH as root
// to any autogroup:member node, and the sshTests entry asserts exactly
// that.
goodPolicy := policyv2.Policy{
SSHs: []policyv2.SSH{
{
Action: policyv2.SSHActionAccept,
Sources: policyv2.SSHSrcAliases{usernamep(user1)},
Destinations: policyv2.SSHDstAliases{
new(policyv2.AutoGroupMember),
},
Users: []policyv2.SSHUser{policyv2.SSHUser("root")},
},
},
SSHTests: []policyv2.SSHPolicyTest{
{
Src: user1,
Dst: []string{"autogroup:member"},
Accept: []string{"root"},
},
},
}
// Bad policy: same SSH rule, but the sshTests block asserts that
// user2@ can SSH as root to autogroup:member. The rule only admits
// user1@, so the assertion must fail and the write must be rejected.
// SSHTests is a slice (reference type), so reassigning the field
// rather than mutating in place preserves goodPolicy.SSHTests.
badPolicy := goodPolicy
badPolicy.SSHTests = []policyv2.SSHPolicyTest{
{
Src: user2,
Dst: []string{"autogroup:member"},
Accept: []string{"root"},
},
}
spec := ScenarioSpec{
NodesPerUser: 1,
Users: []string{"user1", "user2"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithTestName("cli-policyset-sshtests"),
hsic.WithConfigEnv(map[string]string{
"HEADSCALE_POLICY_MODE": types.PolicyModeDB,
}),
)
require.NoError(t, err)
headscale, err := scenario.Headscale()
require.NoError(t, err)
goodBytes, err := json.Marshal(goodPolicy)
require.NoError(t, err)
badBytes, err := json.Marshal(badPolicy)
require.NoError(t, err)
const (
goodPath = "/etc/headscale/policy-good.json"
badPath = "/etc/headscale/policy-bad.json"
)
require.NoError(t, headscale.WriteFile(goodPath, goodBytes))
require.NoError(t, headscale.WriteFile(badPath, badBytes))
// Establish the good policy as the live policy.
_, err = headscale.Execute([]string{
"headscale", "policy", "set", "-f", goodPath,
})
require.NoError(t, err, "setting the good policy must succeed")
// Confirm the server returns the good policy.
stdoutBefore, err := headscale.Execute([]string{
"headscale", "policy", "get",
})
require.NoError(t, err)
require.JSONEq(t, string(goodBytes), stdoutBefore,
"server should report the good policy after the initial set")
// Attempt to overwrite with a policy whose sshTests fail. The CLI
// must surface the engine's "test(s) failed" sentinel (SaaS uses the
// same body for ACL tests and SSH tests) and exit non-zero.
_, err = headscale.Execute([]string{
"headscale", "policy", "set", "-f", badPath,
})
require.Error(t, err, "setting a policy with failing sshTests must fail")
require.ErrorContains(t, err, "test(s) failed",
"CLI error must surface the engine's test failure sentinel")
// The rejected write must not have mutated the stored policy.
stdoutAfter, err := headscale.Execute([]string{
"headscale", "policy", "get",
})
require.NoError(t, err)
require.JSONEq(t, string(goodBytes), stdoutAfter,
"stored policy must be unchanged after a rejected set")
}