From f9e54092dbb10bec093f0e917b5f2bb9ae313ebc Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 20 Oct 2026 13:57:35 +0000 Subject: [PATCH] servertest, policy/v2: replay tag owner chain joins through registration A key login never asks the policy about requested tags, so the joins are checked where headscale decides them. OAuth mints stay at TagOwnedByTags. --- hscontrol/policy/v2/tagowner_compat_test.go | 25 ++++---- hscontrol/servertest/reauth_compat_test.go | 64 ++++++++++++++++----- hscontrol/servertest/via_ha_compat_test.go | 4 ++ 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/hscontrol/policy/v2/tagowner_compat_test.go b/hscontrol/policy/v2/tagowner_compat_test.go index 3b95fcd24..4bab77581 100644 --- a/hscontrol/policy/v2/tagowner_compat_test.go +++ b/hscontrol/policy/v2/tagowner_compat_test.go @@ -28,7 +28,6 @@ import ( 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, @@ -41,16 +40,18 @@ const ( ) // TestTagOwnerChainCompat compares each SaaS verdict on a tag request with -// the policy function headscale asks on that path: +// the policy function headscale asks for that request: // - 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. +// - an OAuth client's key mint: TagOwnedByTags with the client's tags. // -// The user's own mints have no verdict: as a member his API key could not -// mint at all. +// Only a login without a key asks the first two: a key login never asks the +// policy about requested tags. So TestReauthCompat in ../../servertest also +// replays the joins through registration, and checks a tagged key's joins +// only there. 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() @@ -132,17 +133,13 @@ func TestTagOwnerChainCompat(t *testing.T) { } 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) - }}) - + if _, tagged := credentialTags[a.Key]; tagged { continue } + tags := *a.RequestTags + want := tagsGranted(t, step, a.Node, tags) + user := keyUsers[a.Key] require.NotNil(t, user, "%s: join with unknown key %s", step.Name, a.Key) diff --git a/hscontrol/servertest/reauth_compat_test.go b/hscontrol/servertest/reauth_compat_test.go index 994a4e1f5..a7b58681d 100644 --- a/hscontrol/servertest/reauth_compat_test.go +++ b/hscontrol/servertest/reauth_compat_test.go @@ -1,9 +1,10 @@ // This file replays re-authentication captures from Tailscale SaaS. // // Test data source: testdata/steps/{owner-routes,reauth,reauth-foreign, -// reauth-owner,identity,reauth-user}.hujson, loaded by name. owner-routes is -// checked by runRouteLifecycleCompat in lifecycle_compat_test.go. The tag -// owner verdicts of tag-owner-chain.hujson are checked in ../policy/v2. The +// reauth-owner,identity,reauth-user,tag-owner-chain}.hujson, loaded by name. +// owner-routes is checked by runRouteLifecycleCompat in +// lifecycle_compat_test.go. tag-owner-chain's joins are replayed here; its +// OAuth mints and the tag owner functions are checked in ../policy/v2. The // refused logins of ephemeral-lifecycle.hujson are rows here too. // Source format: github.com/juanfont/headscale/hscontrol/types/testcapture @@ -80,6 +81,22 @@ var reauthSkipReasons = map[string]string{ "identity/w-tagreq-new-join/w-tagreq-new/peer-obs": keyLoginTagsRefused, "identity/w-tagreq-new-join/w-tagreq-new/peer-key-obs": keyLoginTagsRefused, + "tag-owner-chain/u-base-join/u-base/devices": keyLoginTagsRefused, + "tag-owner-chain/u-base-join/u-base/owner": keyLoginTagsRefused, + "tag-owner-chain/u-base-join/u-base/outcome": keyLoginTagsRefused, + "tag-owner-chain/u-base-join/u-base/expiry": keyLoginTagsRefused, + "tag-owner-chain/u-base-join/u-base/ephemeral": keyLoginTagsRefused, + "tag-owner-chain/u-base-join/u-base/peer-obs": keyLoginTagsRefused, + "tag-owner-chain/u-base-join/u-base/peer-key-obs": keyLoginTagsRefused, + + "tag-owner-chain/t-mid-join/t-mid/devices": taggedKeyOwnedTagRefused, + "tag-owner-chain/t-mid-join/t-mid/owner": taggedKeyOwnedTagRefused, + "tag-owner-chain/t-mid-join/t-mid/outcome": taggedKeyOwnedTagRefused, + "tag-owner-chain/t-mid-join/t-mid/expiry": taggedKeyOwnedTagRefused, + "tag-owner-chain/t-mid-join/t-mid/ephemeral": taggedKeyOwnedTagRefused, + "tag-owner-chain/t-mid-join/t-mid/peer-obs": taggedKeyOwnedTagRefused, + "tag-owner-chain/t-mid-join/t-mid/peer-key-obs": taggedKeyOwnedTagRefused, + "reauth/late-pak-spent-key/late-pak/text": refusalText, "reauth/late-pak-spent-key/late-pak/legacy-text": refusalText, @@ -96,12 +113,13 @@ const ( ) const ( - expiredKeyNotRotated = "EXPIRED_KEY_NOT_ROTATED: headscale answers an expired node's key login without asking for a new node key; SaaS answers NodeKeyExpired first, so the client rotates it" - sameKeyRenewsExpiry = "SAME_KEY_LOGIN_RENEWS_EXPIRY: headscale renews a user node's expiry on a key login that keeps its node key; SaaS renews it only with a new node key" - taggedLogoutNotExpired = "TAGGED_LOGOUT_NOT_EXPIRED: headscale leaves a tagged node unexpired when it logs out; SaaS expires it" - wakeWithTagsRefused = "WAKE_WITH_TAGS_REFUSED: headscale refuses a key login that requests tags the key does not carry even when the node keeps its node key and tags; SaaS accepts it and changes nothing" - keyLoginTagsRefused = "KEY_LOGIN_TAGS_REFUSED: headscale refuses a user's key login that requests tags the user owns; SaaS tags the device, converting it in place or creating it tagged" - refusalText = "REFUSAL_TEXT: SaaS refuses a key by naming its ID; headscale says why the key is refused" + expiredKeyNotRotated = "EXPIRED_KEY_NOT_ROTATED: headscale answers an expired node's key login without asking for a new node key; SaaS answers NodeKeyExpired first, so the client rotates it" + sameKeyRenewsExpiry = "SAME_KEY_LOGIN_RENEWS_EXPIRY: headscale renews a user node's expiry on a key login that keeps its node key; SaaS renews it only with a new node key" + taggedLogoutNotExpired = "TAGGED_LOGOUT_NOT_EXPIRED: headscale leaves a tagged node unexpired when it logs out; SaaS expires it" + wakeWithTagsRefused = "WAKE_WITH_TAGS_REFUSED: headscale refuses a key login that requests tags the key does not carry even when the node keeps its node key and tags; SaaS accepts it and changes nothing" + keyLoginTagsRefused = "KEY_LOGIN_TAGS_REFUSED: headscale refuses a user's key login that requests tags the user owns; SaaS tags the device, converting it in place or creating it tagged" + taggedKeyOwnedTagRefused = "TAGGED_KEY_OWNED_TAG_REFUSED: headscale refuses a tagged key's login that requests a tag the key's tags own; SaaS gives the device the requested tags" + refusalText = "REFUSAL_TEXT: SaaS refuses a key by naming its ID; headscale says why the key is refused" ) // reauthNotCompared lists, by key prefix, checks left out: SaaS's answer @@ -123,7 +141,7 @@ var reauthNotCompared = []string{ } // reauthCompatTests are the captures [runReauthCompat] replays. -var reauthCompatTests = []string{"reauth", "reauth-foreign", "reauth-owner", "identity", "reauth-user"} +var reauthCompatTests = []string{"reauth", "reauth-foreign", "reauth-owner", "identity", "reauth-user", "tag-owner-chain"} // reauthObservers stay up through a capture: their view of each cell and // their own filters are checked. @@ -133,9 +151,9 @@ var reauthObservers = []string{"obs", "t-server"} // owner changes on routers with approved routes. The others: expired and // live devices logging in again with spent, revoked, valid and other users' // keys, logout, ephemeral devices, admin tagging, conversion either way, a -// second profile, and one single-use tagged key presented by several -// devices. via=login replays a user's key logins as interactive logins that -// user approves. +// second profile, one single-use tagged key presented by several devices, +// and joins requesting tags owned through other tags. via=login replays a +// user's key logins as interactive logins that user approves. func TestReauthCompat(t *testing.T) { t.Parallel() @@ -193,6 +211,20 @@ func runReauthCompat(t *testing.T, id string, viaLogin bool) { } for _, step := range c.Steps { + // The replay has no OAuth client; ../policy/v2 checks the mints + // against TagOwnedByTags, which headscale's mint asks. + if slices.ContainsFunc(step.Actions, func(a testcapture.Action) bool { return a.Kind == "token" }) { + break + } + + if step.Result == "setup-failed" { + require.False(t, slices.ContainsFunc(step.Actions, func(a testcapture.Action) bool { + return a.Kind != "api" || a.Status < http.StatusBadRequest + }), "%s: a failed setup is skipped only when SaaS refused all of it", step.Name) + + continue + } + require.Contains(t, []string{"observed", "expected-refusal"}, step.Result, "%s: the replay follows every step", step.Name) @@ -775,6 +807,12 @@ func (r *stepReplay) keyChecks(step testcapture.Step) []stepCheck { continue } + // A member may read no keys, so his refused read says nothing about + // the key. + if a.Status != http.StatusOK && a.Principal != "admin" { + continue + } + label = strings.TrimSuffix(label, "}") want := "absent" diff --git a/hscontrol/servertest/via_ha_compat_test.go b/hscontrol/servertest/via_ha_compat_test.go index 6d8b7a0a3..54db6749d 100644 --- a/hscontrol/servertest/via_ha_compat_test.go +++ b/hscontrol/servertest/via_ha_compat_test.go @@ -1115,6 +1115,10 @@ func (r *stepReplay) api(t *testing.T, stepName string, i int, a testcapture.Act r.exchange(t, stepName, i, a, path) + case a.Method == http.MethodPost && strings.HasSuffix(a.Path, "/role"): + // Headscale has no user roles; a role decides only what the user's + // own API key may call, and the replay mints for him directly. + case a.Method == http.MethodPost && strings.HasSuffix(a.Path, "/expire"): now := time.Now()