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
49 changed files with 3102 additions and 757 deletions

View File

@@ -6,7 +6,6 @@ import (
"net/netip"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"time"
@@ -28,6 +27,7 @@ var (
ErrHostnameTooLong = errors.New("hostname too long, cannot except 255 ASCII chars")
ErrNodeHasNoGivenName = errors.New("node has no given name")
ErrNodeUserHasNoName = errors.New("node user has no name")
ErrCannotRemoveAllTags = errors.New("cannot remove all tags from node")
invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+")
)
@@ -97,16 +97,21 @@ type Node struct {
// GivenName is the name used in all DNS related
// parts of headscale.
GivenName string `gorm:"type:varchar(63);unique_index"`
UserID uint
User User `gorm:"constraint:OnDelete:CASCADE;"`
// UserID is set for ALL nodes (tagged and user-owned) to track "created by".
// For tagged nodes, this is informational only - the tag is the owner.
// For user-owned nodes, this identifies the owner.
// Only nil for orphaned nodes (should not happen in normal operation).
UserID *uint
User *User `gorm:"constraint:OnDelete:CASCADE;"`
RegisterMethod string
// ForcedTags are tags set by CLI/API. It is not considered
// the source of truth, but is one of the sources from
// which a tag might originate.
// ForcedTags are _always_ applied to the node.
ForcedTags []string `gorm:"column:forced_tags;serializer:json"`
// Tags is the definitive owner for tagged nodes.
// When non-empty, the node is "tagged" and tags define its identity.
// Empty for user-owned nodes.
// Tags cannot be removed once set (one-way transition).
Tags []string `gorm:"column:tags;serializer:json"`
// When a node has been created with a PreAuthKey, we need to
// prevent the preauthkey from being deleted before the node.
@@ -196,55 +201,32 @@ func (node *Node) HasIP(i netip.Addr) bool {
return false
}
// IsTagged reports if a device is tagged
// and therefore should not be treated as a
// user owned device.
// Currently, this function only handles tags set
// via CLI ("forced tags" and preauthkeys).
// IsTagged reports if a device is tagged and therefore should not be treated
// as a user-owned device.
// When a node has tags, the tags define its identity (not the user).
func (node *Node) IsTagged() bool {
if len(node.ForcedTags) > 0 {
return true
}
return len(node.Tags) > 0
}
if node.AuthKey != nil && len(node.AuthKey.Tags) > 0 {
return true
}
if node.Hostinfo == nil {
return false
}
// TODO(kradalby): Figure out how tagging should work
// and hostinfo.requestedtags.
// Do this in other work.
return false
// IsUserOwned returns true if node is owned by a user (not tagged).
// Tagged nodes may have a UserID for "created by" tracking, but the tag is the owner.
func (node *Node) IsUserOwned() bool {
return !node.IsTagged()
}
// HasTag reports if a node has a given tag.
// Currently, this function only handles tags set
// via CLI ("forced tags" and preauthkeys).
func (node *Node) HasTag(tag string) bool {
return slices.Contains(node.Tags(), tag)
return slices.Contains(node.Tags, tag)
}
func (node *Node) Tags() []string {
var tags []string
if node.AuthKey != nil {
tags = append(tags, node.AuthKey.Tags...)
// TypedUserID returns the UserID as a typed UserID type.
// Returns 0 if UserID is nil.
func (node *Node) TypedUserID() UserID {
if node.UserID == nil {
return 0
}
// TODO(kradalby): Figure out how tagging should work
// and hostinfo.requestedtags.
// Do this in other work.
// #2417
tags = append(tags, node.ForcedTags...)
sort.Strings(tags)
tags = slices.Compact(tags)
return tags
return UserID(*node.UserID)
}
func (node *Node) RequestTags() []string {
@@ -389,8 +371,8 @@ func (node *Node) Proto() *v1.Node {
IpAddresses: node.IPsAsString(),
Name: node.Hostname,
GivenName: node.GivenName,
User: node.User.Proto(),
ForcedTags: node.ForcedTags,
User: nil, // Will be set below based on node type
ForcedTags: node.Tags,
Online: node.IsOnline != nil && *node.IsOnline,
// Only ApprovedRoutes and AvailableRoutes is set here. SubnetRoutes has
@@ -404,6 +386,13 @@ func (node *Node) Proto() *v1.Node {
CreatedAt: timestamppb.New(node.CreatedAt),
}
// Set User field based on node ownership
// Note: User will be set to TaggedDevices in the gRPC layer (grpcv1.go)
// for proper MapResponse formatting
if node.User != nil {
nodeProto.User = node.User.Proto()
}
if node.AuthKey != nil {
nodeProto.PreAuthKey = node.AuthKey.Proto()
}
@@ -701,8 +690,20 @@ func (nodes Nodes) DebugString() string {
func (node Node) DebugString() string {
var sb strings.Builder
fmt.Fprintf(&sb, "%s(%s):\n", node.Hostname, node.ID)
fmt.Fprintf(&sb, "\tUser: %s (%d, %q)\n", node.User.Display(), node.User.ID, node.User.Username())
fmt.Fprintf(&sb, "\tTags: %v\n", node.Tags())
// Show ownership status
if node.IsTagged() {
fmt.Fprintf(&sb, "\tTagged: %v\n", node.Tags)
if node.User != nil {
fmt.Fprintf(&sb, "\tCreated by: %s (%d, %q)\n", node.User.Display(), node.User.ID, node.User.Username())
}
} else if node.User != nil {
fmt.Fprintf(&sb, "\tUser-owned: %s (%d, %q)\n", node.User.Display(), node.User.ID, node.User.Username())
} else {
fmt.Fprintf(&sb, "\tOrphaned: no user or tags\n")
}
fmt.Fprintf(&sb, "\tIPs: %v\n", node.IPs())
fmt.Fprintf(&sb, "\tApprovedRoutes: %v\n", node.ApprovedRoutes)
fmt.Fprintf(&sb, "\tAnnouncedRoutes: %v\n", node.AnnouncedRoutes())
@@ -714,8 +715,7 @@ func (node Node) DebugString() string {
}
func (v NodeView) UserView() UserView {
u := v.User()
return u.View()
return v.User()
}
func (v NodeView) IPs() []netip.Addr {
@@ -790,13 +790,6 @@ func (v NodeView) RequestTagsSlice() views.Slice[string] {
return v.Hostinfo().RequestTags()
}
func (v NodeView) Tags() []string {
if !v.Valid() {
return nil
}
return v.ж.Tags()
}
// IsTagged reports if a device is tagged
// and therefore should not be treated as a
// user owned device.
@@ -893,6 +886,32 @@ func (v NodeView) HasTag(tag string) bool {
return v.ж.HasTag(tag)
}
// TypedUserID returns the UserID as a typed UserID type.
// Returns 0 if UserID is nil or node is invalid.
func (v NodeView) TypedUserID() UserID {
if !v.Valid() {
return 0
}
return v.ж.TypedUserID()
}
// TailscaleUserID returns the user ID to use in Tailscale protocol.
// Tagged nodes always return TaggedDevices.ID, user-owned nodes return their actual UserID.
func (v NodeView) TailscaleUserID() tailcfg.UserID {
if !v.Valid() {
return 0
}
if v.IsTagged() {
//nolint:gosec // G115: TaggedDevices.ID is a constant that fits in int64
return tailcfg.UserID(int64(TaggedDevices.ID))
}
//nolint:gosec // G115: UserID values are within int64 range
return tailcfg.UserID(int64(v.UserID().Get()))
}
// Prefixes returns the node IPs as netip.Prefix.
func (v NodeView) Prefixes() []netip.Prefix {
if !v.Valid() {

View File

@@ -0,0 +1,295 @@
package types
import (
"testing"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"tailscale.com/types/ptr"
)
// TestNodeIsTagged tests the IsTagged() method for determining if a node is tagged.
func TestNodeIsTagged(t *testing.T) {
tests := []struct {
name string
node Node
want bool
}{
{
name: "node with tags - is tagged",
node: Node{
Tags: []string{"tag:server", "tag:prod"},
},
want: true,
},
{
name: "node with single tag - is tagged",
node: Node{
Tags: []string{"tag:web"},
},
want: true,
},
{
name: "node with no tags - not tagged",
node: Node{
Tags: []string{},
},
want: false,
},
{
name: "node with nil tags - not tagged",
node: Node{
Tags: nil,
},
want: false,
},
{
// Tags should be copied from AuthKey during registration, so a node
// with only AuthKey.Tags and no Tags would be invalid in practice.
// IsTagged() only checks node.Tags, not AuthKey.Tags.
name: "node registered with tagged authkey only - not tagged (tags should be copied)",
node: Node{
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
want: false,
},
{
name: "node with both tags and authkey tags - is tagged",
node: Node{
Tags: []string{"tag:server"},
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
want: true,
},
{
name: "node with user and no tags - not tagged",
node: Node{
UserID: ptr.To(uint(42)),
Tags: []string{},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.node.IsTagged()
assert.Equal(t, tt.want, got, "IsTagged() returned unexpected value")
})
}
}
// TestNodeViewIsTagged tests the IsTagged() method on NodeView.
func TestNodeViewIsTagged(t *testing.T) {
tests := []struct {
name string
node Node
want bool
}{
{
name: "tagged node via Tags field",
node: Node{
Tags: []string{"tag:server"},
},
want: true,
},
{
// Tags should be copied from AuthKey during registration, so a node
// with only AuthKey.Tags and no Tags would be invalid in practice.
name: "node with only AuthKey tags - not tagged (tags should be copied)",
node: Node{
AuthKey: &PreAuthKey{
Tags: []string{"tag:web"},
},
},
want: false, // IsTagged() only checks node.Tags
},
{
name: "user-owned node",
node: Node{
UserID: ptr.To(uint(1)),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
view := tt.node.View()
got := view.IsTagged()
assert.Equal(t, tt.want, got, "NodeView.IsTagged() returned unexpected value")
})
}
}
// TestNodeHasTag tests the HasTag() method for checking specific tag membership.
func TestNodeHasTag(t *testing.T) {
tests := []struct {
name string
node Node
tag string
want bool
}{
{
name: "node has the tag",
node: Node{
Tags: []string{"tag:server", "tag:prod"},
},
tag: "tag:server",
want: true,
},
{
name: "node does not have the tag",
node: Node{
Tags: []string{"tag:server", "tag:prod"},
},
tag: "tag:web",
want: false,
},
{
// Tags should be copied from AuthKey during registration
// HasTag() only checks node.Tags, not AuthKey.Tags
name: "node has tag only in authkey - returns false",
node: Node{
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
tag: "tag:database",
want: false,
},
{
// node.Tags is what matters, not AuthKey.Tags
name: "node has tag in Tags but not in AuthKey",
node: Node{
Tags: []string{"tag:server"},
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
tag: "tag:server",
want: true,
},
{
name: "invalid tag format still returns false",
node: Node{
Tags: []string{"tag:server"},
},
tag: "invalid-tag",
want: false,
},
{
name: "empty tag returns false",
node: Node{
Tags: []string{"tag:server"},
},
tag: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.node.HasTag(tt.tag)
assert.Equal(t, tt.want, got, "HasTag() returned unexpected value")
})
}
}
// TestNodeTagsImmutableAfterRegistration tests that tags can only be set during registration.
func TestNodeTagsImmutableAfterRegistration(t *testing.T) {
// Test that a node registered with tags keeps them
taggedNode := Node{
ID: 1,
Tags: []string{"tag:server"},
AuthKey: &PreAuthKey{
Tags: []string{"tag:server"},
},
RegisterMethod: util.RegisterMethodAuthKey,
}
// Node should be tagged
assert.True(t, taggedNode.IsTagged(), "Node registered with tags should be tagged")
// Node should have the tag
has := taggedNode.HasTag("tag:server")
assert.True(t, has, "Node should have the tag it was registered with")
// Test that a user-owned node is not tagged
userNode := Node{
ID: 2,
UserID: ptr.To(uint(42)),
Tags: []string{},
RegisterMethod: util.RegisterMethodOIDC,
}
assert.False(t, userNode.IsTagged(), "User-owned node should not be tagged")
}
// TestNodeOwnershipModel tests the tags-as-identity model.
func TestNodeOwnershipModel(t *testing.T) {
tests := []struct {
name string
node Node
wantIsTagged bool
description string
}{
{
name: "tagged node has tags, UserID is informational",
node: Node{
ID: 1,
UserID: ptr.To(uint(5)), // "created by" user 5
Tags: []string{"tag:server"},
},
wantIsTagged: true,
description: "Tagged nodes may have UserID set for tracking, but ownership is defined by tags",
},
{
name: "user-owned node has no tags",
node: Node{
ID: 2,
UserID: ptr.To(uint(5)),
Tags: []string{},
},
wantIsTagged: false,
description: "User-owned nodes are owned by the user, not by tags",
},
{
// Tags should be copied from AuthKey to Node during registration
// IsTagged() only checks node.Tags, not AuthKey.Tags
name: "node with only authkey tags - not tagged (tags should be copied)",
node: Node{
ID: 3,
UserID: ptr.To(uint(5)), // "created by" user 5
AuthKey: &PreAuthKey{
Tags: []string{"tag:database"},
},
},
wantIsTagged: false,
description: "IsTagged() only checks node.Tags; AuthKey.Tags should be copied during registration",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.node.IsTagged()
assert.Equal(t, tt.wantIsTagged, got, tt.description)
})
}
}
// TestUserTypedID tests the TypedID() helper method.
func TestUserTypedID(t *testing.T) {
user := User{
Model: gorm.Model{ID: 42},
}
typedID := user.TypedID()
assert.NotNil(t, typedID, "TypedID() should return non-nil pointer")
assert.Equal(t, UserID(42), *typedID, "TypedID() should return correct UserID value")
}

View File

@@ -139,7 +139,7 @@ func TestNodeFQDN(t *testing.T) {
name: "no-dnsconfig-with-username",
node: Node{
GivenName: "test",
User: User{
User: &User{
Name: "user",
},
},
@@ -150,7 +150,7 @@ func TestNodeFQDN(t *testing.T) {
name: "all-set",
node: Node{
GivenName: "test",
User: User{
User: &User{
Name: "user",
},
},
@@ -160,7 +160,7 @@ func TestNodeFQDN(t *testing.T) {
{
name: "no-given-name",
node: Node{
User: User{
User: &User{
Name: "user",
},
},
@@ -179,7 +179,7 @@ func TestNodeFQDN(t *testing.T) {
name: "no-dnsconfig",
node: Node{
GivenName: "test",
User: User{
User: &User{
Name: "user",
},
},

View File

@@ -23,16 +23,19 @@ type PreAuthKey struct {
Prefix string
Hash []byte // bcrypt
UserID uint
User User `gorm:"constraint:OnDelete:SET NULL;"`
// For tagged keys: UserID tracks who created the key (informational)
// For user-owned keys: UserID tracks the node owner
// Can be nil for system-created tagged keys
UserID *uint
User *User `gorm:"constraint:OnDelete:SET NULL;"`
Reusable bool
Ephemeral bool `gorm:"default:false"`
Used bool `gorm:"default:false"`
// Tags are always applied to the node and is one of
// the sources of tags a node might have. They are copied
// from the PreAuthKey when the node logs in the first time,
// and ignored after.
// Tags to assign to nodes registered with this key.
// Tags are copied to the node during registration.
// If non-empty, this creates tagged nodes (not user-owned).
Tags []string `gorm:"serializer:json"`
CreatedAt *time.Time
@@ -48,19 +51,23 @@ type PreAuthKeyNew struct {
Tags []string
Expiration *time.Time
CreatedAt *time.Time
User User
User *User // Can be nil for system-created tagged keys
}
func (key *PreAuthKeyNew) Proto() *v1.PreAuthKey {
protoKey := v1.PreAuthKey{
Id: key.ID,
Key: key.Key,
User: key.User.Proto(),
User: nil, // Will be set below if not nil
Reusable: key.Reusable,
Ephemeral: key.Ephemeral,
AclTags: key.Tags,
}
if key.User != nil {
protoKey.User = key.User.Proto()
}
if key.Expiration != nil {
protoKey.Expiration = timestamppb.New(*key.Expiration)
}
@@ -74,7 +81,7 @@ func (key *PreAuthKeyNew) Proto() *v1.PreAuthKey {
func (key *PreAuthKey) Proto() *v1.PreAuthKey {
protoKey := v1.PreAuthKey{
User: key.User.Proto(),
User: nil, // Will be set below if not nil
Id: key.ID,
Ephemeral: key.Ephemeral,
Reusable: key.Reusable,
@@ -82,6 +89,10 @@ func (key *PreAuthKey) Proto() *v1.PreAuthKey {
AclTags: key.Tags,
}
if key.User != nil {
protoKey.User = key.User.Proto()
}
// For new keys (with prefix/hash), show the prefix so users can identify the key
// For legacy keys (with plaintext key), show the full key for backwards compatibility
if key.Prefix != "" {
@@ -139,3 +150,9 @@ func (pak *PreAuthKey) Validate() error {
return nil
}
// IsTagged returns true if this PreAuthKey creates tagged nodes.
// When a PreAuthKey has tags, nodes registered with it will be tagged nodes.
func (pak *PreAuthKey) IsTagged() bool {
return len(pak.Tags) > 0
}

View File

@@ -54,7 +54,13 @@ func (src *Node) Clone() *Node {
if dst.IPv6 != nil {
dst.IPv6 = ptr.To(*src.IPv6)
}
dst.ForcedTags = append(src.ForcedTags[:0:0], src.ForcedTags...)
if dst.UserID != nil {
dst.UserID = ptr.To(*src.UserID)
}
if dst.User != nil {
dst.User = ptr.To(*src.User)
}
dst.Tags = append(src.Tags[:0:0], src.Tags...)
if dst.AuthKeyID != nil {
dst.AuthKeyID = ptr.To(*src.AuthKeyID)
}
@@ -87,10 +93,10 @@ var _NodeCloneNeedsRegeneration = Node(struct {
IPv6 *netip.Addr
Hostname string
GivenName string
UserID uint
User User
UserID *uint
User *User
RegisterMethod string
ForcedTags []string
Tags []string
AuthKeyID *uint64
AuthKey *PreAuthKey
Expiry *time.Time
@@ -111,6 +117,12 @@ func (src *PreAuthKey) Clone() *PreAuthKey {
dst := new(PreAuthKey)
*dst = *src
dst.Hash = append(src.Hash[:0:0], src.Hash...)
if dst.UserID != nil {
dst.UserID = ptr.To(*src.UserID)
}
if dst.User != nil {
dst.User = ptr.To(*src.User)
}
dst.Tags = append(src.Tags[:0:0], src.Tags...)
if dst.CreatedAt != nil {
dst.CreatedAt = ptr.To(*src.CreatedAt)
@@ -127,8 +139,8 @@ var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct {
Key string
Prefix string
Hash []byte
UserID uint
User User
UserID *uint
User *User
Reusable bool
Ephemeral bool
Used bool

View File

@@ -139,12 +139,13 @@ func (v NodeView) IPv4() views.ValuePointer[netip.Addr] { return views.ValuePo
func (v NodeView) IPv6() views.ValuePointer[netip.Addr] { return views.ValuePointerOf(v.ж.IPv6) }
func (v NodeView) Hostname() string { return v.ж.Hostname }
func (v NodeView) GivenName() string { return v.ж.GivenName }
func (v NodeView) UserID() uint { return v.ж.UserID }
func (v NodeView) User() User { return v.ж.User }
func (v NodeView) Hostname() string { return v.ж.Hostname }
func (v NodeView) GivenName() string { return v.ж.GivenName }
func (v NodeView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) }
func (v NodeView) User() UserView { return v.ж.User.View() }
func (v NodeView) RegisterMethod() string { return v.ж.RegisterMethod }
func (v NodeView) ForcedTags() views.Slice[string] { return views.SliceOf(v.ж.ForcedTags) }
func (v NodeView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) }
func (v NodeView) AuthKeyID() views.ValuePointer[uint64] { return views.ValuePointerOf(v.ж.AuthKeyID) }
func (v NodeView) AuthKey() PreAuthKeyView { return v.ж.AuthKey.View() }
@@ -179,10 +180,10 @@ var _NodeViewNeedsRegeneration = Node(struct {
IPv6 *netip.Addr
Hostname string
GivenName string
UserID uint
User User
UserID *uint
User *User
RegisterMethod string
ForcedTags []string
Tags []string
AuthKeyID *uint64
AuthKey *PreAuthKey
Expiry *time.Time
@@ -239,16 +240,17 @@ func (v *PreAuthKeyView) UnmarshalJSON(b []byte) error {
return nil
}
func (v PreAuthKeyView) ID() uint64 { return v.ж.ID }
func (v PreAuthKeyView) Key() string { return v.ж.Key }
func (v PreAuthKeyView) Prefix() string { return v.ж.Prefix }
func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) }
func (v PreAuthKeyView) UserID() uint { return v.ж.UserID }
func (v PreAuthKeyView) User() User { return v.ж.User }
func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable }
func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral }
func (v PreAuthKeyView) Used() bool { return v.ж.Used }
func (v PreAuthKeyView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) }
func (v PreAuthKeyView) ID() uint64 { return v.ж.ID }
func (v PreAuthKeyView) Key() string { return v.ж.Key }
func (v PreAuthKeyView) Prefix() string { return v.ж.Prefix }
func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) }
func (v PreAuthKeyView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) }
func (v PreAuthKeyView) User() UserView { return v.ж.User.View() }
func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable }
func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral }
func (v PreAuthKeyView) Used() bool { return v.ж.Used }
func (v PreAuthKeyView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) }
func (v PreAuthKeyView) CreatedAt() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.CreatedAt)
}
@@ -263,8 +265,8 @@ var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct {
Key string
Prefix string
Hash []byte
UserID uint
User User
UserID *uint
User *User
Reusable bool
Ephemeral bool
Used bool

View File

@@ -22,6 +22,21 @@ type UserID uint64
type Users []User
const (
// TaggedDevicesUserID is the special user ID for tagged devices.
// This ID is used when rendering tagged nodes in the Tailscale protocol.
TaggedDevicesUserID = 2147455555
)
// TaggedDevices is a special user used in MapResponse for tagged nodes.
// Tagged nodes don't belong to a real user - the tag is their identity.
// This special user ID is used when rendering tagged nodes in the Tailscale protocol.
var TaggedDevices = User{
Model: gorm.Model{ID: TaggedDevicesUserID},
Name: "tagged-devices",
DisplayName: "Tagged Devices",
}
func (u Users) String() string {
var sb strings.Builder
sb.WriteString("[ ")
@@ -77,6 +92,13 @@ func (u *User) StringID() string {
return strconv.FormatUint(uint64(u.ID), 10)
}
// TypedID returns a pointer to the user's ID as a UserID type.
// This is a convenience method to avoid ugly casting like ptr.To(types.UserID(user.ID)).
func (u *User) TypedID() *UserID {
uid := UserID(u.ID)
return &uid
}
// Username is the main way to get the username of a user,
// it will return the email if it exists, the name if it exists,
// the OIDCIdentifier if it exists, and the ID if nothing else exists.
@@ -117,6 +139,13 @@ func (u UserView) TailscaleUser() tailcfg.User {
return u.ж.TailscaleUser()
}
// ID returns the user's ID.
// This is a custom accessor because gorm.Model.ID is embedded
// and the viewer generator doesn't always produce it.
func (u UserView) ID() uint {
return u.ж.ID
}
func (u *User) TailscaleLogin() tailcfg.Login {
return tailcfg.Login{
ID: tailcfg.LoginID(u.ID),