1
0
mirror of https://github.com/juanfont/headscale.git synced 2025-12-09 20:04:54 +01:00
juanfont.headscale/hscontrol/grpcv1_test.go
Kristoffer Dalby 22ee2bfc9c
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.
2025-12-08 18:51:07 +01:00

263 lines
7.6 KiB
Go

package hscontrol
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 {
tag string
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "valid tag",
args: args{tag: "tag:test"},
wantErr: false,
},
{
name: "tag without tag prefix",
args: args{tag: "test"},
wantErr: true,
},
{
name: "uppercase tag",
args: args{tag: "tag:tEST"},
wantErr: true,
},
{
name: "tag that contains space",
args: args{tag: "tag:this is a spaced tag"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateTag(tt.args.tag); (err != nil) != tt.wantErr {
t.Errorf("validateTag() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// 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: "requested 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: "requested 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())
}