policy/v2: add user token captures

SaaS answers for unknown users, a short name and a bare "@" at every
site. Each capture also runs under headscale-only user rows, at boot and
runtime; SaaS nodes must not change and overlay nodes get nothing.
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 16:09:00 +01:00
1 parent 3342e60fd4
commit 7b86005350
13 files changed
+77438 -20

No files matched your search

@@ -94,7 +94,21 @@ func runTesterCompat(t *testing.T, c *testcapture.Capture, skips map[string]stri
// Each capture pins its own topology IPs; range and address tests mean
// nothing against other nodes.
nodes := buildGrantsNodesFromCapture(users, c)
_ = runTesterCompatFor(t, c, skips, c.TestID+"/", users, buildGrantsNodesFromCapture(users, c))
}
// runTesterCompatFor is [runTesterCompat] against the given users and nodes,
// with its checks keyed under key. It returns headscale's refusal, if any.
func runTesterCompatFor(
t *testing.T,
c *testcapture.Capture,
skips map[string]string,
key string,
users types.Users,
nodes types.Nodes,
) error {
t.Helper()
policyJSON := []byte(c.Input.FullPolicy)
pm, err := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
@@ -110,7 +124,6 @@ func runTesterCompat(t *testing.T, c *testcapture.Capture, skips map[string]stri
}
saasRefused := c.Input.APIResponseCode != http.StatusOK
key := c.TestID + "/"
compatCheck(t, skips, key+"verdict", func() string {
switch {
@@ -126,7 +139,7 @@ func runTesterCompat(t *testing.T, c *testcapture.Capture, skips map[string]stri
if !saasRefused || err == nil {
compatChecksNotRun(t, skips, key+"message", key+"breakdown", key+"label")
return
return err
}
compatCheck(t, skips, key+"message", func() string {
@@ -140,7 +153,7 @@ func runTesterCompat(t *testing.T, c *testcapture.Capture, skips map[string]stri
if len(body.Data) == 0 {
compatChecksNotRun(t, skips, key+"breakdown", key+"label")
return
return err
}
requireFailureBreakdown(t, skips, key+"breakdown", body.Data, c.Input.FullPolicy, pm, err)
@@ -148,6 +161,8 @@ func runTesterCompat(t *testing.T, c *testcapture.Capture, skips map[string]stri
compatCheck(t, skips, key+"label", func() string {
return cmp.Diff(nodeLabels(c, fmt.Sprint(body.Data)), nodeLabels(c, err.Error()))
})
return err
}
var (
@@ -239,26 +239,12 @@ func testNodeAttrsSuccess(
got, err := pol.compileNodeAttrs(users, nodes.ViewSlice())
require.NoErrorf(t, err, "%s: compileNodeAttrs", tf.TestID)
// Mirror the prod self-build: route function is irrelevant for CapMap.
//
// Taildrop.Enabled defaults to true here because every capture is
// taken with the SaaS default Send Files state. The Tailscale v2
// TailnetSettings API does not expose the Send Files toggle, so
// tscap cannot vary it; the off-path is covered directly by
// TestTaildropDisabledWithholdsFileSharingCap in servertest.
// TODO: wire Taildrop.Enabled from tf.Input.Tailnet.Settings.FileSharing
// once the field is added to the public TailnetSettings API.
cfg := &types.Config{Taildrop: types.TaildropConfig{Enabled: true}}
if v := tf.Input.Tailnet.Settings.DevicesAutoUpdatesOn; v != nil && *v {
cfg.AutoUpdate = types.AutoUpdateConfig{Enabled: true}
}
emptyRoutes := func(types.NodeID) []netip.Prefix { return nil }
cfg := nodeAttrsCompatConfig(tf)
selfCapMap := func(t *testing.T, node *types.Node) tailcfg.NodeCapMap {
t.Helper()
tn, err := node.View().TailNode(0, emptyRoutes, cfg, got[node.ID])
tn, err := node.View().TailNode(0, noRoutes, cfg, got[node.ID])
require.NoErrorf(t, err, "%s/%s: TailNode", tf.TestID, node.GivenName)
return tn.CapMap
@@ -319,6 +305,29 @@ func testNodeAttrsSuccess(
}
}
// nodeAttrsCompatConfig is the server config a capture's tailnet settings
// stand for, as far as the self CapMap reads it.
//
// Taildrop.Enabled defaults to true here because every capture is
// taken with the SaaS default Send Files state. The Tailscale v2
// TailnetSettings API does not expose the Send Files toggle, so
// tscap cannot vary it; the off-path is covered directly by
// TestTaildropDisabledWithholdsFileSharingCap in servertest.
// TODO: wire Taildrop.Enabled from tf.Input.Tailnet.Settings.FileSharing
// once the field is added to the public TailnetSettings API.
func nodeAttrsCompatConfig(tf *testcapture.Capture) *types.Config {
cfg := &types.Config{Taildrop: types.TaildropConfig{Enabled: true}}
if v := tf.Input.Tailnet.Settings.DevicesAutoUpdatesOn; v != nil && *v {
cfg.AutoUpdate = types.AutoUpdateConfig{Enabled: true}
}
return cfg
}
// noRoutes mirrors the self-build's route function, which the CapMap does
// not read.
func noRoutes(types.NodeID) []netip.Prefix { return nil }
// capMapFromView materialises a captured CapMap view into the
// [tailcfg.NodeCapMap] shape headscale renders, so both sides of the
// diff have the same concrete type.
@@ -0,0 +1,610 @@
// Compatibility tests for user tokens that name nobody: well-formed
// addresses with no user, a short name and a bare "@", at every policy
// site. Captures live in testdata/users_results/.
//
// SaaS logins are unique and fixed, so SaaS cannot show two users answering
// one token, or a user without an e-mail. Each capture is therefore also
// replayed under overlays: headscale-only users, each with one node, added
// on top of it. SaaS's own nodes must be unchanged by the overlay, and
// overlay nodes must get nothing. Every overlay loads at boot (one
// constructor call) and at runtime (users and nodes added to a running
// manager); each node check runs on every manager that loaded.
//
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
import (
"database/sql"
"fmt"
"maps"
"net/http"
"net/netip"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
// userTokenOverlay is a set of headscale-only users replayed on top of a
// capture. Each user in add gets one node; replace swaps a capture user's
// row for the one another identity provider would store.
type userTokenOverlay struct {
name string
add types.Users
replace map[string]types.User
}
func oidcUser(id uint, issuer, name, email string) types.User {
return types.User{
ID: id,
Name: name,
Email: email,
Provider: "oidc",
ProviderIdentifier: sql.NullString{String: issuer + "/" + name, Valid: true},
}
}
// userTokenOverlays come from the shapes headscale's user table allows and
// SaaS cannot have. The ambiguous ones match nobody by the #3160 rule
// (never pick one of two equal matches), not by SaaS evidence.
var userTokenOverlays = []userTokenOverlay{
{name: ""},
{
name: "name-is-owner-email",
add: types.Users{oidcUser(101, "https://idp-a.example.net", "odin@example.com", "other@example.net")},
},
{
name: "name-is-unknown-email",
add: types.Users{oidcUser(101, "https://idp-a.example.net", "ghost@example.com", "other@example.net")},
},
{
// An unverified e-mail is not stored.
name: "name-is-unknown-email-unverified",
add: types.Users{oidcUser(101, "https://idp-a.example.net", "ghost@example.com", "")},
},
{
// Okta and Entra on default config store the address as the name only.
name: "okta-name-only",
replace: map[string]types.User{
"odin": oidcUser(1, "https://idp-a.example.net", "odin@example.com", ""),
},
},
{
name: "duplicate-unknown-email",
add: types.Users{
oidcUser(101, "https://idp-a.example.net", "ghost", "ghost@example.com"),
oidcUser(102, "https://idp-b.example.net", "ghost", "ghost@example.com"),
},
},
{name: "no-email-one", add: types.Users{{ID: 101, Name: "svc-a"}}},
{name: "no-email-two", add: types.Users{{ID: 101, Name: "svc-a"}, {ID: 102, Name: "svc-b"}}},
}
// userTokenSkipReasons lists checks where headscale differs from SaaS,
// keyed <test_id>[/<overlay>][/<node>]/<check>. HEADSCALE_SHORT_USERNAME
// and AMBIGUOUS_USER_WRITE are deliberate; USERNAME_ANSWERS_EMAIL_TOKEN is
// an open decision.
var userTokenSkipReasons = map[string]string{
"users-short-name/okta-name-only/server/filter": shortUsernameRow,
"users-bare-at-test-dst/breakdown": testUnknownAlias,
"users-bare-at-test-dst/duplicate-unknown-email/breakdown": testUnknownAlias,
"users-bare-at-test-dst/name-is-unknown-email/breakdown": testUnknownAlias,
"users-bare-at-test-dst/no-email-two/breakdown": testUnknownAlias,
"users-bare-at-test-src/breakdown": testUnknownAlias,
"users-bare-at-test-src/duplicate-unknown-email/breakdown": testUnknownAlias,
"users-bare-at-test-src/name-is-unknown-email/breakdown": testUnknownAlias,
"users-bare-at-test-src/no-email-two/breakdown": testUnknownAlias,
"users-bare-at-dst/name-is-owner-email/boot": ambiguousLoad,
"users-bare-at-dst/no-email-two/boot": ambiguousLoad,
"users-bare-at-group/name-is-owner-email/boot": ambiguousLoad,
"users-bare-at-group/no-email-two/boot": ambiguousLoad,
"users-bare-at-nodeattrs/name-is-owner-email/boot": ambiguousLoad,
"users-bare-at-nodeattrs/no-email-two/boot": ambiguousLoad,
"users-bare-at-nodeattrs/no-email-two/runtime": ambiguousLoad,
"users-bare-at-src/name-is-owner-email/boot": ambiguousLoad,
"users-bare-at-src/no-email-two/boot": ambiguousLoad,
"users-bare-at-tagowner/name-is-owner-email/boot": ambiguousLoad,
"users-bare-at-tagowner/no-email-two/boot": ambiguousLoad,
"users-bare-at-test-dst/name-is-owner-email/boot": ambiguousLoad,
"users-bare-at-test-src/name-is-owner-email/boot": ambiguousLoad,
"users-short-name/name-is-owner-email/boot": ambiguousLoad,
"users-unknown-direct/duplicate-unknown-email/boot": ambiguousLoad,
"users-unknown-direct/name-is-owner-email/boot": ambiguousLoad,
"users-unknown-member/duplicate-unknown-email/boot": ambiguousLoad,
"users-unknown-member/duplicate-unknown-email/runtime": ambiguousLoad,
"users-unknown-member/name-is-owner-email/boot": ambiguousLoad,
"users-unknown-member/name-is-owner-email/runtime": ambiguousLoad,
"users-unknown-direct/duplicate-unknown-email/verdict": ambiguousWrite,
"users-unknown-member/duplicate-unknown-email/verdict": ambiguousWrite,
"users-unknown-direct/duplicate-unknown-email/user-list": ambiguityListsUsers,
"users-unknown-member/duplicate-unknown-email/user-list": ambiguityListsUsers,
"users-bare-at-dst/name-is-owner-email/verdict": emailOwnerAmbiguous,
"users-bare-at-group/name-is-owner-email/verdict": emailOwnerAmbiguous,
"users-bare-at-nodeattrs/name-is-owner-email/server/filter": emailOwnerAmbiguous,
"users-bare-at-nodeattrs/name-is-owner-email/verdict": emailOwnerAmbiguous,
"users-bare-at-src/name-is-owner-email/verdict": emailOwnerAmbiguous,
"users-bare-at-tagowner/name-is-owner-email/server/filter": emailOwnerAmbiguous,
"users-bare-at-tagowner/name-is-owner-email/verdict": emailOwnerAmbiguous,
"users-bare-at-test-dst/name-is-owner-email/breakdown": emailOwnerAmbiguous,
"users-bare-at-test-dst/name-is-owner-email/message": emailOwnerAmbiguous,
"users-bare-at-test-src/name-is-owner-email/breakdown": emailOwnerAmbiguous,
"users-bare-at-test-src/name-is-owner-email/message": emailOwnerAmbiguous,
"users-short-name/name-is-owner-email/verdict": emailOwnerAmbiguous,
"users-unknown-direct/name-is-owner-email/verdict": emailOwnerAmbiguous,
"users-unknown-member/name-is-owner-email/verdict": emailOwnerAmbiguous,
"users-bare-at-dst/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-bare-at-group/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-bare-at-nodeattrs/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-bare-at-src/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-bare-at-tagowner/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-bare-at-test-dst/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-bare-at-test-src/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-short-name/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-unknown-direct/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-unknown-member/name-is-owner-email/user-list": emailOwnerAmbiguous,
"users-unknown-direct/name-is-unknown-email-unverified/overlay-1/filter": usernameAnswersEmail,
"users-unknown-direct/name-is-unknown-email-unverified/overlay-1/route-approver": usernameAnswersEmail,
"users-unknown-direct/name-is-unknown-email-unverified/overlay-1/tag-owner": usernameAnswersEmail,
"users-unknown-direct/name-is-unknown-email-unverified/prod/filter": usernameAnswersEmail,
"users-unknown-direct/name-is-unknown-email/overlay-1/filter": usernameAnswersEmail,
"users-unknown-direct/name-is-unknown-email/overlay-1/route-approver": usernameAnswersEmail,
"users-unknown-direct/name-is-unknown-email/overlay-1/tag-owner": usernameAnswersEmail,
"users-unknown-direct/name-is-unknown-email/prod/filter": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email-unverified/overlay-1/capmap": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email-unverified/overlay-1/filter": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email-unverified/prod/ssh": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email-unverified/server/filter": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email-unverified/server/ssh": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email/overlay-1/capmap": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email/overlay-1/filter": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email/prod/ssh": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email/server/filter": usernameAnswersEmail,
"users-unknown-member/name-is-unknown-email/server/ssh": usernameAnswersEmail,
"users-bare-at-dst/name-is-unknown-email-unverified/overlay-1/filter": bareAtNoEmail,
"users-bare-at-dst/no-email-one/overlay-1/filter": bareAtNoEmail,
"users-bare-at-dst/no-email-two/user-list": bareAtNoEmail,
"users-bare-at-dst/no-email-two/verdict": bareAtNoEmail,
"users-bare-at-dst/okta-name-only/user-tid/filter": bareAtNoEmail,
"users-bare-at-group/name-is-unknown-email-unverified/server/filter": bareAtNoEmail,
"users-bare-at-group/no-email-one/server/filter": bareAtNoEmail,
"users-bare-at-group/no-email-two/user-list": bareAtNoEmail,
"users-bare-at-group/no-email-two/verdict": bareAtNoEmail,
"users-bare-at-group/okta-name-only/server/filter": bareAtNoEmail,
"users-bare-at-nodeattrs/name-is-unknown-email-unverified/overlay-1/capmap": bareAtNoEmail,
"users-bare-at-nodeattrs/no-email-one/overlay-1/capmap": bareAtNoEmail,
"users-bare-at-nodeattrs/no-email-two/user-list": bareAtNoEmail,
"users-bare-at-nodeattrs/no-email-two/verdict": bareAtNoEmail,
"users-bare-at-nodeattrs/okta-name-only/user-tid/capmap": bareAtNoEmail,
"users-bare-at-src/name-is-unknown-email-unverified/server/filter": bareAtNoEmail,
"users-bare-at-src/no-email-one/server/filter": bareAtNoEmail,
"users-bare-at-src/no-email-two/user-list": bareAtNoEmail,
"users-bare-at-src/no-email-two/verdict": bareAtNoEmail,
"users-bare-at-src/okta-name-only/server/filter": bareAtNoEmail,
"users-bare-at-tagowner/name-is-unknown-email-unverified/overlay-1/tag-owner": bareAtNoEmail,
"users-bare-at-tagowner/no-email-one/overlay-1/tag-owner": bareAtNoEmail,
"users-bare-at-tagowner/no-email-two/user-list": bareAtNoEmail,
"users-bare-at-tagowner/no-email-two/verdict": bareAtNoEmail,
"users-bare-at-test-dst/no-email-two/user-list": bareAtNoEmail,
"users-bare-at-test-src/no-email-two/user-list": bareAtNoEmail,
"users-bare-at-test-src/okta-name-only/verdict": bareAtNoEmail,
"users-short-name/server/filter": shortUsername,
"users-unknown-member/prod/ssh": sshUIDZero,
"users-unknown-member/server/ssh": sshUIDZero,
}
const (
shortUsername = "HEADSCALE_SHORT_USERNAME: headscale lets odin@ name the user called odin; SaaS matches it to no one"
shortUsernameRow = "HEADSCALE_SHORT_USERNAME: a row storing the address as its name leaves odin@ nothing to match"
ambiguousLoad = "AMBIGUOUS_USER_LOAD: a user token matching two users stops the policy loading; SaaS keeps serving it"
ambiguousWrite = "AMBIGUOUS_USER_WRITE: headscale refuses to store a policy whose user token matches two users (#3160)"
ambiguityListsUsers = "AMBIGUITY_LISTS_USERS: headscale's refusal of a token matching two users lists both users by ID and name"
emailOwnerAmbiguous = "EMAIL_OWNER_FIRST: a username equal to another user's e-mail makes that e-mail match two users"
usernameAnswersEmail = "USERNAME_ANSWERS_EMAIL_TOKEN: a username shaped like an unclaimed address takes that address's rights"
bareAtNoEmail = `BARE_AT_NO_EMAIL: "@" matches the users without an e-mail; SaaS matches it to no one`
)
// TestUserTokenCompat replays every users capture as captured and under
// every overlay.
func TestUserTokenCompat(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(filepath.Join("testdata", "users_results", "*.hujson"))
require.NoError(t, err, "failed to glob test files")
require.NotEmpty(t, files, "no users captures")
for _, file := range files {
c, err := testcapture.Read(file)
require.NoError(t, err, "reading %s", file)
t.Run(c.TestID, func(t *testing.T) {
t.Parallel()
requireCaptureUserOwnership(t, c)
for _, o := range userTokenOverlays {
name := o.name
if name == "" {
name = "as-captured"
}
t.Run(name, func(t *testing.T) {
key := c.TestID + "/"
if o.name != "" {
key += o.name + "/"
}
runUserTokenCompat(t, c, o, key)
})
}
})
}
}
// requireCaptureUserOwnership fails a capture whose user-owned nodes are not
// shown as their topology user: the runner falls back to the admin's key
// when a user's key is missing, which would hand every node to one user.
func requireCaptureUserOwnership(t *testing.T, c *testcapture.Capture) {
t.Helper()
logins := make(map[string]string, len(c.Topology.Users))
for _, u := range c.Topology.Users {
logins[u.Name] = u.Email
}
for name, n := range c.Topology.Nodes {
if len(n.Tags) > 0 {
continue
}
capture, ok := c.Captures[name]
require.Truef(t, ok && capture.Netmap != nil, "%s: no netmap", name)
self := capture.Netmap.SelfNode
profile, ok := capture.Netmap.UserProfiles[self.User()]
require.Truef(t, ok, "%s: no profile for its own user", name)
require.Equalf(t, logins[n.User], profile.LoginName(), "%s: owner", name)
}
}
// userTokenUsersAndNodes returns the capture's users and nodes with the
// overlay applied, and the overlay's own nodes.
func userTokenUsersAndNodes(
t *testing.T,
c *testcapture.Capture,
o userTokenOverlay,
) (types.Users, types.Nodes, types.Nodes) {
t.Helper()
users := setupACLCompatUsers()
nodes := buildGrantsNodesFromCapture(users, c)
// Stable IDs, so a node is the same node with and without the overlay.
slices.SortFunc(nodes, func(a, b *types.Node) int { return strings.Compare(a.GivenName, b.GivenName) })
for i, n := range nodes {
n.ID = types.NodeID(i + 1) //nolint:gosec
}
// In place, so the nodes keep their owner under its new row.
for i := range users {
if u, ok := o.replace[users[i].Name]; ok {
users[i] = u
}
}
users = append(users, o.add...)
// A range no capture uses, so overlay addresses never collide.
v4 := netip.MustParseAddr("100.127.255.0")
v6 := netip.MustParseAddr("fd7a:115c:a1e0:ffff::")
overlay := make(types.Nodes, 0, len(o.add))
for i := range o.add {
v4, v6 = v4.Next(), v6.Next()
for _, n := range nodes {
require.Falsef(t, n.IPv4 != nil && *n.IPv4 == v4 || n.IPv6 != nil && *n.IPv6 == v6,
"overlay address of %s is used by %s", o.add[i].Name, n.GivenName)
}
user := &users[len(users)-len(o.add)+i]
overlay = append(overlay, &types.Node{
ID: types.NodeID(1000 + i), //nolint:gosec
Hostname: fmt.Sprintf("overlay-%d", i+1),
GivenName: fmt.Sprintf("overlay-%d", i+1),
IPv4: new(v4),
IPv6: new(v6),
User: user,
UserID: &user.ID,
Hostinfo: &tailcfg.Hostinfo{},
ApprovedRoutes: []netip.Prefix{},
})
}
return users, append(nodes, overlay...), overlay
}
// runUserTokenCompat runs one capture under one overlay.
func runUserTokenCompat(t *testing.T, c *testcapture.Capture, o userTokenOverlay, key string) {
t.Helper()
users, nodes, overlay := userTokenUsersAndNodes(t, c, o)
saasUsers, saasNodes, _ := userTokenUsersAndNodes(t, c, userTokenOverlay{})
policyJSON := []byte(c.Input.FullPolicy)
pol, err := unmarshalPolicy(policyJSON)
require.NoError(t, err)
// Boot loads the stored policy with every user and node present.
boot, bootErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
// At runtime the overlay's users log in and their nodes join a server
// already running the policy.
runtime, runtimeErr := NewPolicyManager(policyJSON, saasUsers, saasNodes.ViewSlice())
if runtimeErr == nil {
_, _, runtimeErr = runtime.SetUsers(users)
}
if runtimeErr == nil {
_, runtimeErr = runtime.SetNodes(nodes.ViewSlice())
}
managers := []struct {
name string
pm *PolicyManager
err error
}{{"boot", boot, bootErr}, {"runtime", runtime, runtimeErr}}
loaded := 0
for _, m := range managers {
compatCheck(t, userTokenSkipReasons, key+m.name, func() string {
if m.err != nil {
return m.err.Error()
}
return ""
})
if m.err == nil {
loaded++
}
}
// Under an overlay, SaaS nodes must keep what the capture alone gives.
var unchanged *PolicyManager
if o.name != "" {
unchanged, err = NewPolicyManager(policyJSON, saasUsers, saasNodes.ViewSlice())
require.NoError(t, err)
}
for _, node := range nodes {
nodeKey := key + node.GivenName + "/"
isOverlay := slices.Contains(overlay, node)
checks := []string{"filter", "capmap", "ssh"}
if isOverlay {
checks = append(checks, "tag-owner", "route-approver")
}
// A refused policy leaves no node state to compare.
if loaded == 0 || c.Input.APIResponseCode != http.StatusOK {
for _, check := range checks {
compatChecksNotRun(t, userTokenSkipReasons, nodeKey+check)
}
continue
}
t.Run(node.GivenName, func(t *testing.T) {
capture, ok := c.Captures[node.GivenName]
require.Truef(t, ok || isOverlay, "no capture for %s", node.GivenName)
for _, check := range checks {
compatCheck(t, userTokenSkipReasons, nodeKey+check, func() string {
var diffs []string
for _, m := range managers {
if m.err != nil {
continue
}
var diff string
switch {
case isOverlay:
diff = userTokenOverlayMismatch(check, pol, m.pm, node)
case unchanged != nil:
diff = userTokenUnchangedMismatch(check, unchanged, m.pm, node)
default:
diff = userTokenCaptureMismatch(check, c, pol, m.pm, users, nodes, node, capture)
}
if diff != "" {
diffs = append(diffs, m.name+": "+diff)
}
}
return strings.Join(diffs, "\n")
})
}
})
}
// The same bytes posted as a new policy.
writeErr := runTesterCompatFor(t, c, userTokenSkipReasons, key, users, nodes)
// A refusal may echo a token the policy wrote, never list a user.
compatCheck(t, userTokenSkipReasons, key+"user-list", func() string {
var leaked []string
for _, err := range []error{bootErr, runtimeErr, writeErr} {
if err == nil {
continue
}
rest := quotedToken.ReplaceAllStringFunc(err.Error(), func(q string) string {
if strings.Contains(c.Input.FullPolicy, q) {
return ""
}
return q
})
for _, u := range o.add {
if strings.Contains(rest, u.Name) {
leaked = append(leaked, fmt.Sprintf("%s in %q", u.Name, err))
}
}
}
return strings.Join(leaked, "\n")
})
}
// quotedToken matches a quoted string in an error, the form a refusal
// echoes a policy token in.
var quotedToken = regexp.MustCompile(`"[^"]*"`)
// userTokenCaptureMismatch compares one output of a SaaS node with its
// capture.
func userTokenCaptureMismatch(
check string,
c *testcapture.Capture,
pol *Policy,
pm *PolicyManager,
users types.Users,
nodes types.Nodes,
node *types.Node,
capture testcapture.Node,
) string {
switch check {
case "filter":
got, err := pm.FilterForNode(node.View())
if err != nil {
return err.Error()
}
return cmp.Diff(capture.PacketFilterRules, got, append(cmpOptions(), cmpopts.EquateEmpty())...)
case "capmap":
tn, err := node.View().TailNode(0, noRoutes, nodeAttrsCompatConfig(c), pm.NodeCapMap(node.ID))
if err != nil {
return err.Error()
}
return cmp.Diff(
stripUnmodelledTailnetStateCaps(capMapFromView(capture.Netmap.SelfNode.CapMap())),
stripUnmodelledTailnetStateCaps(tn.CapMap),
cmpopts.EquateEmpty(),
)
default:
return sshRulesMismatch(pol, pm, users, nodes, node, capture)
}
}
// userTokenUnchangedMismatch compares one output of a SaaS node under an
// overlay with the same node's output without it.
func userTokenUnchangedMismatch(check string, want, pm *PolicyManager, node *types.Node) string {
switch check {
case "filter":
wantRules, err := want.FilterForNode(node.View())
if err != nil {
return err.Error()
}
got, err := pm.FilterForNode(node.View())
if err != nil {
return err.Error()
}
return cmp.Diff(wantRules, got, append(cmpOptions(), cmpopts.EquateEmpty())...)
case "capmap":
return cmp.Diff(want.NodeCapMap(node.ID), pm.NodeCapMap(node.ID), cmpopts.EquateEmpty())
default:
wantSSH, err := want.SSHPolicy("https://unused", node.View())
if err != nil {
return err.Error()
}
got, err := pm.SSHPolicy("https://unused", node.View())
if err != nil {
return err.Error()
}
return cmp.Diff(wantSSH, got, cmpopts.EquateEmpty())
}
}
// userTokenOverlayMismatch reports what an overlay node got: it may reach
// nothing, carry no attr, accept no SSH, take no tag and approve no route
// the policy names.
func userTokenOverlayMismatch(check string, pol *Policy, pm *PolicyManager, node *types.Node) string {
var got []string
switch check {
case "filter":
rules, err := pm.FilterForNode(node.View())
if err != nil {
return err.Error()
}
for _, r := range rules {
got = append(got, fmt.Sprint(r))
}
case "capmap":
for attr := range pm.NodeCapMap(node.ID) {
got = append(got, string(attr))
}
case "ssh":
sshPol, err := pm.SSHPolicy("https://unused", node.View())
if err != nil {
return err.Error()
}
if sshPol != nil {
for _, r := range sshPol.Rules {
got = append(got, fmt.Sprint(*r))
}
}
case "tag-owner":
for tag := range pol.TagOwners {
if pm.NodeCanHaveTag(node.View(), string(tag)) {
got = append(got, string(tag))
}
}
case "route-approver":
for _, route := range append(slices.Collect(maps.Keys(pol.AutoApprovers.Routes)), tsaddr.AllIPv4()) {
if pm.NodeCanApproveRoute(node.View(), route) {
got = append(got, route.String())
}
}
}
if len(got) == 0 {
return ""
}
slices.Sort(got)
return fmt.Sprintf("overlay node got %s: %v", check, got)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff