IDP user update API (#34332)

This commit is contained in:
Tim Lee
2025-10-21 12:02:25 -06:00
committed by GitHub
parent 7fa8793b1e
commit d4004a4f8e
17 changed files with 956 additions and 42 deletions
+1
View File
@@ -0,0 +1 @@
- updated device mapping API to allow an "idp" source to manually set IDP user mappings
@@ -85,7 +85,7 @@ const User = ({
{showUsername && (
<DataSet
title={
<TooltipWrapper tipContent="Username collected from your IdP during automatic enrollment (ADE).">
<TooltipWrapper tipContent="Username collected from your IdP during automatic enrollment (ADE) or added via the Fleet API.">
Username (IdP)
</TooltipWrapper>
}
@@ -17,18 +17,26 @@ const AddEndUserModal = ({ onExit }: IAddEndUserModalProps) => {
<Modal title="Add user" onExit={onExit} className={baseClass}>
<>
<div className={`${baseClass}__content`}>
<p>
Currently, <b>Username (IdP)</b> is only added when the host
automatically enrolls (ADE).{" "}
</p>
<p>
To add username when hosts enroll in the future, enable{" "}
<CustomLink
url={paths.CONTROLS_END_USER_AUTHENTICATION}
text="end user authentication"
/>
.
</p>
<p>Currently, Username (IdP) can be added in the following ways:</p>
<ul style={{ listStyle: "disc", paddingLeft: "20px" }}>
<li style={{ marginBottom: "10px" }}>
<b>Automatically:</b> A username is added when the host
automatically enrolls (ADE), if{" "}
<CustomLink
url={paths.CONTROLS_END_USER_AUTHENTICATION}
text="end user authentication"
/>{" "}
is enabled.
</li>
<li>
<b>Manually:</b> Usernames can be added or updated via the{" "}
<CustomLink
url="https://fleetdm.com/learn-more-about/edit-idp-username"
text="REST API"
newTab
/>
</li>
</ul>
</div>
<div className="modal-cta-wrap">
<Button onClick={onExit}>Done</Button>
+48
View File
@@ -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
+172
View File
@@ -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))
+132
View File
@@ -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)
}
+7
View File
@@ -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)
+1
View File
@@ -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
+4 -7
View File
@@ -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)
+36
View File
@@ -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
+6 -6
View File
@@ -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) {
+2 -5
View File
@@ -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{})
+65 -8
View File
@@ -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)
}
////////////////////////////////////////////////////////////////////////////////
+200 -2
View File
@@ -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)
})
}
+97
View File
@@ -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()
@@ -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")
}
+1 -1
View File
@@ -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
}