From d4004a4f8e2bdc0f5fb09482bb335395bb49dfe5 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Tue, 21 Oct 2025 12:02:25 -0600 Subject: [PATCH] IDP user update API (#34332) --- changes/28070-device-mapping | 1 + .../pages/hosts/details/cards/User/User.tsx | 2 +- .../AddEndUserModal/AddEndUserModal.tsx | 32 +-- server/datastore/mysql/hosts.go | 48 +++++ server/datastore/mysql/hosts_test.go | 172 +++++++++++++++ server/datastore/mysql/scim_test.go | 132 ++++++++++++ server/fleet/datastore.go | 7 + server/fleet/hosts.go | 1 + server/fleet/service.go | 11 +- server/mock/datastore_mock.go | 36 ++++ server/mock/service/service_mock.go | 12 +- server/service/handler.go | 7 +- server/service/hosts.go | 73 ++++++- server/service/hosts_test.go | 202 +++++++++++++++++- server/service/integration_core_test.go | 97 +++++++++ server/service/integration_enterprise_test.go | 163 ++++++++++++++ server/service/orbit.go | 2 +- 17 files changed, 956 insertions(+), 42 deletions(-) create mode 100644 changes/28070-device-mapping diff --git a/changes/28070-device-mapping b/changes/28070-device-mapping new file mode 100644 index 0000000000..5cd6abbc60 --- /dev/null +++ b/changes/28070-device-mapping @@ -0,0 +1 @@ +- updated device mapping API to allow an "idp" source to manually set IDP user mappings \ No newline at end of file diff --git a/frontend/pages/hosts/details/cards/User/User.tsx b/frontend/pages/hosts/details/cards/User/User.tsx index 9ea06f7a85..08e11ee53c 100644 --- a/frontend/pages/hosts/details/cards/User/User.tsx +++ b/frontend/pages/hosts/details/cards/User/User.tsx @@ -85,7 +85,7 @@ const User = ({ {showUsername && ( + Username (IdP) } diff --git a/frontend/pages/hosts/details/cards/User/components/AddEndUserModal/AddEndUserModal.tsx b/frontend/pages/hosts/details/cards/User/components/AddEndUserModal/AddEndUserModal.tsx index 86a2dd0fb7..a196701ccb 100644 --- a/frontend/pages/hosts/details/cards/User/components/AddEndUserModal/AddEndUserModal.tsx +++ b/frontend/pages/hosts/details/cards/User/components/AddEndUserModal/AddEndUserModal.tsx @@ -17,18 +17,26 @@ const AddEndUserModal = ({ onExit }: IAddEndUserModalProps) => { <>
-

- Currently, Username (IdP) is only added when the host - automatically enrolls (ADE).{" "} -

-

- To add username when hosts enroll in the future, enable{" "} - - . -

+

Currently, Username (IdP) can be added in the following ways:

+
    +
  • + Automatically: A username is added when the host + automatically enrolls (ADE), if{" "} + {" "} + is enabled. +
  • +
  • + Manually: Usernames can be added or updated via the{" "} + +
  • +
diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 88df85f33a..1ad99dead0 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -3802,6 +3802,30 @@ func (ds *Datastore) SetOrUpdateCustomHostDeviceMapping(ctx context.Context, hos return ds.listHostDeviceMappingDB(ctx, ds.writer(ctx), hostID) } +func (ds *Datastore) SetOrUpdateIDPHostDeviceMapping(ctx context.Context, hostID uint, email string) error { + const ( + delStmt = `DELETE FROM host_emails WHERE host_id = ? AND source = ?` + insStmt = `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)` + ) + + err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + // First, delete any existing IDP mappings for this host (both sources) + if _, err := tx.ExecContext(ctx, delStmt, hostID, fleet.DeviceMappingIDP); err != nil { + return ctxerr.Wrap(ctx, err, "delete existing IDP device mappings") + } + if _, err := tx.ExecContext(ctx, delStmt, hostID, fleet.DeviceMappingMDMIdpAccounts); err != nil { + return ctxerr.Wrap(ctx, err, "delete existing MDM IDP device mappings") + } + + if _, err := tx.ExecContext(ctx, insStmt, email, hostID, fleet.DeviceMappingIDP); err != nil { + return ctxerr.Wrap(ctx, err, "insert IDP device mapping") + } + + return nil + }) + return err +} + func (ds *Datastore) ReplaceHostBatteries(ctx context.Context, hid uint, mappings []*fleet.HostBattery) error { for _, m := range mappings { if hid != m.HostID { @@ -4319,6 +4343,30 @@ func associateHostWithScimUser(ctx context.Context, tx sqlx.ExtContext, hostID u return triggerResendProfilesForIDPUserAddedToHost(ctx, tx, hostID, scimUserID) } +// deleteHostSCIMUserMapping is a helper function to delete SCIM user mapping for a host +func deleteHostSCIMUserMapping(ctx context.Context, exec sqlx.ExecerContext, hostID uint) error { + _, err := exec.ExecContext(ctx, `DELETE FROM host_scim_user WHERE host_id = ?`, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete host SCIM user mapping") + } + return nil +} + +func (ds *Datastore) SetOrUpdateHostSCIMUserMapping(ctx context.Context, hostID uint, scimUserID uint) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + // Remove any existing SCIM user mapping for this host + if err := deleteHostSCIMUserMapping(ctx, tx, hostID); err != nil { + return err + } + + return associateHostWithScimUser(ctx, tx, hostID, scimUserID) + }) +} + +func (ds *Datastore) DeleteHostSCIMUserMapping(ctx context.Context, hostID uint) error { + return deleteHostSCIMUserMapping(ctx, ds.writer(ctx), hostID) +} + func (ds *Datastore) GetHostEmails(ctx context.Context, hostUUID string, source string) ([]string, error) { stmt := ` SELECT email diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 31d0194aa7..d2e8ceb74a 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -138,6 +138,7 @@ func TestHosts(t *testing.T) { {"HostDeviceMapping", testHostDeviceMapping}, {"ReplaceHostDeviceMapping", testHostsReplaceHostDeviceMapping}, {"CustomHostDeviceMapping", testHostsCustomHostDeviceMapping}, + {"IDPHostDeviceMapping", testIDPHostDeviceMapping}, {"HostMDMAndMunki", testHostMDMAndMunki}, {"AggregatedHostMDMAndMunki", testAggregatedHostMDMAndMunki}, {"MunkiIssuesBatchSize", testMunkiIssuesBatchSize}, @@ -6671,6 +6672,177 @@ func assertHostDeviceMapping(t *testing.T, got, want []*fleet.HostDeviceMapping) } } +func testIDPHostDeviceMapping(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Create test hosts + h1, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("idp-host-1"), + NodeKey: ptr.String("idp-host-1"), + Platform: "linux", + Hostname: "idp-host1", + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + }) + require.NoError(t, err) + + h2, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("idp-host-2"), + NodeKey: ptr.String("idp-host-2"), + Platform: "linux", + Hostname: "idp-host2", + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + }) + require.NoError(t, err) + + // Test 1: Add first IDP mapping for h1 + err = ds.SetOrUpdateIDPHostDeviceMapping(ctx, h1.ID, "user1@idp.com") + require.NoError(t, err) + + // Verify the mapping was created + mappings, err := ds.ListHostDeviceMapping(ctx, h1.ID) + require.NoError(t, err) + assertHostDeviceMapping(t, mappings, []*fleet.HostDeviceMapping{ + {Email: "user1@idp.com", Source: fleet.DeviceMappingIDP}, + }) + + // Test 2: Replace IDP mapping with new user (should replace, not add) + err = ds.SetOrUpdateIDPHostDeviceMapping(ctx, h1.ID, "user2@idp.com") + require.NoError(t, err) + + // Should have only the new mapping (user1 should be replaced by user2) + mappings, err = ds.ListHostDeviceMapping(ctx, h1.ID) + require.NoError(t, err) + assertHostDeviceMapping(t, mappings, []*fleet.HostDeviceMapping{ + {Email: "user2@idp.com", Source: fleet.DeviceMappingIDP}, + }) + + // Test 3: Test idempotent behavior - setting same mapping again should not change anything + err = ds.SetOrUpdateIDPHostDeviceMapping(ctx, h1.ID, "user2@idp.com") + require.NoError(t, err) + + // Should still have only the same mapping + mappings, err = ds.ListHostDeviceMapping(ctx, h1.ID) + require.NoError(t, err) + assertHostDeviceMapping(t, mappings, []*fleet.HostDeviceMapping{ + {Email: "user2@idp.com", Source: fleet.DeviceMappingIDP}, + }) + + // Test 4: Add IDP mapping for different host + err = ds.SetOrUpdateIDPHostDeviceMapping(ctx, h2.ID, "user3@idp.com") + require.NoError(t, err) + + // Verify h2 has its own mapping + mappings, err = ds.ListHostDeviceMapping(ctx, h2.ID) + require.NoError(t, err) + assertHostDeviceMapping(t, mappings, []*fleet.HostDeviceMapping{ + {Email: "user3@idp.com", Source: fleet.DeviceMappingIDP}, + }) + + // Verify h1 still has its current mapping unchanged + mappings, err = ds.ListHostDeviceMapping(ctx, h1.ID) + require.NoError(t, err) + assertHostDeviceMapping(t, mappings, []*fleet.HostDeviceMapping{ + {Email: "user2@idp.com", Source: fleet.DeviceMappingIDP}, + }) + + // Test 5: Test coexistence with custom mappings + customMappings, err := ds.SetOrUpdateCustomHostDeviceMapping(ctx, h1.ID, "custom@example.com", fleet.DeviceMappingCustomOverride) + require.NoError(t, err) + + // Should have both IDP and custom mappings (only one IDP mapping) + require.Len(t, customMappings, 2) + assertHostDeviceMapping(t, customMappings, []*fleet.HostDeviceMapping{ + {Email: "custom@example.com", Source: fleet.DeviceMappingCustomReplacement}, // displayed as "custom" + {Email: "user2@idp.com", Source: fleet.DeviceMappingIDP}, + }) + + // Test 6: Test replacement with various email formats + testEmails := []string{ + "simple@domain.com", + "user.name+tag@long-domain-name.co.uk", + "unicode-üser@domain.org", + "123numbers@domain123.net", + } + + for i, email := range testEmails { + err = ds.SetOrUpdateIDPHostDeviceMapping(ctx, h2.ID, email) + require.NoError(t, err, "Failed to set email: %s", email) + + // Verify only the current email exists (replacement behavior) + mappings, err = ds.ListHostDeviceMapping(ctx, h2.ID) + require.NoError(t, err) + require.Len(t, mappings, 1, "Should have exactly one IDP mapping after email %d", i) + assert.Equal(t, email, mappings[0].Email, "Should have the latest email") + assert.Equal(t, fleet.DeviceMappingIDP, mappings[0].Source, "Should be IDP source") + } + + // Test 7: Test empty email (edge case) + err = ds.SetOrUpdateIDPHostDeviceMapping(ctx, h1.ID, "") + require.NoError(t, err) // Should handle empty email gracefully + + // Verify empty email was added + mappings, err = ds.ListHostDeviceMapping(ctx, h1.ID) + require.NoError(t, err) + found := false + for _, mapping := range mappings { + if mapping.Email == "" && mapping.Source == fleet.DeviceMappingIDP { + found = true + break + } + } + require.True(t, found, "Should find empty email mapping") + + // Test 8: Test replacement of mdm_idp_accounts entries + // First, manually insert an mdm_idp_accounts entry to simulate MDM enrollment + _, err = ds.writer(ctx).ExecContext(ctx, + `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, + "mdm.user@example.com", h1.ID, fleet.DeviceMappingMDMIdpAccounts) + require.NoError(t, err) + + // Verify the mdm_idp_accounts entry exists + mappings, err = ds.ListHostDeviceMapping(ctx, h1.ID) + require.NoError(t, err) + foundMdmIdp := false + for _, mapping := range mappings { + if mapping.Email == "mdm.user@example.com" && mapping.Source == fleet.DeviceMappingMDMIdpAccounts { + foundMdmIdp = true + break + } + } + require.True(t, foundMdmIdp, "Should find MDM IDP mapping") + + // Now set a new IDP mapping - this should replace the mdm_idp_accounts entry + err = ds.SetOrUpdateIDPHostDeviceMapping(ctx, h1.ID, "new.user@example.com") + require.NoError(t, err) + + // Verify only the new IDP mapping exists (mdm_idp_accounts should be gone) + mappings, err = ds.ListHostDeviceMapping(ctx, h1.ID) + require.NoError(t, err) + foundNewIdp := false + foundOldMdmIdp := false + foundEmptyEmail := false + for _, mapping := range mappings { + if mapping.Email == "new.user@example.com" && mapping.Source == fleet.DeviceMappingIDP { + foundNewIdp = true + } + if mapping.Email == "mdm.user@example.com" && mapping.Source == fleet.DeviceMappingMDMIdpAccounts { + foundOldMdmIdp = true + } + if mapping.Email == "" && mapping.Source == fleet.DeviceMappingIDP { + foundEmptyEmail = true + } + } + require.True(t, foundNewIdp, "Should find new IDP mapping") + require.False(t, foundOldMdmIdp, "Should NOT find old MDM IDP mapping (replacement behavior)") + require.False(t, foundEmptyEmail, "Should NOT find empty email mapping (replaced)") +} + func testHostMDMAndMunki(t *testing.T, ds *Datastore) { _, err := ds.GetHostMunkiVersion(context.Background(), 123) require.True(t, fleet.IsNotFound(err)) diff --git a/server/datastore/mysql/scim_test.go b/server/datastore/mysql/scim_test.go index 89d0ee9c1f..fb1ddab3b6 100644 --- a/server/datastore/mysql/scim_test.go +++ b/server/datastore/mysql/scim_test.go @@ -45,6 +45,7 @@ func TestScim(t *testing.T) { {"ScimUsersExist", testScimUsersExist}, {"TriggerResendIdPProfiles", testTriggerResendIdPProfiles}, {"TriggerResendIdPProfilesOnTeam", testTriggerResendIdPProfilesOnTeam}, + {"SetOrUpdateHostSCIMUserMapping", testSetOrUpdateHostSCIMUserMapping}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -2357,3 +2358,134 @@ func testScimUsersExist(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.True(t, exist, "Large batch with only existing users should return true") } + +func testSetOrUpdateHostSCIMUserMapping(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // Create test SCIM users + user1 := fleet.ScimUser{ + UserName: "mapping-test-user1", + ExternalID: ptr.String("ext-mapping-123"), + GivenName: ptr.String("Test"), + FamilyName: ptr.String("User1"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "user1@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + Department: ptr.String("Engineering"), + } + + user2 := fleet.ScimUser{ + UserName: "mapping-test-user2", + ExternalID: ptr.String("ext-mapping-456"), + GivenName: ptr.String("Test"), + FamilyName: ptr.String("User2"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "user2@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + Department: ptr.String("Sales"), + } + + var err error + user1.ID, err = ds.CreateScimUser(ctx, &user1) + require.NoError(t, err) + + user2.ID, err = ds.CreateScimUser(ctx, &user2) + require.NoError(t, err) + + hostID1 := uint(1) + hostID2 := uint(2) + + // Create new host-SCIM user mapping + err = ds.SetOrUpdateHostSCIMUserMapping(ctx, hostID1, user1.ID) + require.NoError(t, err) + + // Verify the mapping was created + var scimUserID uint + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &scimUserID, + "SELECT scim_user_id FROM host_scim_user WHERE host_id = ?", hostID1) + }) + assert.Equal(t, user1.ID, scimUserID) + + // Test 2: Update existing host-SCIM user mapping + err = ds.SetOrUpdateHostSCIMUserMapping(ctx, hostID1, user2.ID) + require.NoError(t, err) + + // Verify the mapping was updated (should now point to user2) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &scimUserID, + "SELECT scim_user_id FROM host_scim_user WHERE host_id = ?", hostID1) + }) + assert.Equal(t, user2.ID, scimUserID) + + // Verify there's only one mapping for this host + 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 = ?", hostID1) + }) + assert.Equal(t, 1, count) + + // Test 3: Create mapping for a different host + err = ds.SetOrUpdateHostSCIMUserMapping(ctx, hostID2, user1.ID) + require.NoError(t, err) + + // Verify both hosts have mappings + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &scimUserID, + "SELECT scim_user_id FROM host_scim_user WHERE host_id = ?", hostID2) + }) + assert.Equal(t, user1.ID, scimUserID) + + // Verify total mappings + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &count, + "SELECT COUNT(*) FROM host_scim_user") + }) + assert.Equal(t, 2, count) + + // Update mapping back to original user for hostID1 + err = ds.SetOrUpdateHostSCIMUserMapping(ctx, hostID1, user1.ID) + require.NoError(t, err) + + // Verify hostID1 now maps to user1 + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &scimUserID, + "SELECT scim_user_id FROM host_scim_user WHERE host_id = ?", hostID1) + }) + assert.Equal(t, user1.ID, scimUserID) + + // Error case - non-existent SCIM user + nonExistentUserID := uint(999999) + err = ds.SetOrUpdateHostSCIMUserMapping(ctx, hostID1, nonExistentUserID) + require.Error(t, err) + assert.Contains(t, err.Error(), "foreign key constraint") + + // Verify that failing update didn't change existing mapping + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &scimUserID, + "SELECT scim_user_id FROM host_scim_user WHERE host_id = ?", hostID1) + }) + assert.Equal(t, user1.ID, scimUserID) // Should still be user1 + + // Verify the mapping can be queried via ScimUserByHostID + result, err := ds.ScimUserByHostID(ctx, hostID1) + require.NoError(t, err) + assert.Equal(t, user1.ID, result.ID) + assert.Equal(t, "mapping-test-user1", result.UserName) + + result, err = ds.ScimUserByHostID(ctx, hostID2) + require.NoError(t, err) + assert.Equal(t, user1.ID, result.ID) + assert.Equal(t, "mapping-test-user1", result.UserName) +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index b77439c389..b5d1968d57 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -345,6 +345,13 @@ type Datastore interface { // SetOrUpdateCustomHostDeviceMapping replaces the custom email address // associated with the host with the provided one. SetOrUpdateCustomHostDeviceMapping(ctx context.Context, hostID uint, email, source string) ([]*HostDeviceMapping, error) + // SetOrUpdateIDPHostDeviceMapping creates or updates an IDP device mapping for a host. + SetOrUpdateIDPHostDeviceMapping(ctx context.Context, hostID uint, email string) error + // SetOrUpdateHostSCIMUserMapping associates a host with a SCIM user. If a + // mapping already exists, it will be updated to the new SCIM user. + SetOrUpdateHostSCIMUserMapping(ctx context.Context, hostID uint, scimUserID uint) error + // DeleteHostSCIMUserMapping removes the association between a host and a SCIM user. + DeleteHostSCIMUserMapping(ctx context.Context, hostID uint) error // ListHostBatteries returns the list of batteries for the given host ID. ListHostBatteries(ctx context.Context, id uint) ([]*HostBattery, error) ListUpcomingHostMaintenanceWindows(ctx context.Context, hid uint) ([]*HostMaintenanceWindow, error) diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index b3142c460c..5e3b70f7e2 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -1109,6 +1109,7 @@ func ExpandPlatform(platform string) []string { const ( DeviceMappingGoogleChromeProfiles = "google_chrome_profiles" DeviceMappingMDMIdpAccounts = "mdm_idp_accounts" + DeviceMappingIDP = "idp" // set by user via PUT /hosts/{id}/device_mapping with source=idp DeviceMappingCustomInstaller = "custom_installer" // set by fleetd via device-authenticated API DeviceMappingCustomOverride = "custom_override" // set by user via user-authenticated API diff --git a/server/fleet/service.go b/server/fleet/service.go index edd8f7beec..a32c707be0 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -387,13 +387,10 @@ type Service interface { // ListHostDeviceMapping returns the list of device-mapping of user's email address // for the host. ListHostDeviceMapping(ctx context.Context, id uint) ([]*HostDeviceMapping, error) - // SetCustomHostDeviceMapping sets the custom email address associated with - // the host, which is either set by the fleetd installer at startup (via a - // device-authenticated API), or manually by the user (via the - // user-authenticated API). - SetCustomHostDeviceMapping(ctx context.Context, hostID uint, email string) ([]*HostDeviceMapping, error) - // HostLiteByIdentifier returns a host and a subset of its fields using an "identifier" string. - // The identifier string will be matched against the Hostname, OsqueryHostID, NodeKey, UUID and HardwareSerial fields. + // SetHostDeviceMapping sets the email address associated with the host. + // The source parameter determines the type: "custom" for manually set + // mappings or DeviceMappingIDP for identity provider mappings. + SetHostDeviceMapping(ctx context.Context, id uint, email, source string) ([]*HostDeviceMapping, error) HostLiteByIdentifier(ctx context.Context, identifier string) (*HostLite, error) // HostLiteByIdentifier returns a host and a subset of its fields from its id. HostLiteByID(ctx context.Context, id uint) (*HostLite, error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 64e4065f26..d4daee57f3 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -255,6 +255,12 @@ type ListHostDeviceMappingFunc func(ctx context.Context, id uint) ([]*fleet.Host type SetOrUpdateCustomHostDeviceMappingFunc func(ctx context.Context, hostID uint, email string, source string) ([]*fleet.HostDeviceMapping, error) +type SetOrUpdateIDPHostDeviceMappingFunc func(ctx context.Context, hostID uint, email string) error + +type SetOrUpdateHostSCIMUserMappingFunc func(ctx context.Context, hostID uint, scimUserID uint) error + +type DeleteHostSCIMUserMappingFunc func(ctx context.Context, hostID uint) error + type ListHostBatteriesFunc func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) type ListUpcomingHostMaintenanceWindowsFunc func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) @@ -1906,6 +1912,15 @@ type DataStore struct { SetOrUpdateCustomHostDeviceMappingFunc SetOrUpdateCustomHostDeviceMappingFunc SetOrUpdateCustomHostDeviceMappingFuncInvoked bool + SetOrUpdateIDPHostDeviceMappingFunc SetOrUpdateIDPHostDeviceMappingFunc + SetOrUpdateIDPHostDeviceMappingFuncInvoked bool + + SetOrUpdateHostSCIMUserMappingFunc SetOrUpdateHostSCIMUserMappingFunc + SetOrUpdateHostSCIMUserMappingFuncInvoked bool + + DeleteHostSCIMUserMappingFunc DeleteHostSCIMUserMappingFunc + DeleteHostSCIMUserMappingFuncInvoked bool + ListHostBatteriesFunc ListHostBatteriesFunc ListHostBatteriesFuncInvoked bool @@ -4674,6 +4689,27 @@ func (s *DataStore) SetOrUpdateCustomHostDeviceMapping(ctx context.Context, host return s.SetOrUpdateCustomHostDeviceMappingFunc(ctx, hostID, email, source) } +func (s *DataStore) SetOrUpdateIDPHostDeviceMapping(ctx context.Context, hostID uint, email string) error { + s.mu.Lock() + s.SetOrUpdateIDPHostDeviceMappingFuncInvoked = true + s.mu.Unlock() + return s.SetOrUpdateIDPHostDeviceMappingFunc(ctx, hostID, email) +} + +func (s *DataStore) SetOrUpdateHostSCIMUserMapping(ctx context.Context, hostID uint, scimUserID uint) error { + s.mu.Lock() + s.SetOrUpdateHostSCIMUserMappingFuncInvoked = true + s.mu.Unlock() + return s.SetOrUpdateHostSCIMUserMappingFunc(ctx, hostID, scimUserID) +} + +func (s *DataStore) DeleteHostSCIMUserMapping(ctx context.Context, hostID uint) error { + s.mu.Lock() + s.DeleteHostSCIMUserMappingFuncInvoked = true + s.mu.Unlock() + return s.DeleteHostSCIMUserMappingFunc(ctx, hostID) +} + func (s *DataStore) ListHostBatteries(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { s.mu.Lock() s.ListHostBatteriesFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 4044d2cd78..0169a94e70 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -233,7 +233,7 @@ type SearchHostsFunc func(ctx context.Context, matchQuery string, queryID *uint, type ListHostDeviceMappingFunc func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) -type SetCustomHostDeviceMappingFunc func(ctx context.Context, hostID uint, email string) ([]*fleet.HostDeviceMapping, error) +type SetHostDeviceMappingFunc func(ctx context.Context, id uint, email, source string) ([]*fleet.HostDeviceMapping, error) type HostLiteByIdentifierFunc func(ctx context.Context, identifier string) (*fleet.HostLite, error) @@ -1160,8 +1160,8 @@ type Service struct { ListHostDeviceMappingFunc ListHostDeviceMappingFunc ListHostDeviceMappingFuncInvoked bool - SetCustomHostDeviceMappingFunc SetCustomHostDeviceMappingFunc - SetCustomHostDeviceMappingFuncInvoked bool + SetHostDeviceMappingFunc SetHostDeviceMappingFunc + SetHostDeviceMappingFuncInvoked bool HostLiteByIdentifierFunc HostLiteByIdentifierFunc HostLiteByIdentifierFuncInvoked bool @@ -2822,11 +2822,11 @@ func (s *Service) ListHostDeviceMapping(ctx context.Context, id uint) ([]*fleet. return s.ListHostDeviceMappingFunc(ctx, id) } -func (s *Service) SetCustomHostDeviceMapping(ctx context.Context, hostID uint, email string) ([]*fleet.HostDeviceMapping, error) { +func (s *Service) SetHostDeviceMapping(ctx context.Context, id uint, email, source string) ([]*fleet.HostDeviceMapping, error) { s.mu.Lock() - s.SetCustomHostDeviceMappingFuncInvoked = true + s.SetHostDeviceMappingFuncInvoked = true s.mu.Unlock() - return s.SetCustomHostDeviceMappingFunc(ctx, hostID, email) + return s.SetHostDeviceMappingFunc(ctx, id, email, source) } func (s *Service) HostLiteByIdentifier(ctx context.Context, identifier string) (*fleet.HostLite, error) { diff --git a/server/service/handler.go b/server/service/handler.go index 80aeff56d9..edf97b0707 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -434,12 +434,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/hosts/transfer", addHostsToTeamEndpoint, addHostsToTeamRequest{}) ue.POST("/api/_version_/fleet/hosts/transfer/filter", addHostsToTeamByFilterEndpoint, addHostsToTeamByFilterRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/refetch", refetchHostEndpoint, refetchHostRequest{}) - // Deprecated: Emails are now included in host details endpoint: /api/_version_/fleet/hosts/{id} + // Deprecated: Device mappings are included in the host details endpoint: /api/_version_/fleet/hosts/{id} ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", listHostDeviceMappingEndpoint, listHostDeviceMappingRequest{}) - // Deprecated: Because the corresponding GET endpoint is deprecated. - // /api/fleet/orbit/device_mapping can be used instead. - // FIXME(sarah): Is this really deprecated? The orbit-authenticated endpoint is not a substitute - // for the user-authenticated endpoint? ue.PUT("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", putHostDeviceMappingEndpoint, putHostDeviceMappingRequest{}) ue.GET("/api/_version_/fleet/hosts/report", hostsReportEndpoint, hostsReportRequest{}) ue.GET("/api/_version_/fleet/os_versions", osVersionsEndpoint, osVersionsRequest{}) @@ -853,6 +849,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC de.WithCustomMiddleware(errorLimiter).GET("/api/_version_/fleet/device/{token}/desktop", getFleetDesktopEndpoint, getFleetDesktopRequest{}) de.WithCustomMiddleware(errorLimiter).HEAD("/api/_version_/fleet/device/{token}/ping", devicePingEndpoint, deviceAuthPingRequest{}) de.WithCustomMiddleware(errorLimiter).POST("/api/_version_/fleet/device/{token}/refetch", refetchDeviceHostEndpoint, refetchDeviceHostRequest{}) + // Deprecated: Device mapping data is now included in host details endpoint de.WithCustomMiddleware(errorLimiter).GET("/api/_version_/fleet/device/{token}/device_mapping", listDeviceHostDeviceMappingEndpoint, listDeviceHostDeviceMappingRequest{}) de.WithCustomMiddleware(errorLimiter).GET("/api/_version_/fleet/device/{token}/macadmins", getDeviceMacadminsDataEndpoint, getDeviceMacadminsDataRequest{}) de.WithCustomMiddleware(errorLimiter).GET("/api/_version_/fleet/device/{token}/policies", listDevicePoliciesEndpoint, listDevicePoliciesRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 114182e938..004d250acf 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "database/sql" "encoding/csv" "encoding/json" "errors" @@ -1436,11 +1437,13 @@ func getEndUsers(ctx context.Context, ds fleet.Datastore, hostID uint) ([]fleet. endUser := fleet.HostEndUser{} for _, email := range deviceMapping { switch { - case email.Source == fleet.DeviceMappingMDMIdpAccounts && len(endUsers) == 0: + case (email.Source == fleet.DeviceMappingMDMIdpAccounts || email.Source == fleet.DeviceMappingIDP) && len(endUsers) == 0: // If SCIM data is missing, we still populate IdpUserName if present. + // For DeviceMappingIDP source, this is the user-provided IDP username. // Note: Username and email is the same thing here until we split them with https://github.com/fleetdm/fleet/issues/27952 endUser.IdpUserName = email.Email - case email.Source != fleet.DeviceMappingMDMIdpAccounts: + case email.Source != fleet.DeviceMappingMDMIdpAccounts && email.Source != fleet.DeviceMappingIDP: + // Only add to OtherEmails if it's not an IDP source endUser.OtherEmails = append(endUser.OtherEmails, *email) } } @@ -1631,8 +1634,9 @@ func (svc *Service) ListHostDeviceMapping(ctx context.Context, id uint) ([]*flee //////////////////////////////////////////////////////////////////////////////// type putHostDeviceMappingRequest struct { - ID uint `url:"id"` - Email string `json:"email"` + ID uint `url:"id"` + Email string `json:"email"` + Source string `json:"source,omitempty"` } type putHostDeviceMappingResponse struct { @@ -1647,14 +1651,18 @@ func (r putHostDeviceMappingResponse) Error() error { return r.Err } // Deprecated: Because the corresponding GET endpoint is deprecated. func putHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*putHostDeviceMappingRequest) - dms, err := svc.SetCustomHostDeviceMapping(ctx, req.ID, req.Email) + + var dms []*fleet.HostDeviceMapping + var err error + + dms, err = svc.SetHostDeviceMapping(ctx, req.ID, req.Email, req.Source) if err != nil { return putHostDeviceMappingResponse{Err: err}, nil } return putHostDeviceMappingResponse{HostID: req.ID, DeviceMapping: dms}, nil } -func (svc *Service) SetCustomHostDeviceMapping(ctx context.Context, hostID uint, email string) ([]*fleet.HostDeviceMapping, error) { +func (svc *Service) SetHostDeviceMapping(ctx context.Context, hostID uint, email string, source string) ([]*fleet.HostDeviceMapping, error) { isInstallerSource := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnOrbitToken) if !isInstallerSource { if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { @@ -1672,11 +1680,60 @@ func (svc *Service) SetCustomHostDeviceMapping(ctx context.Context, hostID uint, } } - source := fleet.DeviceMappingCustomOverride + if source == "" { + source = "custom" + } + if isInstallerSource { source = fleet.DeviceMappingCustomInstaller + } else if source == "custom" { + source = fleet.DeviceMappingCustomOverride + } + + switch source { + case fleet.DeviceMappingCustomOverride, fleet.DeviceMappingCustomInstaller: + return svc.ds.SetOrUpdateCustomHostDeviceMapping(ctx, hostID, email, source) + case fleet.DeviceMappingIDP: + // Check if this is a premium-only feature + lic, err := svc.License(ctx) + if err != nil { + return nil, err + } + if lic == nil || !lic.IsPremium() { + return nil, fleet.ErrMissingLicense + } + + // Store the IDP username for display (accept any value) + // This will appear in the host details API under the idp_username field + if err := svc.ds.SetOrUpdateIDPHostDeviceMapping(ctx, hostID, email); err != nil { + return nil, ctxerr.Wrap(ctx, err, "set IDP device mapping") + } + + // Check if the user is a valid SCIM user to manage the join table + scimUser, err := svc.ds.ScimUserByUserNameOrEmail(ctx, email, email) + if err != nil && !fleet.IsNotFound(err) && err != sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, err, "find SCIM user by username or email") + } + if err == nil && scimUser != nil { + // User exists in SCIM, create/update the mapping for additional attributes + // This enables fields like idp_full_name, idp_groups, etc. to appear in the API + if err := svc.ds.SetOrUpdateHostSCIMUserMapping(ctx, hostID, scimUser.ID); err != nil { + // Log the error but don't fail the request since the main IDP mapping succeeded + level.Debug(svc.logger).Log("msg", "failed to set SCIM user mapping", "err", err) + } + } else { + // User doesn't exist in SCIM, remove any existing SCIM mapping for this host + if err := svc.ds.DeleteHostSCIMUserMapping(ctx, hostID); err != nil && !fleet.IsNotFound(err) { + // Log the error but don't fail the request + level.Debug(svc.logger).Log("msg", "failed to delete SCIM user mapping", "err", err) + } + } + + // Return the updated device mappings including the IDP mapping + return svc.ds.ListHostDeviceMapping(ctx, hostID) + default: + return nil, fleet.NewInvalidArgumentError("source", fmt.Sprintf("must be 'custom' or '%s'", fleet.DeviceMappingIDP)) } - return svc.ds.SetOrUpdateCustomHostDeviceMapping(ctx, hostID, email, source) } //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index afbb907c52..7beaa45a25 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -3,6 +3,7 @@ package service import ( "context" "crypto/x509" + "database/sql" "encoding/base64" "errors" "fmt" @@ -14,7 +15,9 @@ import ( "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" + authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/capabilities" + hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql" @@ -971,10 +974,10 @@ func TestHostAuth(t *testing.T) { err = svc.RefetchHost(ctx, 1) checkAuthErr(t, tt.shouldFailTeamRead, err) - _, err = svc.SetCustomHostDeviceMapping(ctx, 1, "a@b.c") + _, err = svc.SetHostDeviceMapping(ctx, 1, "a@b.c", "custom") checkAuthErr(t, tt.shouldFailTeamWrite, err) - _, err = svc.SetCustomHostDeviceMapping(ctx, 2, "a@b.c") + _, err = svc.SetHostDeviceMapping(ctx, 2, "a@b.c", "custom") checkAuthErr(t, tt.shouldFailGlobalWrite, err) _, _, err = svc.ListHostSoftware(ctx, 1, fleet.HostSoftwareTitleListOptions{}) @@ -2790,3 +2793,198 @@ func TestGetHostDetailsExcludeSoftwareFlag(t *testing.T) { assert.True(t, ds.LoadHostSoftwareFuncInvoked, "LoadHostSoftwareFunc should have been called") }) } + +func TestSetHostDeviceMapping(t *testing.T) { + t.Run("custom source success", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ID: 1}, nil + } + ds.SetOrUpdateCustomHostDeviceMappingFunc = func(ctx context.Context, hostID uint, email, source string) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{{HostID: hostID, Email: email, Source: source}}, nil + } + + userCtx := test.UserContext(ctx, test.UserAdmin) + result, err := svc.SetHostDeviceMapping(userCtx, 1, "user@example.com", "custom") + require.NoError(t, err) + require.True(t, ds.SetOrUpdateCustomHostDeviceMappingFuncInvoked) + require.NotNil(t, result) + require.Len(t, result, 1) + assert.Equal(t, uint(1), result[0].HostID) + assert.Equal(t, "user@example.com", result[0].Email) + }) + + t.Run("empty source defaults to custom", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ID: 1}, nil + } + ds.SetOrUpdateCustomHostDeviceMappingFunc = func(ctx context.Context, hostID uint, email, source string) ([]*fleet.HostDeviceMapping, error) { + require.Equal(t, fleet.DeviceMappingCustomOverride, source) // Should store as custom_override for user-authenticated calls + return []*fleet.HostDeviceMapping{{HostID: hostID, Email: email, Source: fleet.DeviceMappingCustomReplacement}}, nil // But return as "custom" for display + } + + userCtx := test.UserContext(ctx, test.UserAdmin) + result, err := svc.SetHostDeviceMapping(userCtx, 1, "user@example.com", "") + require.NoError(t, err) + require.True(t, ds.SetOrUpdateCustomHostDeviceMappingFuncInvoked) + require.NotNil(t, result) + }) + + t.Run("IDP source success with valid SCIM user", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ID: 1}, nil + } + ds.ScimUserByUserNameOrEmailFunc = func(ctx context.Context, userName, email string) (*fleet.ScimUser, error) { + return &fleet.ScimUser{ID: 1, UserName: "user@example.com"}, nil + } + ds.SetOrUpdateHostSCIMUserMappingFunc = func(ctx context.Context, hostID uint, scimUserID uint) error { + return nil + } + ds.SetOrUpdateIDPHostDeviceMappingFunc = func(ctx context.Context, hostID uint, email string) error { + return nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{{HostID: hostID, Email: "user@example.com", Source: fleet.DeviceMappingIDP}}, nil + } + + userCtx := test.UserContext(ctx, test.UserAdmin) + result, err := svc.SetHostDeviceMapping(userCtx, 1, "user@example.com", fleet.DeviceMappingIDP) + require.NoError(t, err) + require.True(t, ds.SetOrUpdateIDPHostDeviceMappingFuncInvoked) + require.True(t, ds.SetOrUpdateHostSCIMUserMappingFuncInvoked) // Should be called since SCIM user exists + require.NotNil(t, result) + require.Len(t, result, 1) + assert.Equal(t, uint(1), result[0].HostID) + assert.Equal(t, "user@example.com", result[0].Email) + assert.Equal(t, fleet.DeviceMappingIDP, result[0].Source) + }) + + t.Run("IDP source success with any username when SCIM user not found", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ID: 1}, nil + } + ds.ScimUserByUserNameOrEmailFunc = func(ctx context.Context, userName, email string) (*fleet.ScimUser, error) { + return nil, sql.ErrNoRows // SCIM user not found + } + ds.SetOrUpdateIDPHostDeviceMappingFunc = func(ctx context.Context, hostID uint, email string) error { + return nil + } + ds.DeleteHostSCIMUserMappingFunc = func(ctx context.Context, hostID uint) error { + return nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{{HostID: hostID, Email: "any@username.com", Source: fleet.DeviceMappingIDP}}, nil + } + + userCtx := test.UserContext(ctx, test.UserAdmin) + result, err := svc.SetHostDeviceMapping(userCtx, 1, "any@username.com", fleet.DeviceMappingIDP) + require.NoError(t, err) + require.True(t, ds.SetOrUpdateIDPHostDeviceMappingFuncInvoked) + require.False(t, ds.SetOrUpdateHostSCIMUserMappingFuncInvoked) // Should NOT be called since SCIM user doesn't exist + require.True(t, ds.DeleteHostSCIMUserMappingFuncInvoked) // Should be called to remove any existing SCIM mapping + require.NotNil(t, result) + require.Len(t, result, 1) + assert.Equal(t, uint(1), result[0].HostID) + assert.Equal(t, "any@username.com", result[0].Email) + assert.Equal(t, fleet.DeviceMappingIDP, result[0].Source) + }) + + t.Run("IDP source fails without premium license", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierFree}}) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ID: 1}, nil + } + + userCtx := test.UserContext(ctx, test.UserAdmin) + _, err := svc.SetHostDeviceMapping(userCtx, 1, "user@example.com", fleet.DeviceMappingIDP) + require.Error(t, err) + assert.Equal(t, fleet.ErrMissingLicense, err) + }) + + t.Run("invalid source returns validation error", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ID: 1}, nil + } + + userCtx := test.UserContext(ctx, test.UserAdmin) + _, err := svc.SetHostDeviceMapping(userCtx, 1, "user@example.com", "invalid") + require.Error(t, err) + require.Contains(t, err.Error(), "must be 'custom' or 'idp'") + require.True(t, ds.HostLiteFuncInvoked) // Authorization was checked + }) + + t.Run("authorization failure for observer user", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ID: 1}, nil + } + + // Use observer user who shouldn't have write permission + user := &fleet.User{ + ID: 42, + Email: "observer@example.com", + GlobalRole: ptr.String(fleet.RoleObserver), + } + userCtx := viewer.NewContext(ctx, viewer.Viewer{User: user}) + + _, err := svc.SetHostDeviceMapping(userCtx, 1, "user@example.com", "custom") + require.Error(t, err) + require.Contains(t, err.Error(), "forbidden") + }) + + t.Run("host not found returns error", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return nil, sql.ErrNoRows + } + + userCtx := test.UserContext(ctx, test.UserAdmin) + _, err := svc.SetHostDeviceMapping(userCtx, 999, "user@example.com", "custom") + require.Error(t, err) + assert.Contains(t, err.Error(), "get host") + }) + + t.Run("orbit installer source override", func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + // Create orbit context that simulates installer authentication + authzCtx := &authzctx.AuthorizationContext{} + orbitCtx := authzctx.NewContext(ctx, authzCtx) + orbitCtx = hostctx.NewContext(orbitCtx, &fleet.Host{ID: 1}) + if ac, ok := authzctx.FromContext(orbitCtx); ok { + ac.SetAuthnMethod(authzctx.AuthnOrbitToken) + } + + ds.SetOrUpdateCustomHostDeviceMappingFunc = func(ctx context.Context, hostID uint, email, source string) ([]*fleet.HostDeviceMapping, error) { + // Should use installer source for orbit token + require.Equal(t, fleet.DeviceMappingCustomInstaller, source) + return []*fleet.HostDeviceMapping{{HostID: hostID, Email: email, Source: source}}, nil + } + + result, err := svc.SetHostDeviceMapping(orbitCtx, 1, "user@example.com", "custom") + require.NoError(t, err) + require.True(t, ds.SetOrUpdateCustomHostDeviceMappingFuncInvoked) + require.NotNil(t, result) + }) +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 0267ef18c3..0cbb069a87 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -3890,6 +3890,103 @@ func (s *integrationTestSuite) TestHostDeviceMapping() { require.Len(t, listHosts.Hosts, 0) } +func (s *integrationTestSuite) TestHostDeviceMappingIDP() { + t := s.T() + hosts := s.createHosts(t) + host := hosts[0] + + // Test 1: Test invalid source parameter validation + var putResp putHostDeviceMappingResponse + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", host.ID), + putHostDeviceMappingRequest{Email: "test@example.com", Source: "invalid"}, + http.StatusUnprocessableEntity, &putResp) + + // Test 2: Test endpoint routing - empty source defaults to custom (should work without premium) + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", host.ID), + putHostDeviceMappingRequest{Email: "default@example.com"}, + http.StatusOK, &putResp) + + // Find the new mapping in the response + var foundCustom bool + for _, mapping := range putResp.DeviceMapping { + if mapping.Email == "default@example.com" { + assert.Equal(t, fleet.DeviceMappingCustomReplacement, mapping.Source) + foundCustom = true + break + } + } + assert.True(t, foundCustom, "Should find the default custom mapping") + + // Test 3: Explicit custom source should work without premium + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", host.ID), + putHostDeviceMappingRequest{Email: "custom@example.com", Source: "custom"}, + http.StatusOK, &putResp) + + // Find the custom mapping in the response + var foundExplicitCustom bool + for _, mapping := range putResp.DeviceMapping { + if mapping.Email == "custom@example.com" { + assert.Equal(t, fleet.DeviceMappingCustomReplacement, mapping.Source) + foundExplicitCustom = true + break + } + } + assert.True(t, foundExplicitCustom, "Should find the explicit custom mapping") + + // Test 4: Verify custom mappings appear in host details via getHostEndpoint + var hostResp getHostResponse + s.DoJSON("GET", "/api/v1/fleet/hosts/identifier/"+host.UUID, nil, http.StatusOK, &hostResp) + + // Should have at least 1 end user with device mappings + require.GreaterOrEqual(t, len(hostResp.Host.EndUsers), 1) + + // Find mappings by checking OtherEmails in EndUsers + foundMappings := make(map[string]string) // email -> source + for _, endUser := range hostResp.Host.EndUsers { + for _, otherEmail := range endUser.OtherEmails { + foundMappings[otherEmail.Email] = otherEmail.Source + } + } + + // Verify that we have at least one custom mapping + // (the exact emails present may vary based on how the system consolidates mappings) + hasCustomMapping := false + for email, source := range foundMappings { + if source == fleet.DeviceMappingCustomReplacement { + hasCustomMapping = true + t.Logf("Found custom mapping: %s -> %s", email, source) + } + } + assert.True(t, hasCustomMapping, "Should find at least one custom mapping in host details") + + // Verify that if we find specific mappings, they have the correct source + if source, found := foundMappings["default@example.com"]; found { + assert.Equal(t, fleet.DeviceMappingCustomReplacement, source) + } + if source, found := foundMappings["custom@example.com"]; found { + assert.Equal(t, fleet.DeviceMappingCustomReplacement, source) + } + + // Also test the ID-based endpoint + hostResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) + + // Verify mappings are consistent between identifier and ID endpoints + foundMappingsById := make(map[string]string) // email -> source + for _, endUser := range hostResp.Host.EndUsers { + for _, otherEmail := range endUser.OtherEmails { + foundMappingsById[otherEmail.Email] = otherEmail.Source + } + } + assert.Equal(t, foundMappings, foundMappingsById, "Host details should be consistent between identifier and ID endpoints") + + // Test 5: IDP source validation (requires Fleet Premium) + // This test verifies that the endpoint rejects IDP requests appropriately on free tier + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", host.ID), + putHostDeviceMappingRequest{Email: "idp.user1@example.com", Source: "idp"}, + http.StatusPaymentRequired, &putResp) +} + func (s *integrationTestSuite) TestListHostsDeviceMappingSize() { t := s.T() ctx := context.Background() diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 4e7e6645e4..d75092c6bc 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -21296,3 +21296,166 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftwareW require.Equal(t, "Hello world", orbitRes.Results.Software[1].Name) require.EqualValues(t, "success", orbitRes.Results.Software[1].Status) } + +func (s *integrationEnterpriseTestSuite) TestHostDeviceMappingIDP() { + t := s.T() + ctx := context.Background() + + hosts := s.createHosts(t, "darwin") + host := hosts[0] + + // Create a SCIM user for testing + scimUser := &fleet.ScimUser{ + UserName: "test.user", + Emails: []fleet.ScimUserEmail{ + { + Email: "scim.user@example.com", + Primary: ptr.Bool(true), + }, + }, + } + + createdUserID, err := s.ds.CreateScimUser(ctx, scimUser) + require.NoError(t, err) + defer func() { _ = s.ds.DeleteScimUser(ctx, createdUserID) }() + + // Test IDP device mapping with premium license and valid SCIM user + var putResp putHostDeviceMappingResponse + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", host.ID), + putHostDeviceMappingRequest{Email: "scim.user@example.com", Source: "idp"}, + http.StatusOK, &putResp) + + // Verify the IDP mapping was created and returned in device mappings + require.Equal(t, "scim.user@example.com", putResp.DeviceMapping[0].Email) + require.Equal(t, fleet.DeviceMappingIDP, putResp.DeviceMapping[0].Source) + + // Verify the IDP mapping appears in the host's device mappings via getHostDeviceMapping + var getResp listHostDeviceMappingResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", host.ID), + nil, http.StatusOK, &getResp) + require.Len(t, getResp.DeviceMapping, 1) + require.Equal(t, "scim.user@example.com", getResp.DeviceMapping[0].Email) + require.Equal(t, fleet.DeviceMappingIDP, getResp.DeviceMapping[0].Source) + + // Test that IDP mapping appears correctly in host details via getHostEndpoint + var hostResp getHostResponse + s.DoJSON("GET", "/api/v1/fleet/hosts/identifier/"+host.UUID, nil, http.StatusOK, &hostResp) + + // Find the IDP information in end_users field + foundIdpInDetails := false + for _, endUser := range hostResp.Host.EndUsers { + // IDP users should have IdpUserName populated + if endUser.IdpUserName == "test.user" { + foundIdpInDetails = true + // Verify other IDP fields if they exist + assert.NotEmpty(t, endUser.IdpUserName, "IDP EndUser should have IdpUserName") + } + } + assert.True(t, foundIdpInDetails, "Should find IDP user in host end_users") + + // Verify consistency between identifier and ID-based endpoints + hostResp2 := getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp2) + + // Find the same IDP information in the ID-based endpoint + foundIdpInDetailsById := false + for _, endUser := range hostResp2.Host.EndUsers { + if endUser.IdpUserName == "test.user" { + foundIdpInDetailsById = true + } + } + assert.True(t, foundIdpInDetailsById, "Should find IDP user in ID-based host endpoint") + + // Test that IDP accepts any username, even if not a SCIM user + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", host.ID), + putHostDeviceMappingRequest{Email: "any.username@example.com", Source: "idp"}, + http.StatusOK, &putResp) + + // Test that custom mappings still work alongside IDP + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", host.ID), + putHostDeviceMappingRequest{Email: "custom.user@example.com", Source: "custom"}, + http.StatusOK, &putResp) + + // Verify current mappings: custom and latest IDP (replacement behavior) + foundCustom := false + foundCurrentIdp := false + foundOldIdp := false + for _, mapping := range putResp.DeviceMapping { + if mapping.Email == "custom.user@example.com" && mapping.Source == fleet.DeviceMappingCustomReplacement { + foundCustom = true + } + if mapping.Email == "any.username@example.com" && mapping.Source == fleet.DeviceMappingIDP { + foundCurrentIdp = true + } + if mapping.Email == "scim.user@example.com" && mapping.Source == fleet.DeviceMappingIDP { + foundOldIdp = true + } + } + assert.True(t, foundCustom, "Should find custom mapping in device_mapping") + assert.True(t, foundCurrentIdp, "Should find current IDP mapping (any.username) in device_mapping") + assert.False(t, foundOldIdp, "Should NOT find old IDP mapping (scim.user) - replacement behavior") + + // Verify IDP appears in idp_username and custom appears in other_emails + var finalHostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), nil, http.StatusOK, &finalHostResp) + + require.Len(t, finalHostResp.Host.EndUsers, 1, "Should have exactly one end user") + endUser := finalHostResp.Host.EndUsers[0] + + // Current IDP user (non-SCIM) should appear in idp_username field + assert.Equal(t, "any.username@example.com", endUser.IdpUserName, "Current IDP user should be in idp_username") + assert.Empty(t, endUser.IdpFullName, "Non-SCIM user should not have full name") + + // Verify custom mapping appears in other_emails + foundCustomInOtherEmails := false + foundIdpInOtherEmails := false + for _, otherEmail := range endUser.OtherEmails { + if otherEmail.Email == "custom.user@example.com" { + foundCustomInOtherEmails = true + } + // Verify IDP emails do NOT appear in other_emails + if otherEmail.Email == "any.username@example.com" { + foundIdpInOtherEmails = true + } + } + assert.True(t, foundCustomInOtherEmails, "Custom mapping should appear in other_emails") + assert.False(t, foundIdpInOtherEmails, "IDP mappings should NOT appear in other_emails") + + // Test with non-SCIM IDP user only (should replace previous IDP mapping) + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", host.ID), + putHostDeviceMappingRequest{Email: "nonscim@example.com", Source: "idp"}, + http.StatusOK, &putResp) + + // Verify device mapping API shows only the new IDP mapping (replacement behavior) + foundNonScimIdp := false + foundPreviousIdp := false + for _, mapping := range putResp.DeviceMapping { + if mapping.Email == "nonscim@example.com" && mapping.Source == fleet.DeviceMappingIDP { + foundNonScimIdp = true + } + if (mapping.Email == "scim.user@example.com" || mapping.Email == "any.username@example.com") && mapping.Source == fleet.DeviceMappingIDP { + foundPreviousIdp = true + } + } + assert.True(t, foundNonScimIdp, "Should find new IDP mapping in device_mapping") + assert.False(t, foundPreviousIdp, "Should NOT find old IDP mappings (replacement behavior)") + + var nonScimHostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), nil, http.StatusOK, &nonScimHostResp) + + require.Len(t, nonScimHostResp.Host.EndUsers, 1, "Should have exactly one end user") + endUser = nonScimHostResp.Host.EndUsers[0] + + // Non-SCIM IDP user should appear in idp_username field (no SCIM data) + assert.Equal(t, "nonscim@example.com", endUser.IdpUserName, "Non-SCIM IDP user should be in idp_username") + assert.Empty(t, endUser.IdpFullName, "Non-SCIM user should not have full name") + + // Verify IDP email doesn't appear in other_emails + foundNonScimInOtherEmails := false + for _, otherEmail := range endUser.OtherEmails { + if otherEmail.Email == "nonscim@example.com" { + foundNonScimInOtherEmails = true + } + } + assert.False(t, foundNonScimInOtherEmails, "Non-SCIM IDP should NOT appear in other_emails") +} diff --git a/server/service/orbit.go b/server/service/orbit.go index cc270ca8ab..e1b1ec4c46 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -1018,7 +1018,7 @@ func putOrbitDeviceMappingEndpoint(ctx context.Context, request interface{}, svc return orbitPutDeviceMappingResponse{Err: err}, nil } - _, err := svc.SetCustomHostDeviceMapping(ctx, host.ID, req.Email) + _, err := svc.SetHostDeviceMapping(ctx, host.ID, req.Email, fleet.DeviceMappingCustomReplacement) return orbitPutDeviceMappingResponse{Err: err}, nil }