make tags first class node owner (#2885)

This PR changes tags to be something that exists on nodes in addition to users, to being its own thing. It is part of moving our tags support towards the correct tailscale compatible implementation.

There are probably rough edges in this PR, but the intention is to get it in, and then start fixing bugs from 0.28.0 milestone (long standing tags issue) to discover what works and what doesnt.

Updates #2417
Closes #2619
This commit is contained in:
Kristoffer Dalby 2025-12-02 12:01:25 +01:00 committed by GitHub
parent 705b239677
commit eb788cd007
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3102 additions and 757 deletions

View file

@ -78,7 +78,7 @@ func (s *State) DebugOverview() string {
now := time.Now()
for _, node := range allNodes.All() {
if node.Valid() {
userName := node.User().Name
userName := node.User().Name()
userNodeCounts[userName]++
if node.IsOnline().Valid() && node.IsOnline().Get() {
@ -281,7 +281,7 @@ func (s *State) DebugOverviewJSON() DebugOverviewInfo {
for _, node := range allNodes.All() {
if node.Valid() {
userName := node.User().Name
userName := node.User().Name()
info.Users[userName]++
if node.IsOnline().Valid() && node.IsOnline().Get() {

View file

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/ptr"
)
func TestNetInfoFromMapRequest(t *testing.T) {
@ -148,8 +149,8 @@ func createTestNodeSimple(id types.NodeID) *types.Node {
node := &types.Node{
ID: id,
Hostname: "test-node",
UserID: uint(id),
User: user,
UserID: ptr.To(uint(id)),
User: &user,
MachineKey: machineKey.Public(),
NodeKey: nodeKey.Public(),
IPv4: &netip.Addr{},

View file

@ -408,7 +408,7 @@ func snapshotFromNodes(nodes map[types.NodeID]types.Node, peersFunc PeersFunc) S
// Build nodesByUser, nodesByNodeKey, and nodesByMachineKey maps
for _, n := range nodes {
nodeView := n.View()
userID := types.UserID(n.UserID)
userID := n.TypedUserID()
newSnap.nodesByUser[userID] = append(newSnap.nodesByUser[userID], nodeView)
newSnap.nodesByNodeKey[n.NodeKey] = nodeView
@ -515,7 +515,7 @@ func (s *NodeStore) DebugString() string {
if len(nodes) > 0 {
userName := "unknown"
if len(nodes) > 0 && nodes[0].Valid() {
userName = nodes[0].User().Name
userName = nodes[0].User().Name()
}
sb.WriteString(fmt.Sprintf(" - User %d (%s): %d nodes\n", userID, userName, len(nodes)))
}

View file

@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/types/key"
"tailscale.com/types/ptr"
)
func TestSnapshotFromNodes(t *testing.T) {
@ -173,8 +174,8 @@ func createTestNode(nodeID types.NodeID, userID uint, username, hostname string)
DiscoKey: discoKey.Public(),
Hostname: hostname,
GivenName: hostname,
UserID: userID,
User: types.User{
UserID: ptr.To(userID),
User: &types.User{
Name: username,
DisplayName: username,
},
@ -627,7 +628,7 @@ func TestNodeStoreOperations(t *testing.T) {
go func() {
resultNode3, ok3 = store.UpdateNode(1, func(n *types.Node) {
n.ForcedTags = []string{"tag1", "tag2"}
n.Tags = []string{"tag1", "tag2"}
})
close(done3)
}()
@ -648,24 +649,24 @@ func TestNodeStoreOperations(t *testing.T) {
// resultNode1 (from hostname update) should also have the givenname and tags changes
assert.Equal(t, "multi-update-hostname", resultNode1.Hostname())
assert.Equal(t, "multi-update-givenname", resultNode1.GivenName())
assert.Equal(t, []string{"tag1", "tag2"}, resultNode1.ForcedTags().AsSlice())
assert.Equal(t, []string{"tag1", "tag2"}, resultNode1.Tags().AsSlice())
// resultNode2 (from givenname update) should also have the hostname and tags changes
assert.Equal(t, "multi-update-hostname", resultNode2.Hostname())
assert.Equal(t, "multi-update-givenname", resultNode2.GivenName())
assert.Equal(t, []string{"tag1", "tag2"}, resultNode2.ForcedTags().AsSlice())
assert.Equal(t, []string{"tag1", "tag2"}, resultNode2.Tags().AsSlice())
// resultNode3 (from tags update) should also have the hostname and givenname changes
assert.Equal(t, "multi-update-hostname", resultNode3.Hostname())
assert.Equal(t, "multi-update-givenname", resultNode3.GivenName())
assert.Equal(t, []string{"tag1", "tag2"}, resultNode3.ForcedTags().AsSlice())
assert.Equal(t, []string{"tag1", "tag2"}, resultNode3.Tags().AsSlice())
// Verify the snapshot also has all changes
snapshot := store.data.Load()
finalNode := snapshot.nodesByID[1]
assert.Equal(t, "multi-update-hostname", finalNode.Hostname)
assert.Equal(t, "multi-update-givenname", finalNode.GivenName)
assert.Equal(t, []string{"tag1", "tag2"}, finalNode.ForcedTags)
assert.Equal(t, []string{"tag1", "tag2"}, finalNode.Tags)
},
},
},
@ -687,7 +688,7 @@ func TestNodeStoreOperations(t *testing.T) {
resultNode, ok := store.UpdateNode(1, func(n *types.Node) {
n.Hostname = "db-save-hostname"
n.GivenName = "db-save-given"
n.ForcedTags = []string{"db-tag1", "db-tag2"}
n.Tags = []string{"db-tag1", "db-tag2"}
})
assert.True(t, ok, "UpdateNode should succeed")
@ -696,21 +697,21 @@ func TestNodeStoreOperations(t *testing.T) {
// Verify the returned node has all expected values
assert.Equal(t, "db-save-hostname", resultNode.Hostname())
assert.Equal(t, "db-save-given", resultNode.GivenName())
assert.Equal(t, []string{"db-tag1", "db-tag2"}, resultNode.ForcedTags().AsSlice())
assert.Equal(t, []string{"db-tag1", "db-tag2"}, resultNode.Tags().AsSlice())
// Convert to struct as would be done for database save
nodePtr := resultNode.AsStruct()
assert.NotNil(t, nodePtr)
assert.Equal(t, "db-save-hostname", nodePtr.Hostname)
assert.Equal(t, "db-save-given", nodePtr.GivenName)
assert.Equal(t, []string{"db-tag1", "db-tag2"}, nodePtr.ForcedTags)
assert.Equal(t, []string{"db-tag1", "db-tag2"}, nodePtr.Tags)
// Verify the snapshot also reflects the same state
snapshot := store.data.Load()
storedNode := snapshot.nodesByID[1]
assert.Equal(t, "db-save-hostname", storedNode.Hostname)
assert.Equal(t, "db-save-given", storedNode.GivenName)
assert.Equal(t, []string{"db-tag1", "db-tag2"}, storedNode.ForcedTags)
assert.Equal(t, []string{"db-tag1", "db-tag2"}, storedNode.Tags)
},
},
{
@ -742,7 +743,7 @@ func TestNodeStoreOperations(t *testing.T) {
go func() {
result3, ok3 = store.UpdateNode(1, func(n *types.Node) {
n.ForcedTags = []string{"concurrent-tag"}
n.Tags = []string{"concurrent-tag"}
})
close(done3)
}()
@ -767,22 +768,22 @@ func TestNodeStoreOperations(t *testing.T) {
// All should have the complete final state
assert.Equal(t, "concurrent-db-hostname", nodePtr1.Hostname)
assert.Equal(t, "concurrent-db-given", nodePtr1.GivenName)
assert.Equal(t, []string{"concurrent-tag"}, nodePtr1.ForcedTags)
assert.Equal(t, []string{"concurrent-tag"}, nodePtr1.Tags)
assert.Equal(t, "concurrent-db-hostname", nodePtr2.Hostname)
assert.Equal(t, "concurrent-db-given", nodePtr2.GivenName)
assert.Equal(t, []string{"concurrent-tag"}, nodePtr2.ForcedTags)
assert.Equal(t, []string{"concurrent-tag"}, nodePtr2.Tags)
assert.Equal(t, "concurrent-db-hostname", nodePtr3.Hostname)
assert.Equal(t, "concurrent-db-given", nodePtr3.GivenName)
assert.Equal(t, []string{"concurrent-tag"}, nodePtr3.ForcedTags)
assert.Equal(t, []string{"concurrent-tag"}, nodePtr3.Tags)
// Verify consistency with stored state
snapshot := store.data.Load()
storedNode := snapshot.nodesByID[1]
assert.Equal(t, nodePtr1.Hostname, storedNode.Hostname)
assert.Equal(t, nodePtr1.GivenName, storedNode.GivenName)
assert.Equal(t, nodePtr1.ForcedTags, storedNode.ForcedTags)
assert.Equal(t, nodePtr1.Tags, storedNode.Tags)
},
},
{
@ -855,8 +856,8 @@ func createConcurrentTestNode(id types.NodeID, hostname string) types.Node {
Hostname: hostname,
MachineKey: machineKey.Public(),
NodeKey: nodeKey.Public(),
UserID: 1,
User: types.User{
UserID: ptr.To(uint(1)),
User: &types.User{
Name: "concurrent-test-user",
},
}

View file

@ -53,6 +53,9 @@ const (
// ErrUnsupportedPolicyMode is returned for invalid policy modes. Valid modes are "file" and "db".
var ErrUnsupportedPolicyMode = errors.New("unsupported policy mode")
// ErrNodeNotFound is returned when a node cannot be found by its ID.
var ErrNodeNotFound = errors.New("node not found")
// State manages Headscale's core state, coordinating between database, policy management,
// IP allocation, and DERP routing. All methods are thread-safe.
type State struct {
@ -651,13 +654,36 @@ func (s *State) SetNodeExpiry(nodeID types.NodeID, expiry time.Time) (types.Node
return s.persistNodeToDB(n)
}
// SetNodeTags assigns tags to a node for use in access control policies.
// SetNodeTags assigns tags to a node, making it a "tagged node".
// Once a node is tagged, it cannot be un-tagged (only tags can be changed).
// The UserID is preserved as "created by" information.
func (s *State) SetNodeTags(nodeID types.NodeID, tags []string) (types.NodeView, change.ChangeSet, error) {
// CANNOT REMOVE ALL TAGS
if len(tags) == 0 {
return types.NodeView{}, change.EmptySet, types.ErrCannotRemoveAllTags
}
// Get node for validation
existingNode, exists := s.nodeStore.GetNode(nodeID)
if !exists {
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
}
// Log the operation
logTagOperation(existingNode, validatedTags)
// Update NodeStore before database to ensure consistency. The NodeStore update is
// blocking and will be the source of truth for the batcher. The database update must
// make the exact same change.
n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
node.ForcedTags = tags
node.Tags = validatedTags
// UserID is preserved as "created by" - do NOT set to nil
})
if !ok {
@ -927,7 +953,8 @@ func (s *State) DestroyAPIKey(key types.APIKey) error {
}
// CreatePreAuthKey generates a new pre-authentication key for a user.
func (s *State) CreatePreAuthKey(userID types.UserID, reusable bool, ephemeral bool, expiration *time.Time, aclTags []string) (*types.PreAuthKeyNew, error) {
// The userID parameter is now optional (can be nil) for system-created tagged keys.
func (s *State) CreatePreAuthKey(userID *types.UserID, reusable bool, ephemeral bool, expiration *time.Time, aclTags []string) (*types.PreAuthKeyNew, error) {
return s.db.CreatePreAuthKey(userID, reusable, ephemeral, expiration, aclTags)
}
@ -1063,8 +1090,6 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
// Prepare the node for registration
nodeToRegister := types.Node{
Hostname: params.Hostname,
UserID: params.User.ID,
User: params.User,
MachineKey: params.MachineKey,
NodeKey: params.NodeKey,
DiscoKey: params.DiscoKey,
@ -1075,11 +1100,38 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
Expiry: params.Expiry,
}
// Pre-auth key specific fields
// Assign ownership based on PreAuthKey
if params.PreAuthKey != nil {
nodeToRegister.ForcedTags = params.PreAuthKey.Proto().GetAclTags()
if params.PreAuthKey.IsTagged() {
// TAGGED NODE
// Tags from PreAuthKey are assigned ONLY during initial authentication
nodeToRegister.Tags = params.PreAuthKey.Proto().GetAclTags()
// Set UserID to track "created by" (who created the PreAuthKey)
if params.PreAuthKey.UserID != nil {
nodeToRegister.UserID = params.PreAuthKey.UserID
nodeToRegister.User = params.PreAuthKey.User
}
// If PreAuthKey.UserID is nil, the node is "orphaned" (system-created)
} else {
// USER-OWNED NODE
nodeToRegister.UserID = &params.PreAuthKey.User.ID
nodeToRegister.User = params.PreAuthKey.User
nodeToRegister.Tags = nil
}
nodeToRegister.AuthKey = params.PreAuthKey
nodeToRegister.AuthKeyID = &params.PreAuthKey.ID
} else {
// Non-PreAuthKey registration (OIDC, CLI) - always user-owned
nodeToRegister.UserID = &params.User.ID
nodeToRegister.User = &params.User
nodeToRegister.Tags = nil
}
// Validate before saving
err := validateNodeOwnership(&nodeToRegister)
if err != nil {
return types.NodeView{}, err
}
// Allocate new IPs
@ -1156,7 +1208,7 @@ func (s *State) HandleNodeFromAuthPath(
logHostinfoValidation(
regEntry.Node.MachineKey.ShortString(),
regEntry.Node.NodeKey.String(),
user.Username(),
user.Name,
hostname,
regEntry.Node.Hostinfo,
)
@ -1171,7 +1223,7 @@ func (s *State) HandleNodeFromAuthPath(
log.Debug().
Caller().
Str("registration_id", registrationID.String()).
Str("user.name", user.Username()).
Str("user.name", user.Name).
Str("registrationMethod", registrationMethod).
Str("node.name", existingNodeSameUser.Hostname()).
Uint64("node.id", existingNodeSameUser.ID().Uint64()).
@ -1233,7 +1285,7 @@ func (s *State) HandleNodeFromAuthPath(
// Check if node exists with this machine key for a different user (for netinfo preservation)
existingNodeAnyUser, existsAnyUser := s.nodeStore.GetNodeByMachineKeyAnyUser(regEntry.Node.MachineKey)
if existsAnyUser && existingNodeAnyUser.Valid() && existingNodeAnyUser.UserID() != user.ID {
if existsAnyUser && existingNodeAnyUser.Valid() && existingNodeAnyUser.UserID().Get() != user.ID {
// Node exists but belongs to a different user
// Create a NEW node for the new user (do not transfer)
// This allows the same machine to have separate node identities per user
@ -1243,8 +1295,8 @@ func (s *State) HandleNodeFromAuthPath(
Str("existing.node.name", existingNodeAnyUser.Hostname()).
Uint64("existing.node.id", existingNodeAnyUser.ID().Uint64()).
Str("machine.key", regEntry.Node.MachineKey.ShortString()).
Str("old.user", oldUser.Username()).
Str("new.user", user.Username()).
Str("old.user", oldUser.Name()).
Str("new.user", user.Name).
Str("method", registrationMethod).
Msg("Creating new node for different user (same machine key exists for another user)")
}
@ -1253,7 +1305,7 @@ func (s *State) HandleNodeFromAuthPath(
log.Debug().
Caller().
Str("registration_id", registrationID.String()).
Str("user.name", user.Username()).
Str("user.name", user.Name).
Str("registrationMethod", registrationMethod).
Str("expiresAt", fmt.Sprintf("%v", expiry)).
Msg("Registering new node from auth callback")
@ -1416,8 +1468,11 @@ func (s *State) HandleNodeFromPreAuthKey(
node.RegisterMethod = util.RegisterMethodAuthKey
// TODO(kradalby): This might need a rework as part of #2417
node.ForcedTags = pak.Proto().GetAclTags()
// CRITICAL: Tags from PreAuthKey are ONLY applied during initial authentication
// On re-registration, we MUST NOT change tags or node ownership
// The node keeps whatever tags/user ownership it already has
//
// Only update AuthKey reference
node.AuthKey = pak
node.AuthKeyID = &pak.ID
node.IsOnline = ptr.To(false)
@ -1467,7 +1522,7 @@ func (s *State) HandleNodeFromPreAuthKey(
// Check if node exists with this machine key for a different user
existingNodeAnyUser, existsAnyUser := s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey)
if existsAnyUser && existingNodeAnyUser.Valid() && existingNodeAnyUser.UserID() != pak.User.ID {
if existsAnyUser && existingNodeAnyUser.Valid() && existingNodeAnyUser.UserID().Get() != pak.User.ID {
// Node exists but belongs to a different user
// Create a NEW node for the new user (do not transfer)
// This allows the same machine to have separate node identities per user
@ -1477,7 +1532,7 @@ func (s *State) HandleNodeFromPreAuthKey(
Str("existing.node.name", existingNodeAnyUser.Hostname()).
Uint64("existing.node.id", existingNodeAnyUser.ID().Uint64()).
Str("machine.key", machineKey.ShortString()).
Str("old.user", oldUser.Username()).
Str("old.user", oldUser.Name()).
Str("new.user", pak.User.Username()).
Msg("Creating new node for different user (same machine key exists for another user)")
}
@ -1488,7 +1543,7 @@ func (s *State) HandleNodeFromPreAuthKey(
// Create and save new node
var err error
finalNode, err = s.createAndSaveNewNode(newNodeParams{
User: pak.User,
User: *pak.User,
MachineKey: machineKey,
NodeKey: regReq.NodeKey,
DiscoKey: key.DiscoPublic{}, // DiscoKey not available in RegisterRequest

107
hscontrol/state/tags.go Normal file
View file

@ -0,0 +1,107 @@
package state
import (
"errors"
"fmt"
"slices"
"strings"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
)
var (
// ErrNodeMarkedTaggedButHasNoTags is returned when a node is marked as tagged but has no tags.
ErrNodeMarkedTaggedButHasNoTags = errors.New("node marked as tagged but has no tags")
// 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")
)
// validateNodeOwnership ensures proper node ownership model.
// A node must be EITHER user-owned OR tagged (mutually exclusive by behavior).
// Tagged nodes CAN have a UserID for "created by" tracking, but the tag is the owner.
func validateNodeOwnership(node *types.Node) error {
isTagged := node.IsTagged()
// Tagged nodes: Must have tags, UserID is optional (just "created by")
if isTagged {
if len(node.Tags) == 0 {
return fmt.Errorf("%w: %q", ErrNodeMarkedTaggedButHasNoTags, node.Hostname)
}
// UserID can be set (created by) or nil (orphaned), both valid for tagged nodes
return nil
}
// User-owned nodes: Must have UserID, must NOT have tags
if node.UserID == nil {
return fmt.Errorf("%w: %q", ErrNodeHasNeitherUserNorTags, node.Hostname)
}
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() {
log.Info().
Uint64("node.id", existingNode.ID().Uint64()).
Str("node.name", existingNode.Hostname()).
Strs("old.tags", existingNode.Tags().AsSlice()).
Strs("new.tags", newTags).
Msg("Updating tags on already-tagged node")
} else {
var userID uint
if existingNode.UserID().Valid() {
userID = existingNode.UserID().Get()
}
log.Info().
Uint64("node.id", existingNode.ID().Uint64()).
Str("node.name", existingNode.Hostname()).
Uint("created.by.user", userID).
Strs("new.tags", newTags).
Msg("Converting user-owned node to tagged node (irreversible)")
}
}