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

@ -1,6 +1,17 @@
package hscontrol
import "testing"
import (
"context"
"testing"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func Test_validateTag(t *testing.T) {
type args struct {
@ -40,3 +51,212 @@ func Test_validateTag(t *testing.T) {
})
}
}
// TestSetTags_Conversion tests the conversion of user-owned nodes to tagged nodes.
// The tags-as-identity model allows one-way conversion from user-owned to tagged.
// Tag authorization is checked via the policy manager - unauthorized tags are rejected.
func TestSetTags_Conversion(t *testing.T) {
t.Parallel()
app := createTestApp(t)
// Create test user and nodes
user := app.state.CreateUserForTest("test-user")
// Create a pre-auth key WITHOUT tags for user-owned node
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
machineKey1 := key.NewMachine()
nodeKey1 := key.NewNode()
// Register a user-owned node (via untagged PreAuthKey)
userOwnedReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey1.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "user-owned-node",
},
}
_, err = app.handleRegisterWithAuthKey(userOwnedReq, machineKey1.Public())
require.NoError(t, err)
// Get the created node
userOwnedNode, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
require.True(t, found)
// Create API server instance
apiServer := newHeadscaleV1APIServer(app)
tests := []struct {
name string
nodeID uint64
tags []string
wantErr bool
wantCode codes.Code
wantErrMessage string
}{
{
// Conversion is allowed, but tag authorization fails without tagOwners
name: "reject unauthorized tags on user-owned node",
nodeID: uint64(userOwnedNode.ID()),
tags: []string{"tag:server"},
wantErr: true,
wantCode: codes.InvalidArgument,
wantErrMessage: "invalid or unauthorized tags",
},
{
// Conversion is allowed, but tag authorization fails without tagOwners
name: "reject multiple unauthorized tags",
nodeID: uint64(userOwnedNode.ID()),
tags: []string{"tag:server", "tag:database"},
wantErr: true,
wantCode: codes.InvalidArgument,
wantErrMessage: "invalid or unauthorized tags",
},
{
name: "reject non-existent node",
nodeID: 99999,
tags: []string{"tag:server"},
wantErr: true,
wantCode: codes.NotFound,
wantErrMessage: "node not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: tt.nodeID,
Tags: tt.tags,
})
if tt.wantErr {
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "error should be a gRPC status error")
assert.Equal(t, tt.wantCode, st.Code())
assert.Contains(t, st.Message(), tt.wantErrMessage)
assert.Nil(t, resp.GetNode())
} else {
require.NoError(t, err)
assert.NotNil(t, resp)
assert.NotNil(t, resp.GetNode())
}
})
}
}
// TestSetTags_TaggedNode tests that SetTags correctly identifies tagged nodes
// and doesn't reject them with the "user-owned nodes" error.
// Note: This test doesn't validate ACL tag authorization - that's tested elsewhere.
func TestSetTags_TaggedNode(t *testing.T) {
t.Parallel()
app := createTestApp(t)
// Create test user and tagged pre-auth key
user := app.state.CreateUserForTest("test-user")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"tag:initial"})
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
// Register a tagged node (via tagged PreAuthKey)
taggedReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "tagged-node",
},
}
_, err = app.handleRegisterWithAuthKey(taggedReq, machineKey.Public())
require.NoError(t, err)
// Get the created node
taggedNode, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, taggedNode.IsTagged(), "Node should be tagged")
assert.True(t, taggedNode.UserID().Valid(), "Tagged node should have UserID for tracking")
// Create API server instance
apiServer := newHeadscaleV1APIServer(app)
// Test: SetTags should NOT reject tagged nodes with "user-owned" error
// (Even though they have UserID set, IsTagged() identifies them correctly)
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(taggedNode.ID()),
Tags: []string{"tag:initial"}, // Keep existing tag to avoid ACL validation issues
})
// The call should NOT fail with "cannot set tags on user-owned nodes"
if err != nil {
st, ok := status.FromError(err)
require.True(t, ok)
// If error is about unauthorized tags, that's fine - ACL validation is working
// If error is about user-owned nodes, that's the bug we're testing for
assert.NotContains(t, st.Message(), "user-owned nodes", "Should not reject tagged nodes as user-owned")
} else {
// Success is also fine
assert.NotNil(t, resp)
}
}
// TestSetTags_CannotRemoveAllTags tests that SetTags rejects attempts to remove
// all tags from a tagged node, enforcing Tailscale's requirement that tagged
// nodes must have at least one tag.
func TestSetTags_CannotRemoveAllTags(t *testing.T) {
t.Parallel()
app := createTestApp(t)
// Create test user and tagged pre-auth key
user := app.state.CreateUserForTest("test-user")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"tag:server"})
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
// Register a tagged node
taggedReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "tagged-node",
},
}
_, err = app.handleRegisterWithAuthKey(taggedReq, machineKey.Public())
require.NoError(t, err)
// Get the created node
taggedNode, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, taggedNode.IsTagged())
// Create API server instance
apiServer := newHeadscaleV1APIServer(app)
// Attempt to remove all tags (empty array)
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(taggedNode.ID()),
Tags: []string{}, // Empty - attempting to remove all tags
})
// Should fail with InvalidArgument error
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok, "error should be a gRPC status error")
assert.Equal(t, codes.InvalidArgument, st.Code())
assert.Contains(t, st.Message(), "cannot remove all tags")
assert.Nil(t, resp.GetNode())
}