diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index b8bc25290..4d4490af0 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -482,10 +482,18 @@ func routesFromView(view types.NodeView) DeviceRoutes { } } -// deviceUser is the owning login. [types.NodeView.Owner] already resolves the -// tags-as-identity rule: tagged nodes present the special TaggedDevices user, -// user-owned nodes present their login, orphaned nodes present nothing. +// deviceUser is the owning login. A tagged device, owned by its tags, shows +// the login that made the key it joined with, as Tailscale does: nothing when +// a token made that key. func deviceUser(view types.NodeView) string { + if view.IsTagged() { + if key := view.AuthKey(); key.Valid() && key.User().Valid() { + return key.User().Username() + } + + return "" + } + owner := view.Owner() if owner.Valid() { return owner.Username() diff --git a/hscontrol/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index c21c64e20..6d8b8523a 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -111,20 +111,6 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/device:prod-db/default/device-oauth-client": deviceOAuthClientID, "oauth/device:router/default/device-oauth-client": deviceOAuthClientID, - "oauth/device:before/all/device-user": taggedDeviceUser, - "oauth/device:ci-runner/all/device-user": taggedDeviceUser, - "oauth/device:joiner/all/device-user": taggedDeviceUser, - "oauth/device:laptop/all/device-user": taggedDeviceUser, - "oauth/device:managed/all/device-user": taggedDeviceUser, - "oauth/device:mixed/all/device-user": taggedDeviceUser, - "oauth/device:prod-db/all/device-user": taggedDeviceUser, - "oauth/device:router/all/device-user": taggedDeviceUser, - "oauth/device:ci-runner/default/device-user": taggedDeviceUser, - "oauth/device:managed/default/device-user": taggedDeviceUser, - "oauth/device:mixed/default/device-user": taggedDeviceUser, - "oauth/device:prod-db/default/device-user": taggedDeviceUser, - "oauth/device:router/default/device-user": taggedDeviceUser, - "oauth/device:before/empty-route-lists": emptyRouteListsOmitted, "oauth/device:ci-runner/empty-route-lists": emptyRouteListsOmitted, "oauth/device:joiner/empty-route-lists": emptyRouteListsOmitted, @@ -193,7 +179,6 @@ const ( deletedClientCredentialText = "DELETED_CLIENT_CREDENTIAL_TEXT: SaaS refuses a deleted client's secret and tokens with API token invalid; headscale says invalid client credentials and unauthorized" missingScopeText = "MISSING_SCOPE_TEXT: SaaS refuses a delete outside the token's scopes with calling actor does not have enough permissions; headscale names the missing scope" deviceOAuthClientID = "DEVICE_OAUTH_CLIENT_ID: SaaS device bodies carry oauthClientId, naming the join key or the OAuth client that made it; headscale's have no such field" - taggedDeviceUser = "TAGGED_DEVICE_USER: SaaS shows a tagged device's user as the login that made its key, empty for a key a token made; headscale shows tagged-devices" emptyRouteListsOmitted = "EMPTY_ROUTE_LISTS_OMITTED: SaaS sends advertisedRoutes and enabledRoutes as [] for a device without routes under fields=all; headscale leaves both out" noV2Validate = "NO_V2_VALIDATE: headscale's v2 API has no /acl/validate" hujsonFormatted = "HUJSON_FORMATTED: SaaS stores a HuJSON policy formatted, comments kept; headscale stores the bytes as sent" diff --git a/hscontrol/apiv2_devices_test.go b/hscontrol/apiv2_devices_test.go index 50d312f90..6d667702c 100644 --- a/hscontrol/apiv2_devices_test.go +++ b/hscontrol/apiv2_devices_test.go @@ -61,6 +61,11 @@ func newDeviceTestEnv(t *testing.T) deviceTestEnv { node := app.state.CreateRegisteredNodeForTest(user, "contract-dut") node.User = user + + pak, err := app.state.GetPreAuthKeyByID(*node.AuthKeyID) + require.NoError(t, err) + + node.AuthKey = pak.AsCredential() view := app.state.PutNodeInStoreForTest(*node) return deviceTestEnv{ @@ -241,7 +246,8 @@ func TestAPIv2Device_SetTags(t *testing.T) { assert.True(t, n.IsTagged()) assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) assert.False(t, n.User().Valid()) - assert.Equal(t, types.TaggedDevices.Username(), getDevice(t, e.api, e.deviceID).User) + assert.Equal(t, e.user.Username(), getDevice(t, e.api, e.deviceID).User, + "a tagged device shows the login that made its key") // Two tags (sorted). require.Equal(t, http.StatusOK, setTags([]string{"tag:ci", "tag:prod"})) diff --git a/hscontrol/apiv2_oauth_test.go b/hscontrol/apiv2_oauth_test.go index 7df1a1d9d..7015e2cac 100644 --- a/hscontrol/apiv2_oauth_test.go +++ b/hscontrol/apiv2_oauth_test.go @@ -339,6 +339,61 @@ func TestAPIv2OAuth_TaggedKeyCreator(t *testing.T) { assert.False(t, nv.AuthKey().User().Valid()) } +// TestAPIv2OAuth_TaggedDeviceUser: a tagged device shows the login that made +// the key it joined with, and nothing for a key a token made; it stays owned +// by its tags. +func TestAPIv2OAuth_TaggedDeviceUser(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + owner := app.state.CreateUserForTest("odin") + ownerKey, apiKey, err := app.state.CreateAPIKey(nil) + require.NoError(t, err) + require.NoError(t, app.state.SetAPIKeyUser(apiKey.ID, types.UserID(owner.ID))) + + tok := mintScopedToken(t, baseURL, admin, scope.AuthKeys) + + for bearer, want := range map[string]string{ownerKey: owner.Username(), tok: ""} { + status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{ + Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{ + Create: apiv2.KeyDeviceCreateCapabilities{Tags: []string{"tag:ci"}}, + }}, + }) + require.Equalf(t, http.StatusOK, status, "body: %s", body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(body, &key)) + + node, _, err := app.state.HandleNodeFromPreAuthKey(tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: key.Key}, + NodeKey: tskey.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "tagged"}, + }, tskey.NewMachine().Public()) + require.NoError(t, err) + require.True(t, node.IsTagged()) + + status, body = apiReq(t, http.MethodGet, baseURL+"/api/v2/device/"+node.StringID(), admin, nil) + require.Equalf(t, http.StatusOK, status, "body: %s", body) + + var dev apiv2.Device + require.NoError(t, json.Unmarshal(body, &dev)) + assert.Equal(t, want, dev.User) + assert.Equal(t, []string{"tag:ci"}, dev.Tags) + + if want == "" { + continue + } + + // The creator's rename reaches the devices its keys made. + renamed, _, err := app.state.RenameUser(types.UserID(owner.ID), "thor") + require.NoError(t, err) + + status, body = apiReq(t, http.MethodGet, baseURL+"/api/v2/device/"+node.StringID(), admin, nil) + require.Equalf(t, http.StatusOK, status, "body: %s", body) + require.NoError(t, json.Unmarshal(body, &dev)) + assert.Equal(t, renamed.Username(), dev.User) + } +} + // TestAPIv2OAuth_KeyListing: a listing never holds an invalid key; without // all=true a token sees only the auth keys tokens made, whatever their tags. func TestAPIv2OAuth_KeyListing(t *testing.T) { diff --git a/hscontrol/servertest/apiv2_devices_test.go b/hscontrol/servertest/apiv2_devices_test.go index cd7466e51..12513d240 100644 --- a/hscontrol/servertest/apiv2_devices_test.go +++ b/hscontrol/servertest/apiv2_devices_test.go @@ -151,12 +151,15 @@ func apiv2DevicesGoClient(t *testing.T, srv *servertest.TestServer, baseURL, api require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: false})) assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) - // SetTags — flips ownership to the tags; the user is dropped. + // SetTags — flips ownership to the tags; the user is dropped, and the + // device shows the login that made its key. + joinedBy := dev.User + require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"})) dev, err = dr.Get(ctx, deviceID) require.NoError(t, err) assert.Equal(t, []string{"tag:ci"}, dev.Tags) - assert.Equal(t, types.TaggedDevices.Username(), dev.User) + assert.Equal(t, joinedBy, dev.User) n := srvNodeView(t, srv, id) assert.True(t, n.IsTagged()) assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) diff --git a/hscontrol/servertest/mapstream_compat_test.go b/hscontrol/servertest/mapstream_compat_test.go index c798fe2cd..a4d446a1a 100644 --- a/hscontrol/servertest/mapstream_compat_test.go +++ b/hscontrol/servertest/mapstream_compat_test.go @@ -93,15 +93,11 @@ var mapStreamSkipReasons = map[string]string{ "policy-generation/G3/thor-2/peer-owners": sameUserPeers, "policy-generation/G4/thor-1/peer-owners": sameUserPeers, "policy-generation/G4/thor-2/peer-owners": sameUserPeers, - - "user-role/S0/tag-a/device-user": taggedDeviceUser, - "user-role/S0/tag-b/device-user": taggedDeviceUser, } const ( policyResponseToEveryNode = "POLICY_RESPONSE_TO_EVERY_NODE: headscale sends every connected node a policy response when the policy, a node's tags or approved routes, or the set of nodes change; SaaS sends only the nodes whose peers or filter change" restartEndpointsKept = "RESTART_ENDPOINTS_KEPT: headscale keeps a restarted node's last endpoints at its peers until it reports new ones; SaaS sends peers the node without endpoints until then" - taggedDeviceUser = "TAGGED_DEVICE_USER: SaaS shows a tagged device's user as the login that made its key; headscale shows tagged-devices" ) // mapStreamNotCompared lists, by key prefix, checks left out: SaaS's answer diff --git a/hscontrol/servertest/server.go b/hscontrol/servertest/server.go index d6f7f2dc2..969377325 100644 --- a/hscontrol/servertest/server.go +++ b/hscontrol/servertest/server.go @@ -403,10 +403,18 @@ func (s *TestServer) CreateRegisteredNode(tb testing.TB, owner *types.User, host tb.Helper() node := s.st.CreateRegisteredNodeForTest(owner, hostname...) - // CreateRegisteredNodeForTest sets UserID but leaves the User association - // unloaded; the device response renders the owner login, so attach it. + // CreateRegisteredNodeForTest sets UserID and AuthKeyID but leaves the + // associations unloaded; the device response renders the owner login and, + // once tagged, the login that made its key, so attach both. node.User = owner + pak, err := s.st.GetPreAuthKeyByID(*node.AuthKeyID) + if err != nil { + tb.Fatalf("servertest: GetPreAuthKeyByID: %v", err) + } + + node.AuthKey = pak.AsCredential() + return s.st.PutNodeInStoreForTest(*node) }