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.
This commit is contained in:
parent
1f5df017a1
commit
22ee2bfc9c
24 changed files with 3414 additions and 1001 deletions
|
|
@ -1,7 +1,9 @@
|
|||
package v2
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
|
@ -19,6 +21,9 @@ import (
|
|||
"tailscale.com/util/deephash"
|
||||
)
|
||||
|
||||
// ErrInvalidTagOwner is returned when a tag owner is not an Alias type.
|
||||
var ErrInvalidTagOwner = errors.New("tag owner is not an Alias")
|
||||
|
||||
type PolicyManager struct {
|
||||
mu sync.Mutex
|
||||
pol *Policy
|
||||
|
|
@ -536,23 +541,108 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro
|
|||
return false, nil
|
||||
}
|
||||
|
||||
// NodeCanHaveTag checks if a node can have the specified tag during client-initiated
|
||||
// registration or reauth flows (e.g., tailscale up --advertise-tags).
|
||||
//
|
||||
// This function is NOT used by the admin API's SetNodeTags - admins can set any
|
||||
// existing tag on any node by calling State.SetNodeTags directly, which bypasses
|
||||
// this authorization check.
|
||||
func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
|
||||
if pm == nil {
|
||||
if pm == nil || pm.pol == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// Check if tag exists in policy
|
||||
owners, exists := pm.pol.TagOwners[Tag(tag)]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if node's owner can assign this tag via the pre-resolved tagOwnerMap.
|
||||
// The tagOwnerMap contains IP sets built from resolving TagOwners entries
|
||||
// (usernames/groups) to their nodes' IPs, so checking if the node's IP
|
||||
// is in the set answers "does this node's owner own this tag?"
|
||||
if ips, ok := pm.tagOwnerMap[Tag(tag)]; ok {
|
||||
if slices.ContainsFunc(node.IPs(), ips.Contains) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// For new nodes being registered, their IP may not yet be in the tagOwnerMap.
|
||||
// Fall back to checking the node's user directly against the TagOwners.
|
||||
// This handles the case where a user registers a new node with --advertise-tags.
|
||||
if node.User().Valid() {
|
||||
for _, owner := range owners {
|
||||
if pm.userMatchesOwner(node.User(), owner) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// userMatchesOwner checks if a user matches a tag owner entry.
|
||||
// This is used as a fallback when the node's IP is not in the tagOwnerMap.
|
||||
func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool {
|
||||
switch o := owner.(type) {
|
||||
case *Username:
|
||||
if o == nil {
|
||||
return false
|
||||
}
|
||||
// Resolve the username to find the user it refers to
|
||||
resolvedUser, err := o.resolveUser(pm.users)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return user.ID() == resolvedUser.ID
|
||||
|
||||
case *Group:
|
||||
if o == nil || pm.pol == nil {
|
||||
return false
|
||||
}
|
||||
// Resolve the group to get usernames
|
||||
usernames, ok := pm.pol.Groups[*o]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Check if the user matches any username in the group
|
||||
for _, uname := range usernames {
|
||||
resolvedUser, err := uname.resolveUser(pm.users)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if user.ID() == resolvedUser.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TagExists reports whether the given tag is defined in the policy.
|
||||
func (pm *PolicyManager) TagExists(tag string) bool {
|
||||
if pm == nil || pm.pol == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
_, exists := pm.pol.TagOwners[Tag(tag)]
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool {
|
||||
if pm == nil {
|
||||
return false
|
||||
|
|
@ -834,3 +924,126 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flattenTags flattens the TagOwners by resolving nested tags and detecting cycles.
|
||||
// It will return a Owners list where all the Tag types have been resolved to their underlying Owners.
|
||||
func flattenTags(tagOwners TagOwners, tag Tag, visiting map[Tag]bool, chain []Tag) (Owners, error) {
|
||||
if visiting[tag] {
|
||||
cycleStart := 0
|
||||
|
||||
for i, t := range chain {
|
||||
if t == tag {
|
||||
cycleStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cycleTags := make([]string, len(chain[cycleStart:]))
|
||||
for i, t := range chain[cycleStart:] {
|
||||
cycleTags[i] = string(t)
|
||||
}
|
||||
|
||||
slices.Sort(cycleTags)
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", ErrCircularReference, strings.Join(cycleTags, " -> "))
|
||||
}
|
||||
|
||||
visiting[tag] = true
|
||||
|
||||
chain = append(chain, tag)
|
||||
defer delete(visiting, tag)
|
||||
|
||||
var result Owners
|
||||
|
||||
for _, owner := range tagOwners[tag] {
|
||||
switch o := owner.(type) {
|
||||
case *Tag:
|
||||
if _, ok := tagOwners[*o]; !ok {
|
||||
return nil, fmt.Errorf("tag %q %w %q", tag, ErrUndefinedTagReference, *o)
|
||||
}
|
||||
|
||||
nested, err := flattenTags(tagOwners, *o, visiting, chain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, nested...)
|
||||
default:
|
||||
result = append(result, owner)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// flattenTagOwners flattens all TagOwners by resolving nested tags and detecting cycles.
|
||||
// It will return a new TagOwners map where all the Tag types have been resolved to their underlying Owners.
|
||||
func flattenTagOwners(tagOwners TagOwners) (TagOwners, error) {
|
||||
ret := make(TagOwners)
|
||||
|
||||
for tag := range tagOwners {
|
||||
flattened, err := flattenTags(tagOwners, tag, make(map[Tag]bool), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slices.SortFunc(flattened, func(a, b Owner) int {
|
||||
return cmp.Compare(a.String(), b.String())
|
||||
})
|
||||
ret[tag] = slices.CompactFunc(flattened, func(a, b Owner) bool {
|
||||
return a.String() == b.String()
|
||||
})
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// resolveTagOwners resolves the TagOwners to a map of Tag to netipx.IPSet.
|
||||
// The resulting map can be used to quickly look up the IPSet for a given Tag.
|
||||
// It is intended for internal use in a PolicyManager.
|
||||
func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (map[Tag]*netipx.IPSet, error) {
|
||||
if p == nil {
|
||||
return make(map[Tag]*netipx.IPSet), nil
|
||||
}
|
||||
|
||||
if len(p.TagOwners) == 0 {
|
||||
return make(map[Tag]*netipx.IPSet), nil
|
||||
}
|
||||
|
||||
ret := make(map[Tag]*netipx.IPSet)
|
||||
|
||||
tagOwners, err := flattenTagOwners(p.TagOwners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for tag, owners := range tagOwners {
|
||||
var ips netipx.IPSetBuilder
|
||||
|
||||
for _, owner := range owners {
|
||||
switch o := owner.(type) {
|
||||
case *Tag:
|
||||
// After flattening, Tag types should not appear in the owners list.
|
||||
// If they do, skip them as they represent already-resolved references.
|
||||
|
||||
case Alias:
|
||||
// If it does not resolve, that means the tag is not associated with any IP addresses.
|
||||
resolved, _ := o.Resolve(p, users, nodes)
|
||||
ips.AddSet(resolved)
|
||||
|
||||
default:
|
||||
// Should never happen - after flattening, all owners should be Alias types
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidTagOwner, owner)
|
||||
}
|
||||
}
|
||||
|
||||
ipSet, err := ips.IPSet()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret[tag] = ipSet
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue