policy/v2: evaluate the tests block on user-initiated writes

v2 silently dropped policy.tests, so a policy that contradicted its
own assertions still applied. Resolve src/dst via the existing Alias
machinery, walk the compiled global filter rules (acls and grants
both contribute), and run on every user-write boundary: SetPolicy,
the file watcher, and `headscale policy check`. A failing test
rejects the write before it mutates live state.

Boot-time reload skips evaluation; an already-stored policy that
references a deleted user shouldn't lock the server out.

`headscale policy check` runs the engine only under
--bypass-grpc-and-access-database-directly (where it has live users
+ nodes); without the flag a policy with tests is rejected with a
pointer at the flag rather than silently skipped.

Updates #1803

Co-authored-by: Janis Jansons <janhouse@gmail.com>
This commit is contained in:
Kristoffer Dalby
2026-04-29 14:27:12 +00:00
parent 4e0c2b8556
commit 47f851c43b
5 changed files with 874 additions and 19 deletions
+61 -19
View File
@@ -1,6 +1,7 @@
package cli
import (
"bytes"
"errors"
"fmt"
"os"
@@ -17,7 +18,10 @@ const (
bypassFlag = "bypass-grpc-and-access-database-directly" //nolint:gosec // not a credential
)
var errAborted = errors.New("command aborted by user")
var (
errAborted = errors.New("command aborted by user")
errTestsRequireBypass = errors.New("policy contains a tests block; rerun with --" + bypassFlag + " to evaluate it (headscale must not be running)")
)
// bypassDatabase loads the server config and opens the database directly,
// bypassing the gRPC server. The caller is responsible for closing the
@@ -48,7 +52,7 @@ func init() {
policyCmd.AddCommand(setPolicy)
checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running. Required to validate that user@ tokens resolve against the user database; without it, the check is syntax-only.")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to validate user@ token references and to evaluate the policy's tests block. Required when those checks are needed.")
mustMarkRequired(checkPolicy, "file")
policyCmd.AddCommand(checkPolicy)
}
@@ -171,6 +175,10 @@ var setPolicy = &cobra.Command{
var checkPolicy = &cobra.Command{
Use: "check",
Short: "Check the Policy file for errors",
Long: `
Check parses the policy and validates its structure. If the policy contains a
"tests" block, those tests are evaluated against the live users and nodes:
pass --` + bypassFlag + ` to open the database directly when headscale is not running.`,
RunE: func(cmd *cobra.Command, args []string) error {
policyPath, _ := cmd.Flags().GetString("file")
@@ -179,36 +187,70 @@ var checkPolicy = &cobra.Command{
return fmt.Errorf("reading policy file: %w", err)
}
var users []types.User
bypass, _ := cmd.Flags().GetBool(bypassFlag)
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
return errAborted
}
d, err := bypassDatabase()
// Without bypass we don't have users or nodes to resolve user@
// tokens or test aliases against. Validate structure and warn
// explicitly if tests are present, rather than running them
// with empty data and reporting spurious failures.
if !bypass {
_, err = policy.NewPolicyManager(policyBytes, nil, views.Slice[types.NodeView]{})
if err != nil {
return err
return fmt.Errorf("parsing policy file: %w", err)
}
defer d.Close()
users, err = d.ListUsers()
if err != nil {
return fmt.Errorf("loading users for policy validation: %w", err)
if policyHasTestsBlock(policyBytes) {
return errTestsRequireBypass
}
fmt.Println("Policy syntax is valid (run with --" + bypassFlag + " to also validate user references against the database)")
return nil
}
_, err = policy.NewPolicyManager(policyBytes, users, views.Slice[types.NodeView]{})
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
return errAborted
}
d, err := bypassDatabase()
if err != nil {
return err
}
defer d.Close()
users, err := d.ListUsers()
if err != nil {
return fmt.Errorf("loading users: %w", err)
}
nodes, err := d.ListNodes()
if err != nil {
return fmt.Errorf("loading nodes: %w", err)
}
// NewPolicyManager validates structure and user references but
// intentionally skips test evaluation (boot path). SetPolicy is
// the user-write boundary and is what runs the tests block.
pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice())
if err != nil {
return fmt.Errorf("parsing policy file: %w", err)
}
if users == nil {
fmt.Println("Policy syntax is valid (run with --" + bypassFlag + " to also validate user references against the database)")
} else {
fmt.Println("Policy is valid")
_, err = pm.SetPolicy(policyBytes)
if err != nil {
return err
}
fmt.Println("Policy is valid")
return nil
},
}
// policyHasTestsBlock is a cheap textual probe for `"tests"` so callers
// can warn the user when they need to rerun with bypass. False positives
// (e.g. a comment containing the word) are harmless: the user is told to
// pass a flag they could have passed anyway.
func policyHasTestsBlock(b []byte) bool {
return bytes.Contains(b, []byte(`"tests"`))
}