cli/policy: route check through gRPC; bypass goes direct to DB

policy check previously ran the full policy engine in-process inside
the CLI, building a sandbox PolicyManager from the file and the
database. That duplicated the engine's runtime dependencies onto the
CLI and forced --bypass-grpc-and-access-database-directly to pull in
full server config validation. nblock hit it on PR #3229: passing
--bypass with a real config produced a flood of 'Fatal config error:'
lines from validateServerConfig because the cobra init early-return
for 'policy check' skipped --config registration and OnInitialize.

Make 'policy check' a thin frontend for the new CheckPolicy gRPC
method. The server-side handler builds a fresh PolicyManager from the
request bytes and the state's live users/nodes, runs SetPolicy on the
sandbox so the tests block executes, and returns the result through
gRPC status. No persistence, no policy_mode coupling.

--bypass-grpc-and-access-database-directly keeps doing what its name
says — opens the DB directly for cases where the server is not
running — but is no longer the only way to evaluate a tests block.

Drop the 'policy check' early-return in cmd/headscale/cli/root.go
(added in PR #2580 when check was syntax-only). All paths now need
either gRPC or direct DB access, both of which want the config and
flags the rest of cobra init sets up.

integration/cli_policy_test.go covers the matrix nblock asked about:
policy_mode={file,database} x fixture={acl-only, acl+passing-tests,
acl+failing-tests} x bypass={false,true} = 12 rows. acl-only and
acl-plus-passing-tests must pass; acl-plus-failing-tests must surface
'test(s) failed'; the policy_mode axis proves check does not depend
on where the server stores its current policy.

Updates #1803
This commit is contained in:
Kristoffer Dalby
2026-05-11 14:09:59 +00:00
parent 596ecec1db
commit 1ef18fb010
4 changed files with 235 additions and 58 deletions
+39 -52
View File
@@ -1,7 +1,6 @@
package cli
import (
"bytes"
"errors"
"fmt"
"os"
@@ -18,10 +17,7 @@ const (
bypassFlag = "bypass-grpc-and-access-database-directly" //nolint:gosec // not a credential
)
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)")
)
var errAborted = errors.New("command aborted by user")
// bypassDatabase loads the server config and opens the database directly,
// bypassing the gRPC server. The caller is responsible for closing the
@@ -176,9 +172,10 @@ 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.`,
Check validates the policy against the server's live users and nodes,
running any "tests" block. By default the command is a thin frontend
for a gRPC call to a running headscale; 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")
@@ -187,56 +184,54 @@ var checkPolicy = &cobra.Command{
return fmt.Errorf("reading policy file: %w", err)
}
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
}
// 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]{})
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 policyHasTestsBlock(policyBytes) {
return errTestsRequireBypass
_, err = pm.SetPolicy(policyBytes)
if err != nil {
return err
}
fmt.Println("Policy syntax is valid (run with --" + bypassFlag + " to also validate user references against the database)")
fmt.Println("Policy is valid")
return nil
}
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()
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return err
return fmt.Errorf("connecting to headscale: %w", err)
}
defer d.Close()
defer cancel()
defer conn.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)
}
_, err = pm.SetPolicy(policyBytes)
_, err = client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)})
if err != nil {
return err
}
@@ -246,11 +241,3 @@ var checkPolicy = &cobra.Command{
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"`))
}
-6
View File
@@ -3,7 +3,6 @@ package cli
import (
"os"
"runtime"
"slices"
"strings"
"github.com/juanfont/headscale/hscontrol/types"
@@ -22,11 +21,6 @@ func init() {
return
}
if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
zerolog.SetGlobalLevel(zerolog.Disabled)
return
}
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().
StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")