hscontrol: enforce that tagged nodes never have user_id
Tagged nodes are owned by their tags, not a user. Enforce this invariant at every write path: - createAndSaveNewNode: do not set UserID for tagged PreAuthKey registration; clear UserID when advertise-tags are applied during OIDC/CLI registration - SetNodeTags: clear UserID/User when tags are assigned - processReauthTags: clear UserID/User when tags are applied during re-authentication - validateNodeOwnership: reject tagged nodes with non-nil UserID - NodeStore: skip nodesByUser indexing for tagged nodes since they have no owning user - HandleNodeFromPreAuthKey: add fallback lookup for tagged PAK re-registration (tagged nodes indexed under UserID(0)); guard against nil User deref for tagged nodes in different-user check Since tagged nodes now have user_id = NULL, ListNodesByUser will not return them and DestroyUser naturally allows deleting users whose nodes have all been tagged. The ON DELETE CASCADE FK cannot reach tagged nodes through a NULL foreign key. Also tone down shouty comments throughout state.go. Fixes #3077
This commit is contained in:
parent
52d454d0c8
commit
75e56df9e4
@ -48,7 +48,8 @@ func (hsdb *HSDatabase) DestroyUser(uid types.UserID) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DestroyUser destroys a User. Returns error if the User does
|
// DestroyUser destroys a User. Returns error if the User does
|
||||||
// not exist or if there are nodes associated with it.
|
// not exist or if there are user-owned nodes associated with it.
|
||||||
|
// Tagged nodes have user_id = NULL so they do not block deletion.
|
||||||
func DestroyUser(tx *gorm.DB, uid types.UserID) error {
|
func DestroyUser(tx *gorm.DB, uid types.UserID) error {
|
||||||
user, err := GetUserByID(tx, uid)
|
user, err := GetUserByID(tx, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -425,7 +425,12 @@ func snapshotFromNodes(nodes map[types.NodeID]types.Node, peersFunc PeersFunc) S
|
|||||||
nodeView := n.View()
|
nodeView := n.View()
|
||||||
userID := n.TypedUserID()
|
userID := n.TypedUserID()
|
||||||
|
|
||||||
|
// Tagged nodes are owned by their tags, not a user,
|
||||||
|
// so they are not indexed by user.
|
||||||
|
if !n.IsTagged() {
|
||||||
newSnap.nodesByUser[userID] = append(newSnap.nodesByUser[userID], nodeView)
|
newSnap.nodesByUser[userID] = append(newSnap.nodesByUser[userID], nodeView)
|
||||||
|
}
|
||||||
|
|
||||||
newSnap.nodesByNodeKey[n.NodeKey] = nodeView
|
newSnap.nodesByNodeKey[n.NodeKey] = nodeView
|
||||||
|
|
||||||
// Build machine key index
|
// Build machine key index
|
||||||
@ -531,25 +536,14 @@ func (s *NodeStore) DebugString() string {
|
|||||||
for userID, nodes := range snapshot.nodesByUser {
|
for userID, nodes := range snapshot.nodesByUser {
|
||||||
if len(nodes) > 0 {
|
if len(nodes) > 0 {
|
||||||
userName := "unknown"
|
userName := "unknown"
|
||||||
taggedCount := 0
|
|
||||||
|
|
||||||
if len(nodes) > 0 && nodes[0].Valid() {
|
if nodes[0].Valid() && nodes[0].User().Valid() {
|
||||||
userName = nodes[0].User().Name()
|
userName = nodes[0].User().Name()
|
||||||
// Count tagged nodes (which have UserID set but are owned by "tagged-devices")
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.IsTagged() {
|
|
||||||
taggedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if taggedCount > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - User %d (%s): %d nodes (%d tagged)\n", userID, userName, len(nodes), taggedCount))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - User %d (%s): %d nodes\n", userID, userName, len(nodes)))
|
sb.WriteString(fmt.Sprintf(" - User %d (%s): %d nodes\n", userID, userName, len(nodes)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
|||||||
@ -472,10 +472,9 @@ func (s *State) DeleteNode(node types.NodeView) (change.Change, error) {
|
|||||||
|
|
||||||
// Connect marks a node as connected and updates its primary routes in the state.
|
// Connect marks a node as connected and updates its primary routes in the state.
|
||||||
func (s *State) Connect(id types.NodeID) []change.Change {
|
func (s *State) Connect(id types.NodeID) []change.Change {
|
||||||
// CRITICAL FIX: Update the online status in NodeStore BEFORE creating change notification
|
// Update online status in NodeStore before creating change notification
|
||||||
// This ensures that when the NodeCameOnline change is distributed and processed by other nodes,
|
// so the NodeStore already reflects the correct state when other nodes
|
||||||
// the NodeStore already reflects the correct online status for full map generation.
|
// process the NodeCameOnline change for full map generation.
|
||||||
// now := time.Now()
|
|
||||||
node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
|
node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
|
||||||
n.IsOnline = new(true)
|
n.IsOnline = new(true)
|
||||||
// n.LastSeen = ptr.To(now)
|
// n.LastSeen = ptr.To(now)
|
||||||
@ -488,9 +487,8 @@ func (s *State) Connect(id types.NodeID) []change.Change {
|
|||||||
|
|
||||||
log.Info().EmbedObject(node).Msg("node connected")
|
log.Info().EmbedObject(node).Msg("node connected")
|
||||||
|
|
||||||
// Use the node's current routes for primary route update
|
// Use the node's current routes for primary route update.
|
||||||
// AllApprovedRoutes() returns only the intersection of announced AND approved routes
|
// AllApprovedRoutes() returns only the intersection of announced and approved routes.
|
||||||
// We MUST use AllApprovedRoutes() to maintain the security model
|
|
||||||
routeChange := s.primaryRoutes.SetRoutes(id, node.AllApprovedRoutes()...)
|
routeChange := s.primaryRoutes.SetRoutes(id, node.AllApprovedRoutes()...)
|
||||||
|
|
||||||
if routeChange {
|
if routeChange {
|
||||||
@ -674,9 +672,8 @@ func (s *State) SetNodeExpiry(nodeID types.NodeID, expiry *time.Time) (types.Nod
|
|||||||
|
|
||||||
// SetNodeTags assigns tags to a node, making it a "tagged node".
|
// 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).
|
// Once a node is tagged, it cannot be un-tagged (only tags can be changed).
|
||||||
// The UserID is preserved as "created by" information.
|
// Setting tags clears UserID since tagged nodes are owned by their tags.
|
||||||
func (s *State) SetNodeTags(nodeID types.NodeID, tags []string) (types.NodeView, change.Change, error) {
|
func (s *State) SetNodeTags(nodeID types.NodeID, tags []string) (types.NodeView, change.Change, error) {
|
||||||
// CANNOT REMOVE ALL TAGS
|
|
||||||
if len(tags) == 0 {
|
if len(tags) == 0 {
|
||||||
return types.NodeView{}, change.Change{}, types.ErrCannotRemoveAllTags
|
return types.NodeView{}, change.Change{}, types.ErrCannotRemoveAllTags
|
||||||
}
|
}
|
||||||
@ -716,7 +713,9 @@ func (s *State) SetNodeTags(nodeID types.NodeID, tags []string) (types.NodeView,
|
|||||||
// make the exact same change.
|
// make the exact same change.
|
||||||
n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
|
n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
|
||||||
node.Tags = validatedTags
|
node.Tags = validatedTags
|
||||||
// UserID is preserved as "created by" - do NOT set to nil
|
// Tagged nodes are owned by their tags, not a user.
|
||||||
|
node.UserID = nil
|
||||||
|
node.User = nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -1307,17 +1306,10 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
|
|||||||
// Assign ownership based on PreAuthKey
|
// Assign ownership based on PreAuthKey
|
||||||
if params.PreAuthKey != nil {
|
if params.PreAuthKey != nil {
|
||||||
if params.PreAuthKey.IsTagged() {
|
if params.PreAuthKey.IsTagged() {
|
||||||
// TAGGED NODE
|
// Tagged nodes are owned by their tags, not a user.
|
||||||
// Tags from PreAuthKey are assigned ONLY during initial authentication
|
// UserID is intentionally left nil.
|
||||||
nodeToRegister.Tags = params.PreAuthKey.Proto().GetAclTags()
|
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)
|
|
||||||
|
|
||||||
// Tagged nodes have key expiry disabled.
|
// Tagged nodes have key expiry disabled.
|
||||||
nodeToRegister.Expiry = nil
|
nodeToRegister.Expiry = nil
|
||||||
} else {
|
} else {
|
||||||
@ -1358,6 +1350,11 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
|
|||||||
slices.Sort(nodeToRegister.Tags)
|
slices.Sort(nodeToRegister.Tags)
|
||||||
nodeToRegister.Tags = slices.Compact(nodeToRegister.Tags)
|
nodeToRegister.Tags = slices.Compact(nodeToRegister.Tags)
|
||||||
|
|
||||||
|
// Node is now tagged, so clear user ownership.
|
||||||
|
// Tagged nodes are owned by their tags, not a user.
|
||||||
|
nodeToRegister.UserID = nil
|
||||||
|
nodeToRegister.User = nil
|
||||||
|
|
||||||
// Tagged nodes have key expiry disabled.
|
// Tagged nodes have key expiry disabled.
|
||||||
nodeToRegister.Expiry = nil
|
nodeToRegister.Expiry = nil
|
||||||
|
|
||||||
@ -1504,7 +1501,10 @@ func (s *State) processReauthTags(
|
|||||||
wasTagged := node.IsTagged()
|
wasTagged := node.IsTagged()
|
||||||
node.Tags = approvedTags
|
node.Tags = approvedTags
|
||||||
|
|
||||||
// Note: UserID is preserved as "created by" tracking, consistent with SetNodeTags
|
// Tagged nodes are owned by their tags, not a user.
|
||||||
|
node.UserID = nil
|
||||||
|
node.User = nil
|
||||||
|
|
||||||
if !wasTagged {
|
if !wasTagged {
|
||||||
log.Info().
|
log.Info().
|
||||||
Uint64(zf.NodeID, uint64(node.ID)).
|
Uint64(zf.NodeID, uint64(node.ID)).
|
||||||
@ -1729,9 +1729,15 @@ func (s *State) HandleNodeFromPreAuthKey(
|
|||||||
existingNodeSameUser, existsSameUser = s.nodeStore.GetNodeByMachineKey(machineKey, types.UserID(pak.User.ID))
|
existingNodeSameUser, existsSameUser = s.nodeStore.GetNodeByMachineKey(machineKey, types.UserID(pak.User.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tagged nodes have nil UserID, so they are indexed under UserID(0)
|
||||||
|
// in nodesByMachineKey. Check there too for tagged PAK re-registration.
|
||||||
|
if !existsSameUser && pak.IsTagged() {
|
||||||
|
existingNodeSameUser, existsSameUser = s.nodeStore.GetNodeByMachineKey(machineKey, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// For existing nodes, skip validation if:
|
// For existing nodes, skip validation if:
|
||||||
// 1. MachineKey matches (cryptographic proof of machine identity)
|
// 1. MachineKey matches (cryptographic proof of machine identity)
|
||||||
// 2. User matches (from the PAK being used)
|
// 2. User/tag ownership matches (from the PAK being used)
|
||||||
// 3. Not a NodeKey rotation (rotation requires fresh validation)
|
// 3. Not a NodeKey rotation (rotation requires fresh validation)
|
||||||
//
|
//
|
||||||
// Security: MachineKey is the cryptographic identity. If someone has the MachineKey,
|
// Security: MachineKey is the cryptographic identity. If someone has the MachineKey,
|
||||||
@ -1739,9 +1745,7 @@ func (s *State) HandleNodeFromPreAuthKey(
|
|||||||
// We don't check which specific PAK was used originally because:
|
// We don't check which specific PAK was used originally because:
|
||||||
// - Container restarts may use different PAKs (e.g., env var changed)
|
// - Container restarts may use different PAKs (e.g., env var changed)
|
||||||
// - Original PAK may be deleted
|
// - Original PAK may be deleted
|
||||||
// - MachineKey + User is sufficient to prove this is the same node
|
// - MachineKey + ownership is sufficient to prove this is the same node
|
||||||
//
|
|
||||||
// Note: For tags-only keys, existsSameUser is always false, so we always validate.
|
|
||||||
isExistingNodeReregistering := existsSameUser && existingNodeSameUser.Valid()
|
isExistingNodeReregistering := existsSameUser && existingNodeSameUser.Valid()
|
||||||
|
|
||||||
// Check if this is a NodeKey rotation (different NodeKey)
|
// Check if this is a NodeKey rotation (different NodeKey)
|
||||||
@ -1828,11 +1832,9 @@ func (s *State) HandleNodeFromPreAuthKey(
|
|||||||
|
|
||||||
node.RegisterMethod = util.RegisterMethodAuthKey
|
node.RegisterMethod = util.RegisterMethodAuthKey
|
||||||
|
|
||||||
// CRITICAL: Tags from PreAuthKey are ONLY applied during initial authentication
|
// Tags from PreAuthKey are only applied during initial registration.
|
||||||
// On re-registration, we MUST NOT change tags or node ownership
|
// On re-registration the node keeps its existing tags and ownership.
|
||||||
// The node keeps whatever tags/user ownership it already has
|
// Only update AuthKey reference.
|
||||||
//
|
|
||||||
// Only update AuthKey reference
|
|
||||||
node.AuthKey = pak
|
node.AuthKey = pak
|
||||||
node.AuthKeyID = &pak.ID
|
node.AuthKeyID = &pak.ID
|
||||||
node.IsOnline = new(false)
|
node.IsOnline = new(false)
|
||||||
@ -1885,19 +1887,27 @@ func (s *State) HandleNodeFromPreAuthKey(
|
|||||||
// Check if node exists with this machine key for a different user
|
// Check if node exists with this machine key for a different user
|
||||||
existingNodeAnyUser, existsAnyUser := s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey)
|
existingNodeAnyUser, existsAnyUser := s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey)
|
||||||
|
|
||||||
// For user-owned keys, check if node exists for a different user
|
// For user-owned keys, check if node exists for a different user.
|
||||||
// For tags-only keys (pak.User == nil), this check is skipped
|
// Tags-only keys (pak.User == nil) skip this check.
|
||||||
if pak.User != nil && existsAnyUser && existingNodeAnyUser.Valid() && existingNodeAnyUser.UserID().Get() != pak.User.ID {
|
// Tagged nodes are also skipped since they have no owning user.
|
||||||
// Node exists but belongs to a different user
|
existingIsUserOwned := existsAnyUser &&
|
||||||
// Create a NEW node for the new user (do not transfer)
|
existingNodeAnyUser.Valid() &&
|
||||||
// This allows the same machine to have separate node identities per user
|
!existingNodeAnyUser.IsTagged()
|
||||||
oldUser := existingNodeAnyUser.User()
|
belongsToDifferentUser := pak.User != nil &&
|
||||||
|
existingIsUserOwned &&
|
||||||
|
existingNodeAnyUser.UserID().Get() != pak.User.ID
|
||||||
|
|
||||||
|
if belongsToDifferentUser {
|
||||||
|
// Node exists but belongs to a different user.
|
||||||
|
// Create a new node for the new user (do not transfer).
|
||||||
|
oldUserName := existingNodeAnyUser.User().Name()
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Caller().
|
Caller().
|
||||||
Str(zf.ExistingNodeName, existingNodeAnyUser.Hostname()).
|
Str(zf.ExistingNodeName, existingNodeAnyUser.Hostname()).
|
||||||
Uint64(zf.ExistingNodeID, existingNodeAnyUser.ID().Uint64()).
|
Uint64(zf.ExistingNodeID, existingNodeAnyUser.ID().Uint64()).
|
||||||
Str(zf.MachineKey, machineKey.ShortString()).
|
Str(zf.MachineKey, machineKey.ShortString()).
|
||||||
Str(zf.OldUser, oldUser.Name()).
|
Str(zf.OldUser, oldUserName).
|
||||||
Str(zf.NewUser, pakUsername()).
|
Str(zf.NewUser, pakUsername()).
|
||||||
Msg("Creating new node for different user (same machine key exists for another user)")
|
Msg("Creating new node for different user (same machine key exists for another user)")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,22 +20,26 @@ var (
|
|||||||
ErrRequestedTagsInvalidOrNotPermitted = errors.New("requested tags")
|
ErrRequestedTagsInvalidOrNotPermitted = errors.New("requested tags")
|
||||||
)
|
)
|
||||||
|
|
||||||
// validateNodeOwnership ensures proper node ownership model.
|
// ErrTaggedNodeHasUser is returned when a tagged node has a UserID set.
|
||||||
// A node must be EITHER user-owned OR tagged (mutually exclusive by behavior).
|
var ErrTaggedNodeHasUser = errors.New("tagged node must not have user_id set")
|
||||||
// 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")
|
// validateNodeOwnership ensures proper node ownership model.
|
||||||
if isTagged {
|
// A node must be either user-owned or tagged, and these are mutually exclusive:
|
||||||
|
// tagged nodes must not have a UserID, and user-owned nodes must not have tags.
|
||||||
|
func validateNodeOwnership(node *types.Node) error {
|
||||||
|
if node.IsTagged() {
|
||||||
if len(node.Tags) == 0 {
|
if len(node.Tags) == 0 {
|
||||||
return fmt.Errorf("%w: %q", ErrNodeMarkedTaggedButHasNoTags, node.Hostname)
|
return fmt.Errorf("%w: %q", ErrNodeMarkedTaggedButHasNoTags, node.Hostname)
|
||||||
}
|
}
|
||||||
// UserID can be set (created by) or nil (orphaned), both valid for tagged nodes
|
|
||||||
|
if node.UserID != nil {
|
||||||
|
return fmt.Errorf("%w: %q", ErrTaggedNodeHasUser, node.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// User-owned nodes: Must have UserID, must NOT have tags
|
// User-owned nodes must have a UserID.
|
||||||
if node.UserID == nil {
|
if node.UserID == nil {
|
||||||
return fmt.Errorf("%w: %q", ErrNodeHasNeitherUserNorTags, node.Hostname)
|
return fmt.Errorf("%w: %q", ErrNodeHasNeitherUserNorTags, node.Hostname)
|
||||||
}
|
}
|
||||||
@ -59,7 +63,7 @@ func logTagOperation(existingNode types.NodeView, newTags []string) {
|
|||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
EmbedObject(existingNode).
|
EmbedObject(existingNode).
|
||||||
Uint("created.by.user", userID).
|
Uint("previous.user", userID).
|
||||||
Strs("new.tags", newTags).
|
Strs("new.tags", newTags).
|
||||||
Msg("Converting user-owned node to tagged node")
|
Msg("Converting user-owned node to tagged node")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,10 +105,8 @@ type Node struct {
|
|||||||
// parts of headscale.
|
// parts of headscale.
|
||||||
GivenName string `gorm:"type:varchar(63);unique_index"`
|
GivenName string `gorm:"type:varchar(63);unique_index"`
|
||||||
|
|
||||||
// UserID is set for ALL nodes (tagged and user-owned) to track "created by".
|
// UserID identifies the owning user for user-owned nodes.
|
||||||
// For tagged nodes, this is informational only - the tag is the owner.
|
// Nil for tagged nodes, which are owned by their tags.
|
||||||
// For user-owned nodes, this identifies the owner.
|
|
||||||
// Only nil for orphaned nodes (should not happen in normal operation).
|
|
||||||
UserID *uint
|
UserID *uint
|
||||||
User *User `gorm:"constraint:OnDelete:CASCADE;"`
|
User *User `gorm:"constraint:OnDelete:CASCADE;"`
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user