servertest, policy/v2: add re-authentication golden data

Expired, live, ephemeral and admin-tagged devices logging in again with
spent, revoked, valid and other users' keys, by key and by login;
tag ownership through tags per policy function.

Updates ANT-2026-LYVGXVSW
Updates ANT-2026-AWGRB7O1
This commit is contained in:
Kristoffer Dalby committed 2026-10-29 14:00:02 +01:00
1 parent 5c2cf6ed6f
commit c4d4e32f12
10 files changed
+61757 -54

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
// Compatibility tests for tags owned by tags: which tags a user, a tagged
// auth key and an OAuth client may apply when tagOwners chain tags, as
// Tailscale SaaS answered joins, relogins and key mints.
//
// Test data source: ../../servertest/testdata/steps/tag-owner-chain.hujson,
// loaded by name; the servertest step captures live there.
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
)
// tagOwnerSkipReasons lists checks where headscale differs from SaaS, keyed
// <test_id>/<step>/<function>. A listed check that matches fails, so a fix
// must delete its entry.
var tagOwnerSkipReasons = map[string]string{
"tag-owner-chain/u-re-relogin/NodeCanHaveTag": tagOwnedThroughTag,
"tag-owner-chain/t-top-join/TagOwnedByTags": tagReachedTwoHops,
"tag-owner-chain/C-base-mint-top/TagOwnedByTags": tagReachedTwoHops,
"tag-owner-chain/C-base-mint-dtop/TagOwnedByTags": tagReachedTwoHops,
"tag-owner-chain/C-two-mint-subset/TagOwnedByTags": clientTagSubset,
}
const (
tagOwnedThroughTag = "TAG_OWNED_THROUGH_TAG: headscale lets a user's node apply a tag whose owner is a tag the user owns; SaaS gives a user only the tags it owns directly"
tagReachedTwoHops = "TAG_REACHED_TWO_HOPS: headscale lets a tagged credential apply a tag any number of ownership steps away; SaaS allows only tags its own tags own directly"
clientTagSubset = "CLIENT_TAG_SUBSET: headscale lets an OAuth client mint a key with a subset of its tags; SaaS needs that subset owned by the client's tags"
)
// TestTagOwnerChainCompat compares each SaaS verdict on a tag request with
// the policy function headscale asks on that path:
// - a join with a user's untagged key and requested tags: UserCanHaveTag,
// and NodeCanHaveTag for a node the policy does not know yet;
// - a relogin of the user's existing node with requested tags:
// NodeCanHaveTag for that node;
// - a join with a tagged key, and an OAuth client's key mint:
// TagOwnedByTags with the credential's tags.
//
// The user's own mints have no verdict: as a member his API key could not
// mint at all.
func TestTagOwnerChainCompat(t *testing.T) {
t.Parallel()
const id = "tag-owner-chain"
c, err := testcapture.Read(filepath.Join("..", "..", "servertest", "testdata", "steps", id+".hujson"))
require.NoError(t, err)
require.NotEmpty(t, c.Steps, "%s: not a step capture", id)
users := setupGrantsCompatUsers()
nodes := buildGrantsNodesFromCapture(users, c)
pm, err := NewPolicyManager([]byte(c.Input.FullPolicy), users, nodes.ViewSlice())
require.NoError(t, err)
byName := func(name string) *types.User {
i := slices.IndexFunc(users, func(u types.User) bool { return u.Name == name })
require.GreaterOrEqual(t, i, 0, "no user %s", name)
return &users[i]
}
// credentialTags holds the tags of each tagged key and OAuth client, and
// keyUsers the owner of each untagged key, by label.
credentialTags := map[string][]string{}
keyUsers := map[string]*types.User{}
// One verdict per step and function, read in order: a mint's tags are
// known only once its step has been read.
type row struct {
step, function string
want bool
got func() bool
}
var rows []row
for _, step := range c.Steps {
if step.Result != "observed" {
continue
}
for _, a := range step.Actions {
switch {
case a.Kind == "api" && a.Method == http.MethodPost && a.Path == "/tailnet/-/keys":
var body struct {
Tags []string `json:"tags"`
Capabilities struct {
Devices struct {
Create struct {
Tags []string `json:"tags"`
} `json:"create"`
} `json:"devices"`
} `json:"capabilities"`
}
require.NoError(t, json.Unmarshal(a.Body, &body), "%s: mint body", step.Name)
tags := body.Capabilities.Devices.Create.Tags
client, byToken := strings.CutPrefix(a.Principal, "token:")
switch {
case a.Client != "":
credentialTags[a.Client] = body.Tags
case byToken:
owners := credentialTags[client]
rows = append(rows, row{step.Name, "TagOwnedByTags", a.Status == http.StatusOK, func() bool {
return ownedByTags(pm, tags, owners)
}})
case len(tags) > 0:
credentialTags[a.Key] = tags
default:
owner := byName("odin")
if name, ok := strings.CutPrefix(a.Principal, "user:"); ok {
owner = byName(name)
}
keyUsers[a.Key] = owner
}
case a.Kind == "join" && a.RequestTags != nil:
tags := *a.RequestTags
want := tagsGranted(t, step, a.Node, tags)
if owners, tagged := credentialTags[a.Key]; tagged {
rows = append(rows, row{step.Name, "TagOwnedByTags", want, func() bool {
return ownedByTags(pm, tags, owners)
}})
continue
}
user := keyUsers[a.Key]
require.NotNil(t, user, "%s: join with unknown key %s", step.Name, a.Key)
// Outside the policy's node set, as a node that is only now
// registering.
fresh := (&types.Node{
ID: types.NodeID(len(nodes) + 1), //nolint:gosec
User: user,
UserID: &user.ID,
IPv4: ptrAddr("100.64.255.254"),
}).View()
rows = append(rows,
row{step.Name, "UserCanHaveTag", want, func() bool {
return allTags(tags, func(tag string) bool { return pm.UserCanHaveTag(user.View(), tag) })
}},
row{step.Name, "NodeCanHaveTag", want, func() bool {
return allTags(tags, func(tag string) bool { return pm.NodeCanHaveTag(fresh, tag) })
}},
)
case a.Kind == "relogin" && a.RequestTags != nil:
tags := *a.RequestTags
want := tagsGranted(t, step, a.Node, tags)
node := findGrantsNode(nodes, a.Node)
require.NotNil(t, node, "%s: %s is not in the topology", step.Name, a.Node)
rows = append(rows, row{step.Name, "NodeCanHaveTag", want, func() bool {
return allTags(tags, func(tag string) bool { return pm.NodeCanHaveTag(node.View(), tag) })
}})
}
}
}
evaluated := map[string]bool{}
for _, step := range c.Steps {
stepRows := slices.DeleteFunc(slices.Clone(rows), func(r row) bool { return r.step != step.Name })
if len(stepRows) == 0 {
continue
}
for _, r := range stepRows {
evaluated[id+"/"+r.step+"/"+r.function] = true
}
t.Run(step.Name, func(t *testing.T) {
t.Parallel()
for _, r := range stepRows {
compatCheck(t, tagOwnerSkipReasons, id+"/"+r.step+"/"+r.function, func() string {
if got := r.got(); got != r.want {
return fmt.Sprintf("SaaS allowed %t, headscale %t", r.want, got)
}
return ""
})
}
})
}
for k := range tagOwnerSkipReasons {
if !evaluated[k] {
t.Errorf("skip entry %q names no check", k)
}
}
}
// tagsGranted reports whether SaaS gave node's device every requested tag:
// the step's device list holds a device on node's machine carrying them.
func tagsGranted(t *testing.T, step testcapture.Step, node string, tags []string) bool {
t.Helper()
for _, a := range slices.Backward(step.Actions) {
if a.Kind != "api" || a.Method != http.MethodGet || !strings.HasPrefix(a.Path, "/tailnet/-/devices") {
continue
}
var out struct {
Devices []struct {
MachineKey string `json:"machineKey"`
Tags []string `json:"tags"`
} `json:"devices"`
}
require.NoError(t, json.Unmarshal(a.Response, &out), "%s: device list", step.Name)
return slices.ContainsFunc(out.Devices, func(d struct {
MachineKey string `json:"machineKey"`
Tags []string `json:"tags"`
},
) bool {
return d.MachineKey == "{machine:"+node+"}" &&
allTags(tags, func(tag string) bool { return slices.Contains(d.Tags, tag) })
})
}
t.Fatalf("%s: no device list", step.Name)
return false
}
func ownedByTags(pm *PolicyManager, tags, ownerTags []string) bool {
return allTags(tags, func(tag string) bool { return pm.TagOwnedByTags(tag, ownerTags) })
}
func allTags(tags []string, ok func(string) bool) bool {
return len(tags) > 0 && !slices.ContainsFunc(tags, func(tag string) bool { return !ok(tag) })
}
+10 -7
View File
@@ -125,7 +125,7 @@ func runRouteLifecycleCompat(t *testing.T, id, observer string, skips map[string
historyStart = max(len(obs.History())-1, 0)
}
r.apply(t, step)
require.Empty(t, r.apply(t, step), "%s: every login succeeds", step.Name)
prevSaaS := prevObserved
prevObserved = step.Captures[observer].Netmap
@@ -350,12 +350,15 @@ type deviceRoutes struct {
// saasDevice is the part of a SaaS device-list entry the checks read.
// Keys and IDs are the capture's labels.
type saasDevice struct {
ID string `json:"id"`
NodeKey string `json:"nodeKey"`
MachineKey string `json:"machineKey"`
Addresses []string `json:"addresses"`
Tags []string `json:"tags"`
User string `json:"user"`
ID string `json:"id"`
NodeKey string `json:"nodeKey"`
MachineKey string `json:"machineKey"`
Addresses []string `json:"addresses"`
Tags []string `json:"tags"`
User string `json:"user"`
Expires string `json:"expires"`
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
IsEphemeral bool `json:"isEphemeral"`
}
// lastRoutesRead returns the step's last routes read: the device it named
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
+6937
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+193 -38
View File
@@ -19,6 +19,7 @@ import (
"net/netip"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
@@ -28,8 +29,10 @@ import (
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/control/controlclient"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
)
// viaHACompatTests lists golden captures that exercise via+HA interactions.
@@ -466,24 +469,38 @@ func readStepCapture(t *testing.T, id string) *testcapture.Capture {
}
// stepReplay drives a step capture's actions against headscale: policy
// pushes, key mints, joins, relogins, route announcements, logouts and
// admin calls, the last through the v2 API with the bodies SaaS received.
// Nothing is seeded from the capture's end state.
// pushes, key mints and revokes, joins, relogins, profile switches,
// restarts, route announcements, logouts, retires and admin calls, the
// last through the v2 API with the bodies SaaS received. Nothing is seeded
// from the capture's end state.
type stepReplay struct {
c *testcapture.Capture
srv *servertest.TestServer
admin *types.User
users map[string]*types.User
apiKey string
http *http.Client
keys map[string]string
clients map[string]*servertest.TestClient
hostinfo map[string]*tailcfg.Hostinfo
// keyIDs, keyTags and keyUsers describe each minted key by label.
keyIDs map[string]uint64
keyTags map[string][]string
keyUsers map[string]*types.User
// profiles holds the login a node switched away from.
profiles map[string]persist.Persist
// interactive, when set, picks the joins and logins replayed as a login
// approved by the key's user instead of with the key.
interactive func(step testcapture.Step, a testcapture.Action) bool
}
func newStepReplay(t *testing.T, c *testcapture.Capture) *stepReplay {
func newStepReplay(t *testing.T, c *testcapture.Capture, opts ...servertest.ServerOption) *stepReplay {
t.Helper()
srv := servertest.NewServer(t)
srv := servertest.NewServer(t, opts...)
users := createCaptureUsers(t, srv)
// SaaS keys and admin calls act as the tailnet's admin.
@@ -494,22 +511,29 @@ func newStepReplay(t *testing.T, c *testcapture.Capture) *stepReplay {
c: c,
srv: srv,
admin: admin,
users: users,
apiKey: srv.CreateAPIKey(t, admin),
http: &http.Client{Transport: &http.Transport{DialContext: srv.MemNet().Dial}},
keys: map[string]string{},
clients: map[string]*servertest.TestClient{},
hostinfo: map[string]*tailcfg.Hostinfo{},
keyIDs: map[string]uint64{},
keyTags: map[string][]string{},
keyUsers: map[string]*types.User{},
profiles: map[string]persist.Persist{},
}
}
// apply runs the step's actions in order. Reads are skipped: they are what
// consumers compare.
func (r *stepReplay) apply(t *testing.T, step testcapture.Step) {
// apply runs the step's actions in order and returns the logins headscale
// refused, by node. Reads are skipped: they are what consumers compare.
func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
refused := map[string]error{}
for _, a := range step.Actions {
switch a.Kind {
case "policy":
@@ -521,49 +545,112 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) {
require.NoError(t, json.Unmarshal(a.Body, &policy))
}
changed, err := r.srv.State().SetPolicy(convertPolicyEmails(t, r.c.TestID, policy))
require.NoError(t, err, "%s: policy", step.Name)
if changed {
changes, err := r.srv.State().ReloadPolicy()
require.NoError(t, err)
r.srv.App.Change(changes...)
}
r.setPolicy(t, step.Name, policy)
case "api":
r.api(t, step.Name, a)
case "join":
require.Contains(t, r.keys, a.Key, "%s: join with unknown key", step.Name)
hi := &tailcfg.Hostinfo{
BackendLogID: "servertest-" + a.Node,
Hostname: a.Hostname,
RequestTags: requestTags(a, nil),
}
r.hostinfo[a.Node] = hi
r.hostinfo[a.Node] = &tailcfg.Hostinfo{BackendLogID: "servertest-" + a.Node, Hostname: a.Hostname}
r.clients[a.Node] = servertest.NewClient(t, r.srv, a.Node,
servertest.WithUser(r.admin),
servertest.WithAuthKey(r.keys[a.Key]),
servertest.WithHostname(a.Hostname),
)
opts := []servertest.ClientOption{servertest.WithUser(r.admin), servertest.WithHostinfo(hi.Clone())}
if r.interactive != nil && r.interactive(step, a) {
opts = append(opts, servertest.WithInteractiveLogin(r.approver(ctx, t, a.Key)))
} else {
require.Contains(t, r.keys, a.Key, "%s: join with unknown key", step.Name)
opts = append(opts, servertest.WithAuthKey(r.keys[a.Key]))
}
cl, err := servertest.TryNewClient(t, r.srv, a.Node, opts...)
r.clients[a.Node] = cl
if err != nil {
refused[a.Node] = err
continue
}
if len(a.Routes) > 0 {
r.announce(ctx, t, a.Node, a.Routes)
}
case "relogin":
require.Contains(t, r.keys, a.Key, "%s: relogin with unknown key", step.Name)
// The probe restates hostname and routes on every login.
hi := r.hostinfo[a.Node].Clone()
hi.Hostname = a.Hostname
hi.RoutableIPs = prefixes(t, a.Routes)
hi.RequestTags = requestTags(a, hi.RequestTags)
r.hostinfo[a.Node] = hi
require.NoError(t, r.clients[a.Node].Relogin(ctx, r.keys[a.Key], a.Force, hi.Clone()),
"%s: relogin", step.Name)
cl := r.clients[a.Node]
var err error
if r.interactive != nil && r.interactive(step, a) {
err = cl.Login(ctx, cl.Persist(), "", controlclient.LoginInteractive, hi.Clone(), r.approver(ctx, t, a.Key))
} else {
require.Contains(t, r.keys, a.Key, "%s: relogin with unknown key", step.Name)
err = cl.Relogin(ctx, r.keys[a.Key], a.Force, hi.Clone())
}
if err != nil {
refused[a.Node] = err
}
case "switch_profile":
cl := r.clients[a.Node]
switch strings.Join(a.Args, " ") {
case "new":
r.profiles[a.Node] = cl.Persist()
case "back":
err := cl.Login(ctx, r.profiles[a.Node], "", controlclient.LoginDefault, r.hostinfo[a.Node].Clone(), nil)
if err != nil {
refused[a.Node] = err
}
default:
t.Fatalf("%s: no replay for switch_profile %q", step.Name, a.Args)
}
case "login":
// A login on a new profile: the machine key stays, the login
// starts empty.
hi := r.hostinfo[a.Node].Clone()
hi.Hostname = a.Hostname
cl := r.clients[a.Node]
var err error
if r.interactive != nil && r.interactive(step, a) {
err = cl.Login(ctx, persist.Persist{}, "", controlclient.LoginInteractive, hi, r.approver(ctx, t, a.Key))
} else {
require.Contains(t, r.keys, a.Key, "%s: login with unknown key", step.Name)
err = cl.Login(ctx, persist.Persist{}, r.keys[a.Key], controlclient.LoginDefault, hi, nil)
}
if err != nil {
refused[a.Node] = err
}
case "restart":
require.NoError(t, r.clients[a.Node].Restart(ctx, r.hostinfo[a.Node].Clone()), "%s: restart", step.Name)
case "advertise":
r.announce(ctx, t, a.Node, a.Routes)
case "cli":
hostname, ok := strings.CutPrefix(strings.Join(a.Args, " "), "set --hostname=")
args := strings.Join(a.Args, " ")
if args == "logout" {
require.NoError(t, r.clients[a.Node].LogoutAndDisconnect(ctx), "%s: logout", step.Name)
continue
}
hostname, ok := strings.CutPrefix(args, "set --hostname=")
require.True(t, ok, "%s: no replay for cli %q", step.Name, a.Args)
r.hostinfo[a.Node].Hostname = hostname
@@ -572,18 +659,66 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) {
case "logout":
require.NoError(t, r.clients[a.Node].LogoutAndDisconnect(ctx), "%s: logout", step.Name)
case "wait", "retire":
// Waits are SaaS settle time; retire only frees SaaS devices.
case "retire":
// SaaS stops the daemon and deletes its devices.
r.clients[a.Node].Disconnect(t)
for _, nv := range r.machineNodes(a.Node) {
changes, err := r.srv.State().DeleteNode(nv)
require.NoError(t, err, "%s: retire %s", step.Name, a.Node)
r.srv.App.Change(changes...)
}
case "wait":
// SaaS settle time.
default:
t.Fatalf("%s: no replay for action %q", step.Name, a.Kind)
}
}
return refused
}
// api replays one admin exchange. Device paths go to the v2 API; key
// mints and expiry, which the v2 API lacks or would answer with secrets,
// go to State.
// requestTags is the Hostinfo.RequestTags a login sends: the action's when
// it set them, else the ones the node sent last.
func requestTags(a testcapture.Action, last []string) []string {
if a.RequestTags == nil {
return last
}
return *a.RequestTags
}
// approver completes an interactive login as the user who owns key.
func (r *stepReplay) approver(ctx context.Context, t *testing.T, key string) func(string) error {
t.Helper()
user, ok := r.keyUsers[key]
require.True(t, ok, "interactive login for unknown key %s", key)
require.Empty(t, r.keyTags[key], "interactive login for tagged key %s", key)
return func(authID string) error {
return r.srv.ApproveAuth(ctx, authID, user.Name)
}
}
func (r *stepReplay) setPolicy(t *testing.T, stepName, policy string) {
t.Helper()
changed, err := r.srv.State().SetPolicy(convertPolicyEmails(t, r.c.TestID, policy))
require.NoError(t, err, "%s: policy", stepName)
if changed {
changes, err := r.srv.State().ReloadPolicy()
require.NoError(t, err)
r.srv.App.Change(changes...)
}
}
// api replays one admin exchange. Device and key deletes go to the v2 API;
// key mints and expiry, which the v2 API lacks or would answer with
// secrets, go to State.
func (r *stepReplay) api(t *testing.T, stepName string, a testcapture.Action) {
t.Helper()
@@ -591,7 +726,13 @@ func (r *stepReplay) api(t *testing.T, stepName string, a testcapture.Action) {
case a.Method == http.MethodGet:
return
case a.Method == http.MethodPost && a.Path == "/tailnet/-/acl":
require.Equal(t, http.StatusOK, a.Status, "%s: replay of a refused policy", stepName)
r.setPolicy(t, stepName, string(a.Body))
case a.Method == http.MethodPost && a.Path == "/tailnet/-/keys":
require.Equal(t, http.StatusOK, a.Status, "%s: replay of a refused mint %s", stepName, a.Key)
var body struct {
Capabilities struct {
Devices struct {
@@ -606,19 +747,33 @@ func (r *stepReplay) api(t *testing.T, stepName string, a testcapture.Action) {
}
require.NoError(t, json.Unmarshal(a.Body, &body), "%s: key body", stepName)
// The admin mints as the tailnet owner; a user's own key mints as
// that user.
user := r.admin
if name, ok := strings.CutPrefix(a.Principal, "user:"); ok {
user, ok = r.users[name]
require.True(t, ok, "%s: mint as unknown %s", stepName, a.Principal)
}
create := body.Capabilities.Devices.Create
expiry := time.Now().Add(time.Duration(body.ExpirySeconds) * time.Second)
uid := types.UserID(r.admin.ID)
uid := types.UserID(user.ID)
pak, err := r.srv.State().CreatePreAuthKey(&uid, create.Reusable, create.Ephemeral, &expiry, create.Tags)
require.NoError(t, err, "%s: mint %s", stepName, a.Key)
r.keys[a.Key] = pak.Key
r.keyIDs[a.Key] = pak.ID
r.keyTags[a.Key] = create.Tags
r.keyUsers[a.Key] = user
case a.Method == http.MethodDelete && strings.HasPrefix(a.Path, "/tailnet/-/keys/"):
pak, err := r.srv.State().GetPreAuthKey(r.keys[a.Key])
require.NoError(t, err, "%s: key %s", stepName, a.Key)
require.NoError(t, r.srv.State().DeletePreAuthKey(pak.ID), "%s: delete %s", stepName, a.Key)
case a.Method == http.MethodDelete && strings.HasPrefix(a.Path, "/tailnet/-/keys/{key:"):
label := strings.TrimSuffix(strings.TrimPrefix(a.Path, "/tailnet/-/keys/{key:"), "}")
id, ok := r.keyIDs[label]
require.True(t, ok, "%s: delete of unknown key %q", stepName, label)
status, resp := r.call(t, a.Method, "/api/v2/tailnet/-/keys/"+strconv.FormatUint(id, 10), nil)
require.Equal(t, a.Status, status, "%s: %s %s: %s", stepName, a.Method, a.Path, resp)
case a.Method == http.MethodPost && strings.HasSuffix(a.Path, "/expire"):
now := time.Now()