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` is 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 opens the DB
directly when the server is not running.

cmd/headscale/cli/root.go no longer special-cases `policy check` in
init() (the early return from PR #2580 broke --config registration
and viper priming for --bypass).

integration/cli_policy_test.go covers policy_mode={file,database} x
fixture={acl-only, acl+passing-tests, acl+failing-tests} x
bypass={false,true} = 12 rows.

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 56146de377
commit b29ae25356
9 changed files with 1062 additions and 19 deletions

View file

@ -187,6 +187,16 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
return nil, err
}
// Boot path: log a warning if the stored policy's tests would
// fail against the current users and nodes, but keep the server
// running. A stale stored policy (e.g. referencing a user that
// was deleted while the server was offline) should not block
// boot; the operator finds out via logs and re-runs the write
// boundary when they are ready.
if testErr := pm.RunTests(); testErr != nil { //nolint:noinlineerr // boot path: warn-and-continue, not return
log.Warn().Err(testErr).Msg("policy tests failed at boot; server starting anyway, fix the policy and reload")
}
return &pm, nil
}
@ -447,6 +457,15 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
return false, fmt.Errorf("validating policy user references: %w", err)
}
// SetPolicy is the user-write boundary. Tests evaluate against a
// sandbox compiled from the new policy + current users/nodes; if
// they fail, return without mutating the live PolicyManager so the
// failed write does not knock the running config offline.
err = evaluateTests(pol, pm.users, pm.nodes)
if err != nil {
return false, err
}
// Log policy metadata for debugging
log.Debug().
Int("policy.bytes", len(polB)).
@ -455,6 +474,7 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
Int("hosts.count", len(pol.Hosts)).
Int("tagOwners.count", len(pol.TagOwners)).
Int("autoApprovers.routes.count", len(pol.AutoApprovers.Routes)).
Int("tests.count", len(pol.Tests)).
Msg("Policy parsed successfully")
pm.pol = pol