Adopt the agent's preferred host when re-registering a matched node

The resolved-identity match in canonical auto-register always preserved
the stored host, so the route-aware IP preference added to the agent in
022f170df could never take effect for an already-registered node: a
reinstall re-matched the node and kept the stale short-DNS host forever.
Preserve the stored host only when it is absent from the agent's ordered
candidate list (an admin-managed endpoint the agent cannot see); when the
agent itself lists the stored host as a lower-priority candidate, adopt
the newly selected host. Identity continuity (same node record, same
token) is unchanged.
This commit is contained in:
rcourtman 2026-07-09 09:52:59 +01:00
parent e240f162e9
commit c21693489a
3 changed files with 77 additions and 32 deletions

View file

@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
@ -186,6 +187,52 @@ func intersects(values []string, allowed []string) bool {
return false
}
// resolveGroupRoles maps a user's group memberships to Pulse role IDs using the
// provider's group-to-role mappings. Group names are matched case-insensitively
// with surrounding whitespace trimmed, matching how AllowedGroups are matched in
// intersects, so an IdP group like "Admins" still resolves a mapping keyed
// "admins". Without this an admin whose IdP casing differs from the configured
// mapping passes the AllowedGroups gate but silently gets no role and lands as a
// default user. Returned roles preserve first-seen group order and are
// de-duplicated. Mapping keys are visited in sorted order so a normalized-key
// collision resolves deterministically.
func resolveGroupRoles(groups []string, mappings map[string]string) []string {
if len(groups) == 0 || len(mappings) == 0 {
return nil
}
keys := make([]string, 0, len(mappings))
for group := range mappings {
keys = append(keys, group)
}
sort.Strings(keys)
normalized := make(map[string]string, len(mappings))
for _, group := range keys {
key := strings.ToLower(strings.TrimSpace(group))
if key == "" {
continue
}
if _, exists := normalized[key]; !exists {
normalized[key] = mappings[group]
}
}
var roles []string
seen := make(map[string]bool)
for _, group := range groups {
key := strings.ToLower(strings.TrimSpace(group))
if key == "" {
continue
}
if roleID, ok := normalized[key]; ok && !seen[roleID] {
roles = append(roles, roleID)
seen[roleID] = true
}
}
return roles
}
// InitializeOIDCProviders initializes all enabled SSO OIDC providers at startup.
func (r *Router) InitializeOIDCProviders(ctx context.Context) error {
if r.ssoConfig == nil {
@ -587,16 +634,7 @@ func (r *Router) handleSSOOIDCCallback(w http.ResponseWriter, req *http.Request)
// SSO users by the provider-scoped subject instead of mutable display claims.
if authManager := internalauth.GetManager(); authManager != nil {
groups := extractStringSliceClaim(claims, provider.GroupsClaim)
var rolesToAssign []string
seenRoles := make(map[string]bool)
for _, group := range groups {
if roleID, ok := provider.GroupRoleMappings[group]; ok {
if !seenRoles[roleID] {
rolesToAssign = append(rolesToAssign, roleID)
seenRoles[roleID] = true
}
}
}
rolesToAssign := resolveGroupRoles(groups, provider.GroupRoleMappings)
legacyCandidates := ssoLegacyPrincipalCandidates(username, email, extractStringClaim(claims, "name"))
if err := applySSORoleAssignments(authManager, principal, legacyCandidates, rolesToAssign, len(provider.GroupRoleMappings) > 0, true); err != nil {
log.Error().Err(err).Str("user", principal).Str("display_user", username).Msg("Failed to update SSO OIDC user roles")

View file

@ -67,6 +67,33 @@ func TestOIDCRoleMappingLogic(t *testing.T) {
},
expectedRoles: nil,
},
// Regression cases for #1535: the IdP's group casing/whitespace must
// not have to match the configured mapping exactly. AllowedGroups
// already match case-insensitively (intersects), so role mapping has to
// as well or a user passes the group gate but silently lands with no
// role.
{
name: "case-insensitive group match",
groupsClaim: "groups",
mappings: map[string]string{
"admins": "admin",
},
claims: map[string]any{
"groups": []string{"Admins"},
},
expectedRoles: []string{"admin"},
},
{
name: "whitespace-padded mapping key match",
groupsClaim: "groups",
mappings: map[string]string{
" admin ": "admin",
},
claims: map[string]any{
"groups": []string{"admin"},
},
expectedRoles: []string{"admin"},
},
}
for _, tt := range tests {
@ -77,17 +104,7 @@ func TestOIDCRoleMappingLogic(t *testing.T) {
}
groups := extractStringSliceClaim(tt.claims, cfg.GroupsClaim)
var rolesToAssign []string
seenRoles := make(map[string]bool)
for _, group := range groups {
if roleID, ok := cfg.GroupRoleMappings[group]; ok {
if !seenRoles[roleID] {
rolesToAssign = append(rolesToAssign, roleID)
seenRoles[roleID] = true
}
}
}
rolesToAssign := resolveGroupRoles(groups, cfg.GroupRoleMappings)
if len(rolesToAssign) != len(tt.expectedRoles) {
t.Errorf("expected %d roles, got %d", len(tt.expectedRoles), len(rolesToAssign))

View file

@ -268,17 +268,7 @@ func (r *Router) handleSAMLACS(w http.ResponseWriter, req *http.Request) {
// principal. When mappings are configured they are authoritative, including
// the case where the current assertion maps to no roles.
if authManager := internalauth.GetManager(); authManager != nil {
var rolesToAssign []string
seenRoles := make(map[string]bool)
for _, group := range result.Groups {
if roleID, ok := provider.GroupRoleMappings[group]; ok {
if !seenRoles[roleID] {
rolesToAssign = append(rolesToAssign, roleID)
seenRoles[roleID] = true
}
}
}
rolesToAssign := resolveGroupRoles(result.Groups, provider.GroupRoleMappings)
legacyCandidates := ssoLegacyPrincipalCandidates(result.Username, result.Email, result.NameID)
roleErr := applySSORoleAssignments(authManager, principal, legacyCandidates, rolesToAssign, len(provider.GroupRoleMappings) > 0, false)