mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-17 22:12:03 +09:00
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:
+61
-19
@@ -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"`))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user