mirror of
https://github.com/juanfont/headscale.git
synced 2026-01-11 20:00:28 +01:00
tags: process tags on registration, simplify policy (#2931)
This PR investigates, adds tests and aims to correctly implement Tailscale's model for how Tags should be accepted, assigned and used to identify nodes in the Tailscale access and ownership model. When evaluating in Headscale's policy, Tags are now only checked against a nodes "tags" list, which defines the source of truth for all tags for a given node. This simplifies the code for dealing with tags greatly, and should help us have less access bugs related to nodes belonging to tags or users. A node can either be owned by a user, or a tag. Next, to ensure the tags list on the node is correctly implemented, we first add tests for every registration scenario and combination of user, pre auth key and pre auth key with tags with the same registration expectation as observed by trying them all with the Tailscale control server. This should ensure that we implement the correct behaviour and that it does not change or break over time. Lastly, the missing parts of the auth has been added, or changed in the cases where it was wrong. This has in large parts allowed us to delete and simplify a lot of code. Now, tags can only be changed when a node authenticates or if set via the CLI/API. Tags can only be fully overwritten/replaced and any use of either auth or CLI will replace the current set if different. A user owned device can be converted to a tagged device, but it cannot be changed back. A tagged device can never remove the last tag either, it has to have a minimum of one.
This commit is contained in:
@@ -927,6 +927,82 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
},
|
||||
},
|
||||
|
||||
// === ADVERTISE-TAGS (RequestTags) SCENARIOS ===
|
||||
// Tests for client-provided tags via --advertise-tags flag
|
||||
|
||||
// TEST: PreAuthKey registration rejects client-provided RequestTags
|
||||
// WHAT: Tests that PreAuthKey registrations cannot use client-provided tags
|
||||
// INPUT: PreAuthKey registration with RequestTags in Hostinfo
|
||||
// EXPECTED: Registration fails with "requested tags [...] are invalid or not permitted" error
|
||||
// WHY: PreAuthKey nodes get their tags from the key itself, not from client requests
|
||||
{
|
||||
name: "preauth_key_rejects_request_tags",
|
||||
setupFunc: func(t *testing.T, app *Headscale) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
user := app.state.CreateUserForTest("pak-requesttags-user")
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pak.Key, nil
|
||||
},
|
||||
request: func(authKey string) tailcfg.RegisterRequest {
|
||||
return tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: authKey,
|
||||
},
|
||||
NodeKey: nodeKey1.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "pak-requesttags-node",
|
||||
RequestTags: []string{"tag:unauthorized"},
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
},
|
||||
machineKey: machineKey1.Public,
|
||||
wantError: true,
|
||||
},
|
||||
|
||||
// TEST: Tagged PreAuthKey ignores client-provided RequestTags
|
||||
// WHAT: Tests that tagged PreAuthKey uses key tags, not client RequestTags
|
||||
// INPUT: Tagged PreAuthKey registration with different RequestTags
|
||||
// EXPECTED: Registration fails because RequestTags are rejected for PreAuthKey
|
||||
// WHY: Tags-as-identity: PreAuthKey tags are authoritative, client cannot override
|
||||
{
|
||||
name: "tagged_preauth_key_rejects_client_request_tags",
|
||||
setupFunc: func(t *testing.T, app *Headscale) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
user := app.state.CreateUserForTest("tagged-pak-clienttags-user")
|
||||
keyTags := []string{"tag:authorized"}
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, keyTags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pak.Key, nil
|
||||
},
|
||||
request: func(authKey string) tailcfg.RegisterRequest {
|
||||
return tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: authKey,
|
||||
},
|
||||
NodeKey: nodeKey1.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "tagged-pak-clienttags-node",
|
||||
RequestTags: []string{"tag:client-wants-this"}, // Should be rejected
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
},
|
||||
machineKey: machineKey1.Public,
|
||||
wantError: true, // RequestTags rejected for PreAuthKey registrations
|
||||
},
|
||||
|
||||
// === RE-AUTHENTICATION SCENARIOS ===
|
||||
// TEST: Existing node re-authenticates with new pre-auth key
|
||||
// WHAT: Tests that existing node can re-authenticate using new pre-auth key
|
||||
@@ -1202,8 +1278,9 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
OS: "unknown-os",
|
||||
OSVersion: "999.999.999",
|
||||
DeviceModel: "test-device-model",
|
||||
RequestTags: []string{"invalid:tag", "another!tag"},
|
||||
Services: []tailcfg.Service{{Proto: "tcp", Port: 65535}},
|
||||
// Note: RequestTags are not included for PreAuthKey registrations
|
||||
// since tags come from the key itself, not client requests.
|
||||
Services: []tailcfg.Service{{Proto: "tcp", Port: 65535}},
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
@@ -1315,9 +1392,13 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
// === AUTH PROVIDER EDGE CASES ===
|
||||
// TEST: Interactive workflow preserves custom hostinfo
|
||||
// WHAT: Tests that custom hostinfo fields are preserved through interactive flow
|
||||
// INPUT: Interactive registration with detailed hostinfo (OS, version, model, etc.)
|
||||
// INPUT: Interactive registration with detailed hostinfo (OS, version, model)
|
||||
// EXPECTED: Node registers with all hostinfo fields preserved
|
||||
// WHY: Ensures interactive flow doesn't lose custom hostinfo data
|
||||
// NOTE: RequestTags are NOT tested here because tag authorization via
|
||||
// advertise-tags requires the user to have existing nodes (for IP-based
|
||||
// ownership verification). New users registering their first node cannot
|
||||
// claim tags via RequestTags - they must use a tagged PreAuthKey instead.
|
||||
{
|
||||
name: "interactive_workflow_with_custom_hostinfo",
|
||||
setupFunc: func(t *testing.T, app *Headscale) (string, error) {
|
||||
@@ -1331,7 +1412,6 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
OS: "linux",
|
||||
OSVersion: "20.04",
|
||||
DeviceModel: "server",
|
||||
RequestTags: []string{"tag:server"},
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
@@ -1353,7 +1433,6 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
assert.Equal(t, "linux", node.Hostinfo().OS())
|
||||
assert.Equal(t, "20.04", node.Hostinfo().OSVersion())
|
||||
assert.Equal(t, "server", node.Hostinfo().DeviceModel())
|
||||
assert.Contains(t, node.Hostinfo().RequestTags().AsSlice(), "tag:server")
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -3423,3 +3502,49 @@ func TestGitHubIssue2830_ExistingNodeCanReregisterWithUsedPreAuthKey(t *testing.
|
||||
nodesAfterAttack := app.state.ListNodesByUser(types.UserID(user.ID))
|
||||
require.Equal(t, 1, nodesAfterAttack.Len(), "Should still have exactly one node (attack prevented)")
|
||||
}
|
||||
|
||||
// TestWebAuthRejectsUnauthorizedRequestTags tests that web auth registrations
|
||||
// validate RequestTags against policy and reject unauthorized tags.
|
||||
func TestWebAuthRejectsUnauthorizedRequestTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := createTestApp(t)
|
||||
|
||||
// Create a user that will authenticate via web auth
|
||||
user := app.state.CreateUserForTest("webauth-tags-user")
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
// Simulate a registration cache entry (as would be created during web auth)
|
||||
registrationID := types.MustRegistrationID()
|
||||
regEntry := types.NewRegisterNode(types.Node{
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostname: "webauth-tags-node",
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "webauth-tags-node",
|
||||
RequestTags: []string{"tag:unauthorized"}, // This tag is not in policy
|
||||
},
|
||||
})
|
||||
app.state.SetRegistrationCacheEntry(registrationID, regEntry)
|
||||
|
||||
// Complete the web auth - should fail because tag is unauthorized
|
||||
_, _, err := app.state.HandleNodeFromAuthPath(
|
||||
registrationID,
|
||||
types.UserID(user.ID),
|
||||
nil, // no expiry
|
||||
"webauth",
|
||||
)
|
||||
|
||||
// Expect error due to unauthorized tags
|
||||
require.Error(t, err, "HandleNodeFromAuthPath should reject unauthorized RequestTags")
|
||||
require.Contains(t, err.Error(), "requested tags",
|
||||
"Error should indicate requested tags are invalid or not permitted")
|
||||
require.Contains(t, err.Error(), "tag:unauthorized",
|
||||
"Error should mention the rejected tag")
|
||||
|
||||
// Verify no node was created
|
||||
_, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.False(t, found, "Node should not be created when tags are unauthorized")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/samber/lo"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
@@ -556,13 +555,7 @@ func nodesToProto(state *state.State, nodes views.Slice[types.NodeView]) []*v1.N
|
||||
resp.User = types.TaggedDevices.Proto()
|
||||
}
|
||||
|
||||
var tags []string
|
||||
for _, tag := range node.RequestTags() {
|
||||
if state.NodeCanHaveTag(node, tag) {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
resp.ValidTags = lo.Uniq(append(tags, node.Tags().AsSlice()...))
|
||||
resp.ValidTags = node.Tags().AsSlice()
|
||||
|
||||
resp.SubnetRoutes = util.PrefixesToString(append(state.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...))
|
||||
response[index] = resp
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestSetTags_Conversion(t *testing.T) {
|
||||
tags: []string{"tag:server"},
|
||||
wantErr: true,
|
||||
wantCode: codes.InvalidArgument,
|
||||
wantErrMessage: "invalid or unauthorized tags",
|
||||
wantErrMessage: "requested tags",
|
||||
},
|
||||
{
|
||||
// Conversion is allowed, but tag authorization fails without tagOwners
|
||||
@@ -114,7 +114,7 @@ func TestSetTags_Conversion(t *testing.T) {
|
||||
tags: []string{"tag:server", "tag:database"},
|
||||
wantErr: true,
|
||||
wantCode: codes.InvalidArgument,
|
||||
wantErrMessage: "invalid or unauthorized tags",
|
||||
wantErrMessage: "requested tags",
|
||||
},
|
||||
{
|
||||
name: "reject non-existent node",
|
||||
|
||||
@@ -77,7 +77,7 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
|
||||
|
||||
_, matchers := b.mapper.state.Filter()
|
||||
tailnode, err := tailNode(
|
||||
nv, b.capVer, b.mapper.state,
|
||||
nv, b.capVer,
|
||||
func(id types.NodeID) []netip.Prefix {
|
||||
return policy.ReduceRoutes(nv, b.mapper.state.GetNodePrimaryRoutes(id), matchers)
|
||||
},
|
||||
@@ -252,7 +252,7 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
|
||||
}
|
||||
|
||||
tailPeers, err := tailNodes(
|
||||
changedViews, b.capVer, b.mapper.state,
|
||||
changedViews, b.capVer,
|
||||
func(id types.NodeID) []netip.Prefix {
|
||||
return policy.ReduceRoutes(node, b.mapper.state.GetNodePrimaryRoutes(id), matchers)
|
||||
},
|
||||
|
||||
@@ -5,21 +5,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/samber/lo"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// NodeCanHaveTagChecker is an interface for checking if a node can have a tag.
|
||||
type NodeCanHaveTagChecker interface {
|
||||
NodeCanHaveTag(node types.NodeView, tag string) bool
|
||||
}
|
||||
|
||||
func tailNodes(
|
||||
nodes views.Slice[types.NodeView],
|
||||
capVer tailcfg.CapabilityVersion,
|
||||
checker NodeCanHaveTagChecker,
|
||||
primaryRouteFunc routeFilterFunc,
|
||||
cfg *types.Config,
|
||||
) ([]*tailcfg.Node, error) {
|
||||
@@ -29,7 +22,6 @@ func tailNodes(
|
||||
tNode, err := tailNode(
|
||||
node,
|
||||
capVer,
|
||||
checker,
|
||||
primaryRouteFunc,
|
||||
cfg,
|
||||
)
|
||||
@@ -47,7 +39,6 @@ func tailNodes(
|
||||
func tailNode(
|
||||
node types.NodeView,
|
||||
capVer tailcfg.CapabilityVersion,
|
||||
checker NodeCanHaveTagChecker,
|
||||
primaryRouteFunc routeFilterFunc,
|
||||
cfg *types.Config,
|
||||
) (*tailcfg.Node, error) {
|
||||
@@ -77,18 +68,6 @@ func tailNode(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tags []string
|
||||
for _, tag := range node.RequestTagsSlice().All() {
|
||||
if checker.NodeCanHaveTag(node, tag) {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range node.Tags().All() {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
tags = lo.Uniq(tags)
|
||||
|
||||
routes := primaryRouteFunc(node.ID())
|
||||
allowed := append(addrs, routes...)
|
||||
allowed = append(allowed, node.ExitRoutes()...)
|
||||
@@ -118,7 +97,7 @@ func tailNode(
|
||||
|
||||
Online: node.IsOnline().Clone(),
|
||||
|
||||
Tags: tags,
|
||||
Tags: node.Tags().AsSlice(),
|
||||
|
||||
MachineAuthorized: !node.IsExpired(),
|
||||
Expired: node.IsExpired(),
|
||||
|
||||
@@ -8,10 +8,8 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/policy"
|
||||
"github.com/juanfont/headscale/hscontrol/routes"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
@@ -71,7 +69,6 @@ func TestTailNode(t *testing.T) {
|
||||
HomeDERP: 0,
|
||||
LegacyDERPString: "127.3.3.40:0",
|
||||
Hostinfo: hiview(tailcfg.Hostinfo{}),
|
||||
Tags: []string{},
|
||||
MachineAuthorized: true,
|
||||
|
||||
CapMap: tailcfg.NodeCapMap{
|
||||
@@ -186,7 +183,6 @@ func TestTailNode(t *testing.T) {
|
||||
HomeDERP: 0,
|
||||
LegacyDERPString: "127.3.3.40:0",
|
||||
Hostinfo: hiview(tailcfg.Hostinfo{}),
|
||||
Tags: []string{},
|
||||
MachineAuthorized: true,
|
||||
|
||||
CapMap: tailcfg.NodeCapMap{
|
||||
@@ -204,8 +200,6 @@ func TestTailNode(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
polMan, err := policy.NewPolicyManager(tt.pol, []types.User{}, types.Nodes{tt.node}.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
primary := routes.New()
|
||||
cfg := &types.Config{
|
||||
BaseDomain: tt.baseDomain,
|
||||
@@ -220,7 +214,6 @@ func TestTailNode(t *testing.T) {
|
||||
got, err := tailNode(
|
||||
tt.node.View(),
|
||||
0,
|
||||
polMan,
|
||||
func(id types.NodeID) []netip.Prefix {
|
||||
return primary.PrimaryRoutes(id)
|
||||
},
|
||||
@@ -274,13 +267,10 @@ func TestNodeExpiry(t *testing.T) {
|
||||
GivenName: "test",
|
||||
Expiry: tt.exp,
|
||||
}
|
||||
polMan, err := policy.NewPolicyManager(nil, nil, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
tn, err := tailNode(
|
||||
node.View(),
|
||||
0,
|
||||
polMan,
|
||||
func(id types.NodeID) []netip.Prefix {
|
||||
return []netip.Prefix{}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,9 @@ type PolicyManager interface {
|
||||
// NodeCanHaveTag reports whether the given node can have the given tag.
|
||||
NodeCanHaveTag(types.NodeView, string) bool
|
||||
|
||||
// TagExists reports whether the given tag is defined in the policy.
|
||||
TagExists(tag string) bool
|
||||
|
||||
// NodeCanApproveRoute reports whether the given node can approve the given route.
|
||||
NodeCanApproveRoute(types.NodeView, netip.Prefix) bool
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
@@ -19,6 +21,9 @@ import (
|
||||
"tailscale.com/util/deephash"
|
||||
)
|
||||
|
||||
// ErrInvalidTagOwner is returned when a tag owner is not an Alias type.
|
||||
var ErrInvalidTagOwner = errors.New("tag owner is not an Alias")
|
||||
|
||||
type PolicyManager struct {
|
||||
mu sync.Mutex
|
||||
pol *Policy
|
||||
@@ -536,23 +541,108 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// NodeCanHaveTag checks if a node can have the specified tag during client-initiated
|
||||
// registration or reauth flows (e.g., tailscale up --advertise-tags).
|
||||
//
|
||||
// This function is NOT used by the admin API's SetNodeTags - admins can set any
|
||||
// existing tag on any node by calling State.SetNodeTags directly, which bypasses
|
||||
// this authorization check.
|
||||
func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
|
||||
if pm == nil {
|
||||
if pm == nil || pm.pol == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// Check if tag exists in policy
|
||||
owners, exists := pm.pol.TagOwners[Tag(tag)]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if node's owner can assign this tag via the pre-resolved tagOwnerMap.
|
||||
// The tagOwnerMap contains IP sets built from resolving TagOwners entries
|
||||
// (usernames/groups) to their nodes' IPs, so checking if the node's IP
|
||||
// is in the set answers "does this node's owner own this tag?"
|
||||
if ips, ok := pm.tagOwnerMap[Tag(tag)]; ok {
|
||||
if slices.ContainsFunc(node.IPs(), ips.Contains) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// For new nodes being registered, their IP may not yet be in the tagOwnerMap.
|
||||
// Fall back to checking the node's user directly against the TagOwners.
|
||||
// This handles the case where a user registers a new node with --advertise-tags.
|
||||
if node.User().Valid() {
|
||||
for _, owner := range owners {
|
||||
if pm.userMatchesOwner(node.User(), owner) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// userMatchesOwner checks if a user matches a tag owner entry.
|
||||
// This is used as a fallback when the node's IP is not in the tagOwnerMap.
|
||||
func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool {
|
||||
switch o := owner.(type) {
|
||||
case *Username:
|
||||
if o == nil {
|
||||
return false
|
||||
}
|
||||
// Resolve the username to find the user it refers to
|
||||
resolvedUser, err := o.resolveUser(pm.users)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return user.ID() == resolvedUser.ID
|
||||
|
||||
case *Group:
|
||||
if o == nil || pm.pol == nil {
|
||||
return false
|
||||
}
|
||||
// Resolve the group to get usernames
|
||||
usernames, ok := pm.pol.Groups[*o]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Check if the user matches any username in the group
|
||||
for _, uname := range usernames {
|
||||
resolvedUser, err := uname.resolveUser(pm.users)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if user.ID() == resolvedUser.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TagExists reports whether the given tag is defined in the policy.
|
||||
func (pm *PolicyManager) TagExists(tag string) bool {
|
||||
if pm == nil || pm.pol == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
_, exists := pm.pol.TagOwners[Tag(tag)]
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool {
|
||||
if pm == nil {
|
||||
return false
|
||||
@@ -834,3 +924,126 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flattenTags flattens the TagOwners by resolving nested tags and detecting cycles.
|
||||
// It will return a Owners list where all the Tag types have been resolved to their underlying Owners.
|
||||
func flattenTags(tagOwners TagOwners, tag Tag, visiting map[Tag]bool, chain []Tag) (Owners, error) {
|
||||
if visiting[tag] {
|
||||
cycleStart := 0
|
||||
|
||||
for i, t := range chain {
|
||||
if t == tag {
|
||||
cycleStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cycleTags := make([]string, len(chain[cycleStart:]))
|
||||
for i, t := range chain[cycleStart:] {
|
||||
cycleTags[i] = string(t)
|
||||
}
|
||||
|
||||
slices.Sort(cycleTags)
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", ErrCircularReference, strings.Join(cycleTags, " -> "))
|
||||
}
|
||||
|
||||
visiting[tag] = true
|
||||
|
||||
chain = append(chain, tag)
|
||||
defer delete(visiting, tag)
|
||||
|
||||
var result Owners
|
||||
|
||||
for _, owner := range tagOwners[tag] {
|
||||
switch o := owner.(type) {
|
||||
case *Tag:
|
||||
if _, ok := tagOwners[*o]; !ok {
|
||||
return nil, fmt.Errorf("tag %q %w %q", tag, ErrUndefinedTagReference, *o)
|
||||
}
|
||||
|
||||
nested, err := flattenTags(tagOwners, *o, visiting, chain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, nested...)
|
||||
default:
|
||||
result = append(result, owner)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// flattenTagOwners flattens all TagOwners by resolving nested tags and detecting cycles.
|
||||
// It will return a new TagOwners map where all the Tag types have been resolved to their underlying Owners.
|
||||
func flattenTagOwners(tagOwners TagOwners) (TagOwners, error) {
|
||||
ret := make(TagOwners)
|
||||
|
||||
for tag := range tagOwners {
|
||||
flattened, err := flattenTags(tagOwners, tag, make(map[Tag]bool), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slices.SortFunc(flattened, func(a, b Owner) int {
|
||||
return cmp.Compare(a.String(), b.String())
|
||||
})
|
||||
ret[tag] = slices.CompactFunc(flattened, func(a, b Owner) bool {
|
||||
return a.String() == b.String()
|
||||
})
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// resolveTagOwners resolves the TagOwners to a map of Tag to netipx.IPSet.
|
||||
// The resulting map can be used to quickly look up the IPSet for a given Tag.
|
||||
// It is intended for internal use in a PolicyManager.
|
||||
func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (map[Tag]*netipx.IPSet, error) {
|
||||
if p == nil {
|
||||
return make(map[Tag]*netipx.IPSet), nil
|
||||
}
|
||||
|
||||
if len(p.TagOwners) == 0 {
|
||||
return make(map[Tag]*netipx.IPSet), nil
|
||||
}
|
||||
|
||||
ret := make(map[Tag]*netipx.IPSet)
|
||||
|
||||
tagOwners, err := flattenTagOwners(p.TagOwners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for tag, owners := range tagOwners {
|
||||
var ips netipx.IPSetBuilder
|
||||
|
||||
for _, owner := range owners {
|
||||
switch o := owner.(type) {
|
||||
case *Tag:
|
||||
// After flattening, Tag types should not appear in the owners list.
|
||||
// If they do, skip them as they represent already-resolved references.
|
||||
|
||||
case Alias:
|
||||
// If it does not resolve, that means the tag is not associated with any IP addresses.
|
||||
resolved, _ := o.Resolve(p, users, nodes)
|
||||
ips.AddSet(resolved)
|
||||
|
||||
default:
|
||||
// Should never happen - after flattening, all owners should be Alias types
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidTagOwner, owner)
|
||||
}
|
||||
}
|
||||
|
||||
ipSet, err := ips.IPSet()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret[tag] = ipSet
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
@@ -307,35 +306,11 @@ func (t *Tag) UnmarshalJSON(b []byte) error {
|
||||
func (t Tag) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
||||
var ips netipx.IPSetBuilder
|
||||
|
||||
// TODO(kradalby): This is currently resolved twice, and should be resolved once.
|
||||
// It is added temporary until we sort out the story on how and when we resolve tags
|
||||
// from the three places they can be "approved":
|
||||
// - As part of a PreAuthKey (handled in HasTag)
|
||||
// - As part of ForcedTags (set via CLI) (handled in HasTag)
|
||||
// - As part of HostInfo.RequestTags and approved by policy (this is happening here)
|
||||
// Part of #2417
|
||||
tagMap, err := resolveTagOwners(p, users, nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, node := range nodes.All() {
|
||||
// Check if node has this tag
|
||||
if node.HasTag(string(t)) {
|
||||
node.AppendToIPSet(&ips)
|
||||
}
|
||||
|
||||
// TODO(kradalby): remove as part of #2417, see comment above
|
||||
if tagMap != nil {
|
||||
if tagips, ok := tagMap[t]; ok && node.InIPSet(tagips) && node.Hostinfo().Valid() {
|
||||
for _, tag := range node.RequestTagsSlice().All() {
|
||||
if tag == string(t) {
|
||||
node.AppendToIPSet(&ips)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ips.IPSet()
|
||||
@@ -545,61 +520,26 @@ func (ag AutoGroup) Resolve(p *Policy, users types.Users, nodes views.Slice[type
|
||||
return util.TheInternet(), nil
|
||||
|
||||
case AutoGroupMember:
|
||||
// autogroup:member represents all untagged devices in the tailnet.
|
||||
tagMap, err := resolveTagOwners(p, users, nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, node := range nodes.All() {
|
||||
// Skip if node is tagged
|
||||
if node.IsTagged() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if node has any allowed requested tags
|
||||
hasAllowedTag := false
|
||||
if node.RequestTagsSlice().Len() != 0 {
|
||||
for _, tag := range node.RequestTagsSlice().All() {
|
||||
if _, ok := tagMap[Tag(tag)]; ok {
|
||||
hasAllowedTag = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasAllowedTag {
|
||||
continue
|
||||
}
|
||||
|
||||
// Node is a member if it has no forced tags and no allowed requested tags
|
||||
// Node is a member if it is not tagged
|
||||
node.AppendToIPSet(&build)
|
||||
}
|
||||
|
||||
return build.IPSet()
|
||||
|
||||
case AutoGroupTagged:
|
||||
// autogroup:tagged represents all devices with a tag in the tailnet.
|
||||
tagMap, err := resolveTagOwners(p, users, nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, node := range nodes.All() {
|
||||
// Include if node is tagged
|
||||
if node.IsTagged() {
|
||||
node.AppendToIPSet(&build)
|
||||
if !node.IsTagged() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Include if node has any allowed requested tags
|
||||
if node.RequestTagsSlice().Len() != 0 {
|
||||
for _, tag := range node.RequestTagsSlice().All() {
|
||||
if _, ok := tagMap[Tag(tag)]; ok {
|
||||
node.AppendToIPSet(&build)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
node.AppendToIPSet(&build)
|
||||
}
|
||||
|
||||
return build.IPSet()
|
||||
@@ -1177,129 +1117,6 @@ func (to TagOwners) Contains(tagOwner *Tag) error {
|
||||
return fmt.Errorf(`Tag %q is not defined in the Policy, please define or remove the reference to it`, tagOwner)
|
||||
}
|
||||
|
||||
// resolveTagOwners resolves the TagOwners to a map of Tag to netipx.IPSet.
|
||||
// The resulting map can be used to quickly look up the IPSet for a given Tag.
|
||||
// It is intended for internal use in a PolicyManager.
|
||||
func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (map[Tag]*netipx.IPSet, error) {
|
||||
if p == nil {
|
||||
return make(map[Tag]*netipx.IPSet), nil
|
||||
}
|
||||
|
||||
if len(p.TagOwners) == 0 {
|
||||
return make(map[Tag]*netipx.IPSet), nil
|
||||
}
|
||||
|
||||
ret := make(map[Tag]*netipx.IPSet)
|
||||
|
||||
tagOwners, err := flattenTagOwners(p.TagOwners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for tag, owners := range tagOwners {
|
||||
var ips netipx.IPSetBuilder
|
||||
|
||||
for _, owner := range owners {
|
||||
switch o := owner.(type) {
|
||||
case *Tag:
|
||||
// After flattening, Tag types should not appear in the owners list.
|
||||
// If they do, skip them as they represent already-resolved references.
|
||||
|
||||
case Alias:
|
||||
// If it does not resolve, that means the tag is not associated with any IP addresses.
|
||||
resolved, _ := o.Resolve(p, users, nodes)
|
||||
ips.AddSet(resolved)
|
||||
|
||||
default:
|
||||
// Should never happen
|
||||
return nil, fmt.Errorf("owner %v is not an Alias", owner)
|
||||
}
|
||||
}
|
||||
|
||||
ipSet, err := ips.IPSet()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret[tag] = ipSet
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// flattenTags flattens the TagOwners by resolving nested tags and detecting cycles.
|
||||
// It will return a Owners list where all the Tag types have been resolved to their underlying Owners.
|
||||
func flattenTags(tagOwners TagOwners, tag Tag, visiting map[Tag]bool, chain []Tag) (Owners, error) {
|
||||
if visiting[tag] {
|
||||
cycleStart := 0
|
||||
|
||||
for i, t := range chain {
|
||||
if t == tag {
|
||||
cycleStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cycleTags := make([]string, len(chain[cycleStart:]))
|
||||
for i, t := range chain[cycleStart:] {
|
||||
cycleTags[i] = string(t)
|
||||
}
|
||||
|
||||
slices.Sort(cycleTags)
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", ErrCircularReference, strings.Join(cycleTags, " -> "))
|
||||
}
|
||||
|
||||
visiting[tag] = true
|
||||
|
||||
chain = append(chain, tag)
|
||||
defer delete(visiting, tag)
|
||||
|
||||
var result Owners
|
||||
|
||||
for _, owner := range tagOwners[tag] {
|
||||
switch o := owner.(type) {
|
||||
case *Tag:
|
||||
if _, ok := tagOwners[*o]; !ok {
|
||||
return nil, fmt.Errorf("tag %q %w %q", tag, ErrUndefinedTagReference, *o)
|
||||
}
|
||||
|
||||
nested, err := flattenTags(tagOwners, *o, visiting, chain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, nested...)
|
||||
default:
|
||||
result = append(result, owner)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// flattenTagOwners flattens all TagOwners by resolving nested tags and detecting cycles.
|
||||
// It will return a new TagOwners map where all the Tag types have been resolved to their underlying Owners.
|
||||
func flattenTagOwners(tagOwners TagOwners) (TagOwners, error) {
|
||||
ret := make(TagOwners)
|
||||
|
||||
for tag := range tagOwners {
|
||||
flattened, err := flattenTags(tagOwners, tag, make(map[Tag]bool), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slices.SortFunc(flattened, func(a, b Owner) int {
|
||||
return cmp.Compare(a.String(), b.String())
|
||||
})
|
||||
ret[tag] = slices.CompactFunc(flattened, func(a, b Owner) bool {
|
||||
return a.String() == b.String()
|
||||
})
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
type AutoApproverPolicy struct {
|
||||
Routes map[netip.Prefix]AutoApprovers `json:"routes,omitempty"`
|
||||
ExitNode AutoApprovers `json:"exitNode,omitempty"`
|
||||
|
||||
@@ -1862,124 +1862,108 @@ func TestResolvePolicy(t *testing.T) {
|
||||
name: "autogroup-member-comprehensive",
|
||||
toResolve: ptr.To(AutoGroup(AutoGroupMember)),
|
||||
nodes: types.Nodes{
|
||||
// Node with no tags (should be included)
|
||||
// Node with no tags (should be included - is a member)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
IPv4: ap("100.100.101.1"),
|
||||
},
|
||||
// Node with forced tags (should be excluded)
|
||||
// Node with single tag (should be excluded - tagged nodes are not members)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Tags: []string{"tag:test"},
|
||||
IPv4: ap("100.100.101.2"),
|
||||
},
|
||||
// Node with allowed requested tag (should be excluded)
|
||||
// Node with multiple tags, all defined in policy (should be excluded)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RequestTags: []string{"tag:test"},
|
||||
},
|
||||
Tags: []string{"tag:test", "tag:other"},
|
||||
IPv4: ap("100.100.101.3"),
|
||||
},
|
||||
// Node with non-allowed requested tag (should be included)
|
||||
// Node with tag not defined in policy (should be excluded - still tagged)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RequestTags: []string{"tag:notallowed"},
|
||||
},
|
||||
Tags: []string{"tag:undefined"},
|
||||
IPv4: ap("100.100.101.4"),
|
||||
},
|
||||
// Node with multiple requested tags, one allowed (should be excluded)
|
||||
// Node with mixed tags - some defined, some not (should be excluded)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RequestTags: []string{"tag:test", "tag:notallowed"},
|
||||
},
|
||||
Tags: []string{"tag:test", "tag:undefined"},
|
||||
IPv4: ap("100.100.101.5"),
|
||||
},
|
||||
// Node with multiple requested tags, none allowed (should be included)
|
||||
// Another untagged node from different user (should be included)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RequestTags: []string{"tag:notallowed1", "tag:notallowed2"},
|
||||
},
|
||||
User: ptr.To(testuser2),
|
||||
IPv4: ap("100.100.101.6"),
|
||||
},
|
||||
},
|
||||
pol: &Policy{
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:test"): Owners{ptr.To(Username("testuser@"))},
|
||||
Tag("tag:test"): Owners{ptr.To(Username("testuser@"))},
|
||||
Tag("tag:other"): Owners{ptr.To(Username("testuser@"))},
|
||||
},
|
||||
},
|
||||
want: []netip.Prefix{
|
||||
mp("100.100.101.1/32"), // No tags
|
||||
mp("100.100.101.4/32"), // Non-allowed requested tag
|
||||
mp("100.100.101.6/32"), // Multiple non-allowed requested tags
|
||||
mp("100.100.101.1/32"), // No tags - is a member
|
||||
mp("100.100.101.6/32"), // No tags, different user - is a member
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autogroup-tagged",
|
||||
toResolve: ptr.To(AutoGroup(AutoGroupTagged)),
|
||||
nodes: types.Nodes{
|
||||
// Node with no tags (should be excluded)
|
||||
// Node with no tags (should be excluded - not tagged)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
IPv4: ap("100.100.101.1"),
|
||||
},
|
||||
// Node with forced tag (should be included)
|
||||
// Node with single tag defined in policy (should be included)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Tags: []string{"tag:test"},
|
||||
IPv4: ap("100.100.101.2"),
|
||||
},
|
||||
// Node with allowed requested tag (should be included)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RequestTags: []string{"tag:test"},
|
||||
},
|
||||
IPv4: ap("100.100.101.3"),
|
||||
},
|
||||
// Node with non-allowed requested tag (should be excluded)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RequestTags: []string{"tag:notallowed"},
|
||||
},
|
||||
IPv4: ap("100.100.101.4"),
|
||||
},
|
||||
// Node with multiple requested tags, one allowed (should be included)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RequestTags: []string{"tag:test", "tag:notallowed"},
|
||||
},
|
||||
IPv4: ap("100.100.101.5"),
|
||||
},
|
||||
// Node with multiple requested tags, none allowed (should be excluded)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RequestTags: []string{"tag:notallowed1", "tag:notallowed2"},
|
||||
},
|
||||
IPv4: ap("100.100.101.6"),
|
||||
},
|
||||
// Node with multiple forced tags (should be included)
|
||||
// Node with multiple tags, all defined in policy (should be included)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Tags: []string{"tag:test", "tag:other"},
|
||||
IPv4: ap("100.100.101.3"),
|
||||
},
|
||||
// Node with tag not defined in policy (should be included - still tagged)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Tags: []string{"tag:undefined"},
|
||||
IPv4: ap("100.100.101.4"),
|
||||
},
|
||||
// Node with mixed tags - some defined, some not (should be included)
|
||||
{
|
||||
User: ptr.To(testuser),
|
||||
Tags: []string{"tag:test", "tag:undefined"},
|
||||
IPv4: ap("100.100.101.5"),
|
||||
},
|
||||
// Another untagged node from different user (should be excluded)
|
||||
{
|
||||
User: ptr.To(testuser2),
|
||||
IPv4: ap("100.100.101.6"),
|
||||
},
|
||||
// Tagged node from different user (should be included)
|
||||
{
|
||||
User: ptr.To(testuser2),
|
||||
Tags: []string{"tag:server"},
|
||||
IPv4: ap("100.100.101.7"),
|
||||
},
|
||||
},
|
||||
pol: &Policy{
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:test"): Owners{ptr.To(Username("testuser@"))},
|
||||
Tag("tag:test"): Owners{ptr.To(Username("testuser@"))},
|
||||
Tag("tag:other"): Owners{ptr.To(Username("testuser@"))},
|
||||
Tag("tag:server"): Owners{ptr.To(Username("testuser2@"))},
|
||||
},
|
||||
},
|
||||
want: []netip.Prefix{
|
||||
mp("100.100.101.2/31"), // Forced tag and allowed requested tag consecutive IPs are put in 31 prefix
|
||||
mp("100.100.101.5/32"), // Multiple requested tags, one allowed
|
||||
mp("100.100.101.7/32"), // Multiple forced tags
|
||||
mp("100.100.101.2/31"), // .2, .3 consecutive tagged nodes
|
||||
mp("100.100.101.4/31"), // .4, .5 consecutive tagged nodes
|
||||
mp("100.100.101.7/32"), // Tagged node from different user
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2001,9 +1985,7 @@ func TestResolvePolicy(t *testing.T) {
|
||||
},
|
||||
{
|
||||
User: ptr.To(testuser2),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RequestTags: []string{"tag:test"},
|
||||
},
|
||||
Tags: []string{"tag:test"},
|
||||
IPv4: ap("100.100.101.4"),
|
||||
},
|
||||
},
|
||||
@@ -2738,6 +2720,127 @@ func TestNodeCanHaveTag(t *testing.T) {
|
||||
tag: "tag:dev", // This tag is not defined in tagOwners
|
||||
want: false,
|
||||
},
|
||||
// Test cases for nodes without IPs (new registration scenario)
|
||||
// These test the user-based fallback in NodeCanHaveTag
|
||||
{
|
||||
name: "node-without-ip-user-owns-tag",
|
||||
policy: &Policy{
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:test"): Owners{ptr.To(Username("user1@"))},
|
||||
},
|
||||
},
|
||||
node: &types.Node{
|
||||
// No IPv4 or IPv6 - simulates new node registration
|
||||
User: &users[0],
|
||||
UserID: ptr.To(users[0].ID),
|
||||
},
|
||||
tag: "tag:test",
|
||||
want: true, // Should succeed via user-based fallback
|
||||
},
|
||||
{
|
||||
name: "node-without-ip-user-does-not-own-tag",
|
||||
policy: &Policy{
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:test"): Owners{ptr.To(Username("user2@"))},
|
||||
},
|
||||
},
|
||||
node: &types.Node{
|
||||
// No IPv4 or IPv6 - simulates new node registration
|
||||
User: &users[0], // user1, but tag owned by user2
|
||||
UserID: ptr.To(users[0].ID),
|
||||
},
|
||||
tag: "tag:test",
|
||||
want: false, // user1 does not own tag:test
|
||||
},
|
||||
{
|
||||
name: "node-without-ip-group-owns-tag",
|
||||
policy: &Policy{
|
||||
Groups: Groups{
|
||||
"group:admins": Usernames{"user1@", "user2@"},
|
||||
},
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:admin"): Owners{ptr.To(Group("group:admins"))},
|
||||
},
|
||||
},
|
||||
node: &types.Node{
|
||||
// No IPv4 or IPv6 - simulates new node registration
|
||||
User: &users[1], // user2 is in group:admins
|
||||
UserID: ptr.To(users[1].ID),
|
||||
},
|
||||
tag: "tag:admin",
|
||||
want: true, // Should succeed via group membership
|
||||
},
|
||||
{
|
||||
name: "node-without-ip-not-in-group",
|
||||
policy: &Policy{
|
||||
Groups: Groups{
|
||||
"group:admins": Usernames{"user1@"},
|
||||
},
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:admin"): Owners{ptr.To(Group("group:admins"))},
|
||||
},
|
||||
},
|
||||
node: &types.Node{
|
||||
// No IPv4 or IPv6 - simulates new node registration
|
||||
User: &users[1], // user2 is NOT in group:admins
|
||||
UserID: ptr.To(users[1].ID),
|
||||
},
|
||||
tag: "tag:admin",
|
||||
want: false, // user2 is not in group:admins
|
||||
},
|
||||
{
|
||||
name: "node-without-ip-no-user",
|
||||
policy: &Policy{
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:test"): Owners{ptr.To(Username("user1@"))},
|
||||
},
|
||||
},
|
||||
node: &types.Node{
|
||||
// No IPv4, IPv6, or User - edge case
|
||||
},
|
||||
tag: "tag:test",
|
||||
want: false, // No user means can't authorize via user-based fallback
|
||||
},
|
||||
{
|
||||
name: "node-without-ip-mixed-owners-user-match",
|
||||
policy: &Policy{
|
||||
Groups: Groups{
|
||||
"group:ops": Usernames{"user3@"},
|
||||
},
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:server"): Owners{
|
||||
ptr.To(Username("user1@")),
|
||||
ptr.To(Group("group:ops")),
|
||||
},
|
||||
},
|
||||
},
|
||||
node: &types.Node{
|
||||
User: &users[0], // user1 directly owns the tag
|
||||
UserID: ptr.To(users[0].ID),
|
||||
},
|
||||
tag: "tag:server",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "node-without-ip-mixed-owners-group-match",
|
||||
policy: &Policy{
|
||||
Groups: Groups{
|
||||
"group:ops": Usernames{"user3@"},
|
||||
},
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:server"): Owners{
|
||||
ptr.To(Username("user1@")),
|
||||
ptr.To(Group("group:ops")),
|
||||
},
|
||||
},
|
||||
},
|
||||
node: &types.Node{
|
||||
User: &users[2], // user3 is in group:ops
|
||||
UserID: ptr.To(users[2].ID),
|
||||
},
|
||||
tag: "tag:server",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -2760,6 +2863,106 @@ func TestNodeCanHaveTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMatchesOwner(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "user1"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "user2"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "user3"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
policy *Policy
|
||||
user types.User
|
||||
owner Owner
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "username-match",
|
||||
policy: &Policy{},
|
||||
user: users[0],
|
||||
owner: ptr.To(Username("user1@")),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "username-no-match",
|
||||
policy: &Policy{},
|
||||
user: users[0],
|
||||
owner: ptr.To(Username("user2@")),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "group-match",
|
||||
policy: &Policy{
|
||||
Groups: Groups{
|
||||
"group:admins": Usernames{"user1@", "user2@"},
|
||||
},
|
||||
},
|
||||
user: users[1], // user2 is in group:admins
|
||||
owner: ptr.To(Group("group:admins")),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "group-no-match",
|
||||
policy: &Policy{
|
||||
Groups: Groups{
|
||||
"group:admins": Usernames{"user1@"},
|
||||
},
|
||||
},
|
||||
user: users[1], // user2 is NOT in group:admins
|
||||
owner: ptr.To(Group("group:admins")),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "group-not-defined",
|
||||
policy: &Policy{
|
||||
Groups: Groups{},
|
||||
},
|
||||
user: users[0],
|
||||
owner: ptr.To(Group("group:undefined")),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil-username-owner",
|
||||
policy: &Policy{},
|
||||
user: users[0],
|
||||
owner: (*Username)(nil),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil-group-owner",
|
||||
policy: &Policy{},
|
||||
user: users[0],
|
||||
owner: (*Group)(nil),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a minimal PolicyManager for testing
|
||||
// We need nodes with IPs to initialize the tagOwnerMap
|
||||
nodes := types.Nodes{
|
||||
{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
User: &users[0],
|
||||
},
|
||||
}
|
||||
|
||||
b, err := json.Marshal(tt.policy)
|
||||
require.NoError(t, err)
|
||||
|
||||
pm, err := NewPolicyManager(b, users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
got := pm.userMatchesOwner(tt.user.View(), tt.owner)
|
||||
if got != tt.want {
|
||||
t.Errorf("userMatchesOwner() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestACL_UnmarshalJSON_WithCommentFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -669,12 +670,27 @@ func (s *State) SetNodeTags(nodeID types.NodeID, tags []string) (types.NodeView,
|
||||
return types.NodeView{}, change.EmptySet, fmt.Errorf("%w: %d", ErrNodeNotFound, nodeID)
|
||||
}
|
||||
|
||||
// Validate tags against policy
|
||||
validatedTags, err := s.validateAndNormalizeTags(existingNode.AsStruct(), tags)
|
||||
if err != nil {
|
||||
return types.NodeView{}, change.EmptySet, err
|
||||
// Validate tags: must have correct format and exist in policy
|
||||
validatedTags := make([]string, 0, len(tags))
|
||||
invalidTags := make([]string, 0)
|
||||
|
||||
for _, tag := range tags {
|
||||
if !strings.HasPrefix(tag, "tag:") || !s.polMan.TagExists(tag) {
|
||||
invalidTags = append(invalidTags, tag)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
validatedTags = append(validatedTags, tag)
|
||||
}
|
||||
|
||||
if len(invalidTags) > 0 {
|
||||
return types.NodeView{}, change.EmptySet, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, invalidTags)
|
||||
}
|
||||
|
||||
slices.Sort(validatedTags)
|
||||
validatedTags = slices.Compact(validatedTags)
|
||||
|
||||
// Log the operation
|
||||
logTagOperation(existingNode, validatedTags)
|
||||
|
||||
@@ -1128,6 +1144,41 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
|
||||
nodeToRegister.Tags = nil
|
||||
}
|
||||
|
||||
// Reject advertise-tags for PreAuthKey registrations early, before any resource allocation.
|
||||
// PreAuthKey nodes get their tags from the key itself, not from client requests.
|
||||
if params.PreAuthKey != nil && params.Hostinfo != nil && len(params.Hostinfo.RequestTags) > 0 {
|
||||
return types.NodeView{}, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, params.Hostinfo.RequestTags)
|
||||
}
|
||||
|
||||
// Process RequestTags (from tailscale up --advertise-tags) ONLY for non-PreAuthKey registrations.
|
||||
// Validate early before IP allocation to avoid resource leaks on failure.
|
||||
if params.PreAuthKey == nil && params.Hostinfo != nil && len(params.Hostinfo.RequestTags) > 0 {
|
||||
var approvedTags, rejectedTags []string
|
||||
|
||||
for _, tag := range params.Hostinfo.RequestTags {
|
||||
if s.polMan.NodeCanHaveTag(nodeToRegister.View(), tag) {
|
||||
approvedTags = append(approvedTags, tag)
|
||||
} else {
|
||||
rejectedTags = append(rejectedTags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Reject registration if any requested tags are unauthorized
|
||||
if len(rejectedTags) > 0 {
|
||||
return types.NodeView{}, fmt.Errorf("%w %v are invalid or not permitted", ErrRequestedTagsInvalidOrNotPermitted, rejectedTags)
|
||||
}
|
||||
|
||||
if len(approvedTags) > 0 {
|
||||
nodeToRegister.Tags = approvedTags
|
||||
slices.Sort(nodeToRegister.Tags)
|
||||
nodeToRegister.Tags = slices.Compact(nodeToRegister.Tags)
|
||||
log.Info().
|
||||
Str("node.name", nodeToRegister.Hostname).
|
||||
Strs("tags", nodeToRegister.Tags).
|
||||
Msg("approved advertise-tags during registration")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate before saving
|
||||
err := validateNodeOwnership(&nodeToRegister)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,8 +3,6 @@ package state
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -17,8 +15,9 @@ var (
|
||||
// ErrNodeHasNeitherUserNorTags is returned when a node has neither a user nor tags.
|
||||
ErrNodeHasNeitherUserNorTags = errors.New("node has neither user nor tags - must be owned by user or tagged")
|
||||
|
||||
// ErrInvalidOrUnauthorizedTags is returned when tags are invalid or unauthorized.
|
||||
ErrInvalidOrUnauthorizedTags = errors.New("invalid or unauthorized tags")
|
||||
// ErrRequestedTagsInvalidOrNotPermitted is returned when requested tags are invalid or not permitted.
|
||||
// This message format matches Tailscale SaaS: "requested tags [tag:xxx] are invalid or not permitted".
|
||||
ErrRequestedTagsInvalidOrNotPermitted = errors.New("requested tags")
|
||||
)
|
||||
|
||||
// validateNodeOwnership ensures proper node ownership model.
|
||||
@@ -44,44 +43,6 @@ func validateNodeOwnership(node *types.Node) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAndNormalizeTags validates tags against policy and normalizes them.
|
||||
// Returns validated and normalized tags, or an error if validation fails.
|
||||
func (s *State) validateAndNormalizeTags(node *types.Node, requestedTags []string) ([]string, error) {
|
||||
if len(requestedTags) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var (
|
||||
validTags []string
|
||||
invalidTags []string
|
||||
)
|
||||
|
||||
for _, tag := range requestedTags {
|
||||
// Validate format
|
||||
if !strings.HasPrefix(tag, "tag:") {
|
||||
invalidTags = append(invalidTags, tag)
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate against policy
|
||||
nodeView := node.View()
|
||||
if s.polMan.NodeCanHaveTag(nodeView, tag) {
|
||||
validTags = append(validTags, tag)
|
||||
} else {
|
||||
invalidTags = append(invalidTags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
if len(invalidTags) > 0 {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidOrUnauthorizedTags, invalidTags)
|
||||
}
|
||||
|
||||
// Normalize: sort and deduplicate
|
||||
slices.Sort(validTags)
|
||||
|
||||
return slices.Compact(validTags), nil
|
||||
}
|
||||
|
||||
// logTagOperation logs tag assignment operations for audit purposes.
|
||||
func logTagOperation(existingNode types.NodeView, newTags []string) {
|
||||
if existingNode.IsTagged() {
|
||||
|
||||
Reference in New Issue
Block a user