mirror of
https://github.com/juanfont/headscale.git
synced 2026-03-20 00:24:20 +01:00
Test the tagged-node-survives-user-deletion scenario at two layers: DB layer (users_test.go): - success_user_only_has_tagged_nodes: tagged nodes with nil user_id do not block user deletion and survive it - error_user_has_tagged_and_owned_nodes: user-owned nodes still block deletion even when tagged nodes coexist App layer (grpcv1_test.go): - TestDeleteUser_TaggedNodeSurvives: full registration flow with tagged PreAuthKey verifies nil UserID after registration, absence from nodesByUser index, user deletion succeeds, and tagged node remains in global node list Also update auth_tags_test.go assertions to expect nil UserID on tagged nodes, consistent with the new invariant. Updates #3077
546 lines
16 KiB
Go
546 lines
16 KiB
Go
package hscontrol
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"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) {
|
|
err := validateTag(tt.args.tag)
|
|
if (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.False(t, taggedNode.UserID().Valid(), "Tagged node should not have UserID")
|
|
|
|
// Create API server instance
|
|
apiServer := newHeadscaleV1APIServer(app)
|
|
|
|
// Test: SetTags should work on tagged nodes.
|
|
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())
|
|
}
|
|
|
|
// TestDeleteUser_ReturnsProperChangeSignal tests issue #2967 fix:
|
|
// When a user is deleted, the state should return a non-empty change signal
|
|
// to ensure policy manager is updated and clients are notified immediately.
|
|
func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
|
|
// Create a user
|
|
user := app.state.CreateUserForTest("test-user-to-delete")
|
|
require.NotNil(t, user)
|
|
|
|
// Delete the user and verify a non-empty change is returned
|
|
// Issue #2967: Without the fix, DeleteUser returned an empty change,
|
|
// causing stale policy state until another user operation triggered an update.
|
|
changeSignal, err := app.state.DeleteUser(*user.TypedID())
|
|
require.NoError(t, err, "DeleteUser should succeed")
|
|
assert.False(t, changeSignal.IsEmpty(), "DeleteUser should return a non-empty change signal (issue #2967)")
|
|
}
|
|
|
|
// TestDeleteUser_TaggedNodeSurvives tests that deleting a user succeeds when
|
|
// the user's only nodes are tagged, and that those nodes remain in the
|
|
// NodeStore with nil UserID.
|
|
// https://github.com/juanfont/headscale/issues/3077
|
|
func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
|
|
user := app.state.CreateUserForTest("legacy-user")
|
|
|
|
// Register a tagged node via the full auth flow.
|
|
tags := []string{"tag:server"}
|
|
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
|
|
require.NoError(t, err)
|
|
|
|
machineKey := key.NewMachine()
|
|
nodeKey := key.NewNode()
|
|
|
|
regReq := tailcfg.RegisterRequest{
|
|
Auth: &tailcfg.RegisterResponseAuth{
|
|
AuthKey: pak.Key,
|
|
},
|
|
NodeKey: nodeKey.Public(),
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
Hostname: "tagged-server",
|
|
},
|
|
Expiry: time.Now().Add(24 * time.Hour),
|
|
}
|
|
|
|
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
|
require.NoError(t, err)
|
|
require.True(t, resp.MachineAuthorized)
|
|
|
|
// Verify the registered node has nil UserID (enforced invariant).
|
|
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
|
require.True(t, found)
|
|
require.True(t, node.IsTagged())
|
|
assert.False(t, node.UserID().Valid(),
|
|
"tagged node should have nil UserID after registration")
|
|
|
|
nodeID := node.ID()
|
|
|
|
// NodeStore should not list the tagged node under any user.
|
|
nodesForUser := app.state.ListNodesByUser(types.UserID(user.ID))
|
|
assert.Equal(t, 0, nodesForUser.Len(),
|
|
"tagged nodes should not appear in nodesByUser index")
|
|
|
|
// Delete the user.
|
|
changeSignal, err := app.state.DeleteUser(*user.TypedID())
|
|
require.NoError(t, err)
|
|
assert.False(t, changeSignal.IsEmpty())
|
|
|
|
// Tagged node survives in the NodeStore.
|
|
nodeAfter, found := app.state.GetNodeByID(nodeID)
|
|
require.True(t, found, "tagged node should survive user deletion")
|
|
assert.True(t, nodeAfter.IsTagged())
|
|
assert.False(t, nodeAfter.UserID().Valid())
|
|
|
|
// Tagged node appears in the global list.
|
|
allNodes := app.state.ListNodes()
|
|
foundInAll := false
|
|
|
|
for _, n := range allNodes.All() {
|
|
if n.ID() == nodeID {
|
|
foundInAll = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
assert.True(t, foundInAll, "tagged node should appear in the global node list")
|
|
}
|
|
|
|
// TestExpireApiKey_ByID tests that API keys can be expired by ID.
|
|
func TestExpireApiKey_ByID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
apiServer := newHeadscaleV1APIServer(app)
|
|
|
|
// Create an API key
|
|
createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, createResp.GetApiKey())
|
|
|
|
// List keys to get the ID
|
|
listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
|
|
require.NoError(t, err)
|
|
require.Len(t, listResp.GetApiKeys(), 1)
|
|
|
|
keyID := listResp.GetApiKeys()[0].GetId()
|
|
|
|
// Expire by ID
|
|
_, err = apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{
|
|
Id: keyID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Verify key is expired (expiration is set to now or in the past)
|
|
listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
|
|
require.NoError(t, err)
|
|
require.Len(t, listResp.GetApiKeys(), 1)
|
|
assert.NotNil(t, listResp.GetApiKeys()[0].GetExpiration(), "expiration should be set")
|
|
}
|
|
|
|
// TestExpireApiKey_ByPrefix tests that API keys can still be expired by prefix.
|
|
func TestExpireApiKey_ByPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
apiServer := newHeadscaleV1APIServer(app)
|
|
|
|
// Create an API key
|
|
createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, createResp.GetApiKey())
|
|
|
|
// List keys to get the prefix
|
|
listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
|
|
require.NoError(t, err)
|
|
require.Len(t, listResp.GetApiKeys(), 1)
|
|
|
|
keyPrefix := listResp.GetApiKeys()[0].GetPrefix()
|
|
|
|
// Expire by prefix
|
|
_, err = apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{
|
|
Prefix: keyPrefix,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestDeleteApiKey_ByID tests that API keys can be deleted by ID.
|
|
func TestDeleteApiKey_ByID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
apiServer := newHeadscaleV1APIServer(app)
|
|
|
|
// Create an API key
|
|
createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, createResp.GetApiKey())
|
|
|
|
// List keys to get the ID
|
|
listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
|
|
require.NoError(t, err)
|
|
require.Len(t, listResp.GetApiKeys(), 1)
|
|
|
|
keyID := listResp.GetApiKeys()[0].GetId()
|
|
|
|
// Delete by ID
|
|
_, err = apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{
|
|
Id: keyID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Verify key is deleted
|
|
listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, listResp.GetApiKeys())
|
|
}
|
|
|
|
// TestDeleteApiKey_ByPrefix tests that API keys can still be deleted by prefix.
|
|
func TestDeleteApiKey_ByPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
apiServer := newHeadscaleV1APIServer(app)
|
|
|
|
// Create an API key
|
|
createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, createResp.GetApiKey())
|
|
|
|
// List keys to get the prefix
|
|
listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
|
|
require.NoError(t, err)
|
|
require.Len(t, listResp.GetApiKeys(), 1)
|
|
|
|
keyPrefix := listResp.GetApiKeys()[0].GetPrefix()
|
|
|
|
// Delete by prefix
|
|
_, err = apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{
|
|
Prefix: keyPrefix,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Verify key is deleted
|
|
listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, listResp.GetApiKeys())
|
|
}
|
|
|
|
// TestExpireApiKey_NoIdentifier tests that an error is returned when neither ID nor prefix is provided.
|
|
func TestExpireApiKey_NoIdentifier(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
apiServer := newHeadscaleV1APIServer(app)
|
|
|
|
_, err := apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{})
|
|
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(), "must provide id or prefix")
|
|
}
|
|
|
|
// TestDeleteApiKey_NoIdentifier tests that an error is returned when neither ID nor prefix is provided.
|
|
func TestDeleteApiKey_NoIdentifier(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
apiServer := newHeadscaleV1APIServer(app)
|
|
|
|
_, err := apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{})
|
|
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(), "must provide id or prefix")
|
|
}
|
|
|
|
// TestExpireApiKey_BothIdentifiers tests that an error is returned when both ID and prefix are provided.
|
|
func TestExpireApiKey_BothIdentifiers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
apiServer := newHeadscaleV1APIServer(app)
|
|
|
|
_, err := apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{
|
|
Id: 1,
|
|
Prefix: "test",
|
|
})
|
|
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(), "provide either id or prefix, not both")
|
|
}
|
|
|
|
// TestDeleteApiKey_BothIdentifiers tests that an error is returned when both ID and prefix are provided.
|
|
func TestDeleteApiKey_BothIdentifiers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := createTestApp(t)
|
|
apiServer := newHeadscaleV1APIServer(app)
|
|
|
|
_, err := apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{
|
|
Id: 1,
|
|
Prefix: "test",
|
|
})
|
|
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(), "provide either id or prefix, not both")
|
|
}
|