mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
e53daf4971
commit
3a12ba8571
3 changed files with 229 additions and 9 deletions
1
changes/34667-scim-user-host-emails-association
Normal file
1
changes/34667-scim-user-host-emails-association
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Fixed SCIM user not associating with host when IdP username was set before the SCIM user was created.
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue