mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 20:49:24 +09:00
api/v2: show a tagged device's creator as its user
A tagged device shows the login that made the key it joined with. Its owner stays its tags.
This commit is contained in:
1 parent
7210c01b60
commit
8ea774564d
7 files changed
+88
-27
No files matched your search
@@ -482,10 +482,18 @@ func routesFromView(view types.NodeView) DeviceRoutes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deviceUser is the owning login. [types.NodeView.Owner] already resolves the
|
// deviceUser is the owning login. A tagged device, owned by its tags, shows
|
||||||
// tags-as-identity rule: tagged nodes present the special TaggedDevices user,
|
// the login that made the key it joined with, as Tailscale does: nothing when
|
||||||
// user-owned nodes present their login, orphaned nodes present nothing.
|
// a token made that key.
|
||||||
func deviceUser(view types.NodeView) string {
|
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()
|
owner := view.Owner()
|
||||||
if owner.Valid() {
|
if owner.Valid() {
|
||||||
return owner.Username()
|
return owner.Username()
|
||||||
|
|||||||
@@ -111,20 +111,6 @@ var apiv2CompatSkipReasons = map[string]string{
|
|||||||
"oauth/device:prod-db/default/device-oauth-client": deviceOAuthClientID,
|
"oauth/device:prod-db/default/device-oauth-client": deviceOAuthClientID,
|
||||||
"oauth/device:router/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:before/empty-route-lists": emptyRouteListsOmitted,
|
||||||
"oauth/device:ci-runner/empty-route-lists": emptyRouteListsOmitted,
|
"oauth/device:ci-runner/empty-route-lists": emptyRouteListsOmitted,
|
||||||
"oauth/device:joiner/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"
|
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"
|
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"
|
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"
|
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"
|
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"
|
hujsonFormatted = "HUJSON_FORMATTED: SaaS stores a HuJSON policy formatted, comments kept; headscale stores the bytes as sent"
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ func newDeviceTestEnv(t *testing.T) deviceTestEnv {
|
|||||||
|
|
||||||
node := app.state.CreateRegisteredNodeForTest(user, "contract-dut")
|
node := app.state.CreateRegisteredNodeForTest(user, "contract-dut")
|
||||||
node.User = user
|
node.User = user
|
||||||
|
|
||||||
|
pak, err := app.state.GetPreAuthKeyByID(*node.AuthKeyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
node.AuthKey = pak.AsCredential()
|
||||||
view := app.state.PutNodeInStoreForTest(*node)
|
view := app.state.PutNodeInStoreForTest(*node)
|
||||||
|
|
||||||
return deviceTestEnv{
|
return deviceTestEnv{
|
||||||
@@ -241,7 +246,8 @@ func TestAPIv2Device_SetTags(t *testing.T) {
|
|||||||
assert.True(t, n.IsTagged())
|
assert.True(t, n.IsTagged())
|
||||||
assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice())
|
assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice())
|
||||||
assert.False(t, n.User().Valid())
|
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).
|
// Two tags (sorted).
|
||||||
require.Equal(t, http.StatusOK, setTags([]string{"tag:ci", "tag:prod"}))
|
require.Equal(t, http.StatusOK, setTags([]string{"tag:ci", "tag:prod"}))
|
||||||
|
|||||||
@@ -339,6 +339,61 @@ func TestAPIv2OAuth_TaggedKeyCreator(t *testing.T) {
|
|||||||
assert.False(t, nv.AuthKey().User().Valid())
|
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
|
// TestAPIv2OAuth_KeyListing: a listing never holds an invalid key; without
|
||||||
// all=true a token sees only the auth keys tokens made, whatever their tags.
|
// all=true a token sees only the auth keys tokens made, whatever their tags.
|
||||||
func TestAPIv2OAuth_KeyListing(t *testing.T) {
|
func TestAPIv2OAuth_KeyListing(t *testing.T) {
|
||||||
|
|||||||
@@ -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}))
|
require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: false}))
|
||||||
assert.False(t, srvNodeView(t, srv, id).Expiry().Valid())
|
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"}))
|
require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"}))
|
||||||
dev, err = dr.Get(ctx, deviceID)
|
dev, err = dr.Get(ctx, deviceID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []string{"tag:ci"}, dev.Tags)
|
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)
|
n := srvNodeView(t, srv, id)
|
||||||
assert.True(t, n.IsTagged())
|
assert.True(t, n.IsTagged())
|
||||||
assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice())
|
assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice())
|
||||||
|
|||||||
@@ -93,15 +93,11 @@ var mapStreamSkipReasons = map[string]string{
|
|||||||
"policy-generation/G3/thor-2/peer-owners": sameUserPeers,
|
"policy-generation/G3/thor-2/peer-owners": sameUserPeers,
|
||||||
"policy-generation/G4/thor-1/peer-owners": sameUserPeers,
|
"policy-generation/G4/thor-1/peer-owners": sameUserPeers,
|
||||||
"policy-generation/G4/thor-2/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 (
|
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"
|
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"
|
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
|
// mapStreamNotCompared lists, by key prefix, checks left out: SaaS's answer
|
||||||
|
|||||||
@@ -403,10 +403,18 @@ func (s *TestServer) CreateRegisteredNode(tb testing.TB, owner *types.User, host
|
|||||||
tb.Helper()
|
tb.Helper()
|
||||||
|
|
||||||
node := s.st.CreateRegisteredNodeForTest(owner, hostname...)
|
node := s.st.CreateRegisteredNodeForTest(owner, hostname...)
|
||||||
// CreateRegisteredNodeForTest sets UserID but leaves the User association
|
// CreateRegisteredNodeForTest sets UserID and AuthKeyID but leaves the
|
||||||
// unloaded; the device response renders the owner login, so attach it.
|
// associations unloaded; the device response renders the owner login and,
|
||||||
|
// once tagged, the login that made its key, so attach both.
|
||||||
node.User = owner
|
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)
|
return s.st.PutNodeInStoreForTest(*node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user