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:
parent
705b239677
commit
eb788cd007
49 changed files with 3102 additions and 757 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ¶ms.PreAuthKey.User.ID
|
||||
nodeToRegister.User = params.PreAuthKey.User
|
||||
nodeToRegister.Tags = nil
|
||||
}
|
||||
nodeToRegister.AuthKey = params.PreAuthKey
|
||||
nodeToRegister.AuthKeyID = ¶ms.PreAuthKey.ID
|
||||
} else {
|
||||
// Non-PreAuthKey registration (OIDC, CLI) - always user-owned
|
||||
nodeToRegister.UserID = ¶ms.User.ID
|
||||
nodeToRegister.User = ¶ms.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
107
hscontrol/state/tags.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue