Fix SCIM user association with host when IdP user is set before being provisioned (#42889)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34667

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually



https://github.com/user-attachments/assets/92a38e91-5b4b-456e-8c5e-1a8742748c39
This commit is contained in:
Nico 2026-04-02 13:35:07 -03:00 committed by GitHub
parent e53daf4971
commit 3a12ba8571
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 229 additions and 9 deletions

View file

@ -0,0 +1 @@
* Fixed SCIM user not associating with host when IdP username was set before the SCIM user was created.

View file

@ -4535,18 +4535,36 @@ WHERE %s`
}
}
}
// Compute the SCIM user's primary email once for reuse across queries.
var primaryEmail string
for _, e := range user.Emails {
if e.Primary != nil && *e.Primary {
primaryEmail = e.Email
break
}
}
// If we don't have any matches yet, try to match by SCIM primary email.
if len(hostIDs) == 0 && len(user.Emails) > 0 {
var primaryEmail string
for _, e := range user.Emails {
if e.Primary != nil && *e.Primary {
primaryEmail = e.Email
break
if len(hostIDs) == 0 && primaryEmail != "" {
if err := sqlx.SelectContext(ctx, tx, &hostIDs, fmt.Sprintf(selectFmt, `mia.email = ?`), primaryEmail); err != nil {
return ctxerr.Wrap(ctx, err, "maybeAssociateScimUserWithHostMDMIdPAccount: select host ids by primary email")
}
}
// If we still don't have any matches, fall back to host_emails with source='idp'.
// This covers the case where the IdP username was set on the host (via the API) before
// the SCIM user was created, so no mdm_idp_accounts record exists yet.
// Use DISTINCT to avoid duplicates since host_emails has no uniqueness constraints.
if len(hostIDs) == 0 {
hostEmailSelectFmt := `SELECT DISTINCT he.host_id FROM host_emails he WHERE he.source = ? AND %s ORDER BY he.host_id`
if user.UserName != "" {
if err := sqlx.SelectContext(ctx, tx, &hostIDs, fmt.Sprintf(hostEmailSelectFmt, `he.email = ?`), fleet.DeviceMappingIDP, user.UserName); err != nil {
return ctxerr.Wrap(ctx, err, "maybeAssociateScimUserWithHostMDMIdPAccount: match host_emails by username")
}
}
if primaryEmail != "" {
if err := sqlx.SelectContext(ctx, tx, &hostIDs, fmt.Sprintf(selectFmt, `mia.email = ?`), primaryEmail); err != nil {
return ctxerr.Wrap(ctx, err, "maybeAssociateScimUserWithHostMDMIdPAccount: select host ids by primary email")
if len(hostIDs) == 0 && primaryEmail != "" {
if err := sqlx.SelectContext(ctx, tx, &hostIDs, fmt.Sprintf(hostEmailSelectFmt, `he.email = ?`), fleet.DeviceMappingIDP, primaryEmail); err != nil {
return ctxerr.Wrap(ctx, err, "maybeAssociateScimUserWithHostMDMIdPAccount: match host_emails primary email")
}
}
}

View file

@ -192,6 +192,7 @@ func TestHosts(t *testing.T) {
{"ListHostsByProfileUUIDAndStatus", testListHostsProfileUUIDAndStatus},
{"SetOrUpdateHostDiskTpmPIN", testSetOrUpdateHostDiskTpmPIN},
{"MaybeAssociateHostWithScimUser", testMaybeAssociateHostWithScimUser},
{"ScimUserAssociationViaHostEmails", testScimUserAssociationViaHostEmails},
{"GetHostsLockWipeStatusBatch", testGetHostsLockWipeStatusBatch},
{"HostTimeZone", testHostTimeZone},
{"ListHostsDEPFilters", testListHostsDEPFilters},
@ -12804,6 +12805,206 @@ func testMaybeAssociateHostWithScimUser(t *testing.T, ds *Datastore) {
})
}
func testScimUserAssociationViaHostEmails(t *testing.T, ds *Datastore) {
ctx := t.Context()
cleanup := func() {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM host_scim_user`)
return err
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM scim_user_emails`)
return err
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM scim_users`)
return err
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM host_emails`)
return err
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM host_mdm_idp_accounts`)
return err
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM mdm_idp_accounts`)
return err
})
}
t.Run("username set via idp source before SCIM user created", func(t *testing.T) {
defer cleanup()
host, err := ds.NewHost(ctx, &fleet.Host{
UUID: uuid.NewString(),
Platform: "darwin",
})
require.NoError(t, err)
// Set IdP username on the host via host_emails (simulates PUT /device_mapping with source=idp)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`,
"scimuser@example.com", host.ID, fleet.DeviceMappingIDP,
)
return err
})
// Create the SCIM user
scimUser := fleet.ScimUser{
UserName: "scimuser@example.com",
GivenName: ptr.String("Test"),
FamilyName: ptr.String("User"),
Active: ptr.Bool(true), //nolint:modernize
}
scimUserID, err := ds.CreateScimUser(ctx, &scimUser)
require.NoError(t, err)
// Verify host_scim_user mapping was created
var associatedScimUserID uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &associatedScimUserID,
`SELECT scim_user_id FROM host_scim_user WHERE host_id = ?`, host.ID)
})
assert.Equal(t, scimUserID, associatedScimUserID)
})
t.Run("SCIM user with primary email matches host_emails idp source", func(t *testing.T) {
defer cleanup()
host, err := ds.NewHost(ctx, &fleet.Host{
UUID: uuid.NewString(),
Platform: "darwin",
})
require.NoError(t, err)
// Set IdP email on the host
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`,
"primary@example.com", host.ID, fleet.DeviceMappingIDP,
)
return err
})
// Create SCIM user whose username doesn't match, but primary email does
scimUser := fleet.ScimUser{
UserName: "different-username",
GivenName: ptr.String("Test"),
FamilyName: ptr.String("User"),
Active: ptr.Bool(true), //nolint:modernize
Emails: []fleet.ScimUserEmail{
{
Email: "primary@example.com",
Primary: ptr.Bool(true), //nolint:modernize
Type: ptr.String("work"),
},
},
}
scimUserID, err := ds.CreateScimUser(ctx, &scimUser)
require.NoError(t, err)
var associatedScimUserID uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &associatedScimUserID,
`SELECT scim_user_id FROM host_scim_user WHERE host_id = ?`, host.ID)
})
assert.Equal(t, scimUserID, associatedScimUserID)
})
t.Run("no association when host_emails source is not idp", func(t *testing.T) {
defer cleanup()
host, err := ds.NewHost(ctx, &fleet.Host{
UUID: uuid.NewString(),
Platform: "darwin",
})
require.NoError(t, err)
// Set email with custom source (not idp)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`,
"customuser@example.com", host.ID, fleet.DeviceMappingCustomOverride,
)
return err
})
// Create SCIM user with matching username
scimUser := fleet.ScimUser{
UserName: "customuser@example.com",
GivenName: ptr.String("Test"),
FamilyName: ptr.String("User"),
Active: ptr.Bool(true), //nolint:modernize
}
_, err = ds.CreateScimUser(ctx, &scimUser)
require.NoError(t, err)
var count int
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &count,
`SELECT COUNT(*) FROM host_scim_user WHERE host_id = ?`, host.ID)
})
assert.Equal(t, 0, count)
})
t.Run("association works when both mdm_idp_accounts and host_emails exist", func(t *testing.T) {
defer cleanup()
host, err := ds.NewHost(ctx, &fleet.Host{
UUID: uuid.NewString(),
Platform: "darwin",
})
require.NoError(t, err)
// Set up mdm_idp_accounts (SSO enrollment path)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO mdm_idp_accounts (uuid, username, fullname, email) VALUES (?,?,?,?)`,
"mdm-uuid-1", "scimuser@example.com", "Test User", "scimuser@example.com",
)
return err
})
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO host_mdm_idp_accounts (host_uuid, account_uuid) VALUES (?,?)`,
host.UUID, "mdm-uuid-1",
)
return err
})
// Also set a different email via host_emails with idp source
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`,
"other@example.com", host.ID, fleet.DeviceMappingIDP,
)
return err
})
// Create SCIM user matching the mdm_idp_accounts username
scimUser := fleet.ScimUser{
UserName: "scimuser@example.com",
GivenName: ptr.String("Test"),
FamilyName: ptr.String("User"),
Active: ptr.Bool(true), //nolint:modernize
}
scimUserID, err := ds.CreateScimUser(ctx, &scimUser)
require.NoError(t, err)
// Verify association was created (via mdm_idp_accounts, not host_emails)
var associatedScimUserID uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &associatedScimUserID,
`SELECT scim_user_id FROM host_scim_user WHERE host_id = ?`, host.ID)
})
assert.Equal(t, scimUserID, associatedScimUserID)
})
}
func testGetHostsLockWipeStatusBatch(t *testing.T, ds *Datastore) {
ctx := context.Background()