IDP user update API (#34332)
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
+20
-12
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user