From 235d2cf2dc6cf91a46a98a42ad74a2beeebf92ac Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Thu, 21 Dec 2023 12:21:39 -0500 Subject: [PATCH] Custom email device-mapping: implement the REST API changes (#15748) --- .../issue-15057-custom-email-device-mapping | 1 + server/datastore/mysql/hosts.go | 93 +++++++++++++-- server/datastore/mysql/hosts_test.go | 97 ++++++++++++++++ server/datastore/mysql/labels.go | 8 +- server/fleet/datastore.go | 3 + server/fleet/hosts.go | 12 ++ server/fleet/service.go | 5 + server/mock/datastore_mock.go | 12 ++ server/service/endpoint_utils.go | 4 + server/service/handler.go | 2 + server/service/hosts.go | 51 ++++++++ server/service/hosts_test.go | 25 ++++ server/service/integration_core_test.go | 109 +++++++++++++----- server/service/integration_desktop_test.go | 15 ++- server/service/integration_mdm_test.go | 6 +- server/service/orbit.go | 38 ++++++ server/service/osquery_utils/queries.go | 4 +- 17 files changed, 430 insertions(+), 55 deletions(-) create mode 100644 changes/issue-15057-custom-email-device-mapping diff --git a/changes/issue-15057-custom-email-device-mapping b/changes/issue-15057-custom-email-device-mapping new file mode 100644 index 0000000000..71572f6659 --- /dev/null +++ b/changes/issue-15057-custom-email-device-mapping @@ -0,0 +1 @@ +* Added the `PUT /api/fleet/orbit/device_mapping` and `PUT /api/v1/fleet/hosts/{id}/device_mapping` endpoints (orbit-authenticated and user-authenticated) to set or replace the custom email address associated with a host. diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index a84055ff77..6985364c35 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -425,7 +425,7 @@ func loadHostScheduledQueryStatsDB(ctx context.Context, db sqlx.QueryerContext, AND (q.team_id IS NULL OR q.team_id = ?) OR EXISTS ( SELECT 1 FROM query_results - WHERE query_results.query_id = q.id + WHERE query_results.query_id = q.id AND query_results.host_id = ? ) GROUP BY q.id @@ -952,14 +952,14 @@ func (ds *Datastore) applyHostFilters( ) (string, []interface{}, error) { opt.OrderKey = defaultHostColumnTableAlias(opt.OrderKey) - deviceMappingJoin := `LEFT JOIN ( + deviceMappingJoin := fmt.Sprintf(`LEFT JOIN ( SELECT host_id, - CONCAT('[', GROUP_CONCAT(JSON_OBJECT('email', email, 'source', source)), ']') AS device_mapping + CONCAT('[', GROUP_CONCAT(JSON_OBJECT('email', email, 'source', %s)), ']') AS device_mapping FROM host_emails GROUP BY - host_id) dm ON dm.host_id = h.id` + host_id) dm ON dm.host_id = h.id`, deviceMappingTranslateSourceColumn("")) if !opt.DeviceMapping { deviceMappingJoin = "" } @@ -2856,21 +2856,35 @@ func (ds *Datastore) CleanupExpiredHosts(ctx context.Context) ([]uint, error) { } func (ds *Datastore) ListHostDeviceMapping(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - stmt := ` + return ds.listHostDeviceMappingDB(ctx, ds.reader(ctx), id) +} + +func deviceMappingTranslateSourceColumn(hostEmailsTableAlias string) string { + if hostEmailsTableAlias != "" && !strings.HasSuffix(hostEmailsTableAlias, ".") { + hostEmailsTableAlias += "." + } + // this means: + // if source starts with "custom_" then return "custom" else return source as-is + return fmt.Sprintf(` CASE WHEN %ssource LIKE '%s%%' THEN '%s' ELSE %[1]ssource END `, + hostEmailsTableAlias, fleet.DeviceMappingCustomPrefix, fleet.DeviceMappingCustomReplacement) +} + +func (ds *Datastore) listHostDeviceMappingDB(ctx context.Context, q sqlx.QueryerContext, hostID uint) ([]*fleet.HostDeviceMapping, error) { + stmt := fmt.Sprintf(` SELECT id, host_id, email, - source + %s as source FROM host_emails WHERE host_id = ? ORDER BY - email, source` + email, source`, deviceMappingTranslateSourceColumn("")) var mappings []*fleet.HostDeviceMapping - err := sqlx.SelectContext(ctx, ds.reader(ctx), &mappings, stmt, id) + err := sqlx.SelectContext(ctx, q, &mappings, stmt, hostID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "select host emails by host id") } @@ -2963,6 +2977,65 @@ func (ds *Datastore) ReplaceHostDeviceMapping(ctx context.Context, hid uint, map }) } +func (ds *Datastore) SetOrUpdateCustomHostDeviceMapping(ctx context.Context, hostID uint, email, source string) ([]*fleet.HostDeviceMapping, error) { + const ( + delStmt = `DELETE FROM host_emails WHERE host_id = ? AND source = ?` + updStmt = `UPDATE host_emails SET email = ? WHERE host_id = ? AND source = ?` + insStmt = `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)` + // for the custom_installer source, we insert it only if there is no + // existing custom_override source for that host. + insInstallerStmt = `INSERT INTO host_emails (email, host_id, source) + ( + SELECT ?, ?, ? + FROM DUAL + WHERE + NOT EXISTS ( + SELECT 1 FROM host_emails WHERE host_id = ? AND source = ? + ) + )` + ) + + err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if source == fleet.DeviceMappingCustomOverride { + // must delete any existing custom installer + if _, err := tx.ExecContext(ctx, delStmt, hostID, fleet.DeviceMappingCustomInstaller); err != nil { + return ctxerr.Wrap(ctx, err, "delete custom installer device mapping") + } + } + + // first attempt an update, and if it updates nothing proceed with an + // insert (this is the same logic as our insertOrUpdate helper function, + // but it is not used directly because we may need a more complex insert + // statement). We cannot use ON DUPLICATE KEY UPDATE because there is no + // unique constraint on that table. + res, err := tx.ExecContext(ctx, updStmt, email, hostID, source) + if err != nil { + return ctxerr.Wrap(ctx, err, "update custom device mapping") + } + + rowsAffected, _ := res.RowsAffected() // cannot fail for mysql driver + if rowsAffected == 0 { + // use an insert, no existing email for that source + stmt := insStmt + params := []any{email, hostID, source} + if source == fleet.DeviceMappingCustomInstaller { + // custom installer can only insert if there's no custom override + stmt = insInstallerStmt + params = append(params, hostID, fleet.DeviceMappingCustomOverride) + } + if _, err := tx.ExecContext(ctx, stmt, params...); err != nil { + return ctxerr.Wrap(ctx, err, "insert custom device mapping") + } + } + return nil + }) + if err != nil { + return nil, err + } + + return ds.listHostDeviceMappingDB(ctx, ds.writer(ctx), hostID) +} + func (ds *Datastore) ReplaceHostBatteries(ctx context.Context, hid uint, mappings []*fleet.HostBattery) error { for _, m := range mappings { if hid != m.HostID { @@ -3347,8 +3420,6 @@ func (ds *Datastore) SetOrUpdateMDMData( ) } -const hostEmailsSourceMdmIdpAccounts = "mdm_idp_accounts" - func (ds *Datastore) SetOrUpdateHostEmailsFromMdmIdpAccounts( ctx context.Context, hostID uint, @@ -3367,7 +3438,7 @@ func (ds *Datastore) SetOrUpdateHostEmailsFromMdmIdpAccounts( ctx, `UPDATE host_emails SET email = ? WHERE host_id = ? AND source = ?`, `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, - email, hostID, hostEmailsSourceMdmIdpAccounts, + email, hostID, fleet.DeviceMappingMDMIdpAccounts, ) } diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 422b1e3ce9..565781d70c 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -124,6 +124,7 @@ func TestHosts(t *testing.T) { {"HostsNoSeenTime", testHostsNoSeenTime}, {"HostDeviceMapping", testHostDeviceMapping}, {"ReplaceHostDeviceMapping", testHostsReplaceHostDeviceMapping}, + {"CustomHostDeviceMapping", testHostsCustomHostDeviceMapping}, {"HostMDMAndMunki", testHostMDMAndMunki}, {"AggregatedHostMDMAndMunki", testAggregatedHostMDMAndMunki}, {"MunkiIssuesBatchSize", testMunkiIssuesBatchSize}, @@ -4888,6 +4889,102 @@ func testHostsReplaceHostDeviceMapping(t *testing.T, ds *Datastore) { }) } +func testHostsCustomHostDeviceMapping(t *testing.T, ds *Datastore) { + ctx := context.Background() + + h1, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("1"), + NodeKey: ptr.String("1"), + Platform: "linux", + Hostname: "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("2"), + NodeKey: ptr.String("2"), + Platform: "linux", + Hostname: "host2", + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + }) + require.NoError(t, err) + + // create a custom installer email for h1 + dms, err := ds.SetOrUpdateCustomHostDeviceMapping(ctx, h1.ID, "a@b.c", fleet.DeviceMappingCustomInstaller) + require.NoError(t, err) + assertHostDeviceMapping(t, dms, []*fleet.HostDeviceMapping{{Email: "a@b.c", Source: fleet.DeviceMappingCustomReplacement}}) + + // custom installer can be updated + dms, err = ds.SetOrUpdateCustomHostDeviceMapping(ctx, h1.ID, "b@b.c", fleet.DeviceMappingCustomInstaller) + require.NoError(t, err) + assertHostDeviceMapping(t, dms, []*fleet.HostDeviceMapping{{Email: "b@b.c", Source: fleet.DeviceMappingCustomReplacement}}) + + // set a custom override, custom installer is removed + dms, err = ds.SetOrUpdateCustomHostDeviceMapping(ctx, h1.ID, "c@b.c", fleet.DeviceMappingCustomOverride) + require.NoError(t, err) + assertHostDeviceMapping(t, dms, []*fleet.HostDeviceMapping{{Email: "c@b.c", Source: fleet.DeviceMappingCustomReplacement}}) + + // updating the custom installer is now ignored + dms, err = ds.SetOrUpdateCustomHostDeviceMapping(ctx, h1.ID, "d@b.c", fleet.DeviceMappingCustomInstaller) + require.NoError(t, err) + assertHostDeviceMapping(t, dms, []*fleet.HostDeviceMapping{{Email: "c@b.c", Source: fleet.DeviceMappingCustomReplacement}}) + + // updating the custom override works + dms, err = ds.SetOrUpdateCustomHostDeviceMapping(ctx, h1.ID, "e@b.c", fleet.DeviceMappingCustomOverride) + require.NoError(t, err) + assertHostDeviceMapping(t, dms, []*fleet.HostDeviceMapping{{Email: "e@b.c", Source: fleet.DeviceMappingCustomReplacement}}) + + // set some unrelated emails for h2 + err = ds.ReplaceHostDeviceMapping(ctx, h2.ID, []*fleet.HostDeviceMapping{ + {HostID: h2.ID, Email: "a@c.d", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {HostID: h2.ID, Email: "b@c.d", Source: fleet.DeviceMappingGoogleChromeProfiles}, + }, fleet.DeviceMappingGoogleChromeProfiles) + require.NoError(t, err) + + // create a custom override immediately, without a custom installer + _, err = ds.SetOrUpdateCustomHostDeviceMapping(ctx, h2.ID, "c@c.d", fleet.DeviceMappingCustomOverride) + require.NoError(t, err) + + // adding a custom installer is ignored + dms, err = ds.SetOrUpdateCustomHostDeviceMapping(ctx, h2.ID, "d@c.d", fleet.DeviceMappingCustomInstaller) + require.NoError(t, err) + + assertHostDeviceMapping(t, dms, []*fleet.HostDeviceMapping{ + {Email: "a@c.d", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "b@c.d", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "c@c.d", Source: fleet.DeviceMappingCustomReplacement}, + }) + + // updating the custom override works + dms, err = ds.SetOrUpdateCustomHostDeviceMapping(ctx, h2.ID, "e@c.d", fleet.DeviceMappingCustomOverride) + require.NoError(t, err) + + assertHostDeviceMapping(t, dms, []*fleet.HostDeviceMapping{ + {Email: "a@c.d", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "b@c.d", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "e@c.d", Source: fleet.DeviceMappingCustomReplacement}, + }) + + // deleting the host deletes the mappings + err = ds.DeleteHost(ctx, h2.ID) + require.NoError(t, err) + dms, err = ds.ListHostDeviceMapping(ctx, h2.ID) + require.NoError(t, err) + require.Empty(t, dms) + + // other host was left untouched + dms, err = ds.ListHostDeviceMapping(ctx, h1.ID) + require.NoError(t, err) + assertHostDeviceMapping(t, dms, []*fleet.HostDeviceMapping{{Email: "e@b.c", Source: fleet.DeviceMappingCustomReplacement}}) +} + func assertHostDeviceMapping(t *testing.T, got, want []*fleet.HostDeviceMapping) { t.Helper() diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 1739a5fc55..9bc4c77c09 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -535,14 +535,14 @@ func (ds *Datastore) ListHostsInLabel(ctx context.Context, filter fleet.TeamFilt failingPoliciesJoin = "" } - deviceMappingJoin := `LEFT JOIN ( + deviceMappingJoin := fmt.Sprintf(`LEFT JOIN ( SELECT host_id, - CONCAT('[', GROUP_CONCAT(JSON_OBJECT('email', email, 'source', source)), ']') AS device_mapping + CONCAT('[', GROUP_CONCAT(JSON_OBJECT('email', email, 'source', %s)), ']') AS device_mapping FROM host_emails GROUP BY - host_id) dm ON dm.host_id = h.id` + host_id) dm ON dm.host_id = h.id`, deviceMappingTranslateSourceColumn("")) if !opt.DeviceMapping { deviceMappingJoin = "" } @@ -605,7 +605,7 @@ func (ds *Datastore) CountHostsInLabel(ctx context.Context, filter fleet.TeamFil query := `SELECT count(*) FROM label_membership lm JOIN hosts h ON (lm.host_id = h.id) LEFT JOIN host_seen_times hst ON (h.id=hst.host_id) - LEFT JOIN host_disks hd ON (h.id=hd.host_id) + LEFT JOIN host_disks hd ON (h.id=hd.host_id) ` query += hostMDMJoin diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 66d81d5a5b..d1adaf00d8 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -269,6 +269,9 @@ type Datastore interface { CountHosts(ctx context.Context, filter TeamFilter, opt HostListOptions) (int, error) CountHostsInLabel(ctx context.Context, filter TeamFilter, lid uint, opt HostListOptions) (int, error) ListHostDeviceMapping(ctx context.Context, id uint) ([]*HostDeviceMapping, error) + // 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) // ListHostBatteries returns the list of batteries for the given host ID. ListHostBatteries(ctx context.Context, id uint) ([]*HostBattery, error) diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index fe72573457..1b58c5b801 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -836,6 +836,18 @@ func ExpandPlatform(platform string) []string { } } +// List of valid sources for HostDeviceMapping (host_emails table in the +// database). +const ( + DeviceMappingGoogleChromeProfiles = "google_chrome_profiles" + DeviceMappingMDMIdpAccounts = "mdm_idp_accounts" + DeviceMappingCustomInstaller = "custom_installer" // set by fleetd via device-authenticated API + DeviceMappingCustomOverride = "custom_override" // set by user via user-authenticated API + + DeviceMappingCustomPrefix = "custom_" // if host_emails.source starts with this, replace with DeviceMappingCustomReplacement + DeviceMappingCustomReplacement = "custom" // replaces a source that starts with CustomPrefix - in the UI, we want to display those as only "custom" +) + // HostDeviceMapping represents a mapping of a user email address to a host, // as reported by the specified source (e.g. Google Chrome Profiles). type HostDeviceMapping struct { diff --git a/server/fleet/service.go b/server/fleet/service.go index 8f06a06af6..76b174f926 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -359,6 +359,11 @@ 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) // ListDevicePolicies lists all policies for the given host, including passing / failing summaries ListDevicePolicies(ctx context.Context, host *Host) ([]*HostPolicy, error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index d28e4c1716..3d9c7d3102 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -204,6 +204,8 @@ type CountHostsInLabelFunc func(ctx context.Context, filter fleet.TeamFilter, li type ListHostDeviceMappingFunc func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) +type SetOrUpdateCustomHostDeviceMappingFunc func(ctx context.Context, hostID uint, email string, source string) ([]*fleet.HostDeviceMapping, error) + type ListHostBatteriesFunc func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) type LoadHostByDeviceAuthTokenFunc func(ctx context.Context, authToken string, tokenTTL time.Duration) (*fleet.Host, error) @@ -1064,6 +1066,9 @@ type DataStore struct { ListHostDeviceMappingFunc ListHostDeviceMappingFunc ListHostDeviceMappingFuncInvoked bool + SetOrUpdateCustomHostDeviceMappingFunc SetOrUpdateCustomHostDeviceMappingFunc + SetOrUpdateCustomHostDeviceMappingFuncInvoked bool + ListHostBatteriesFunc ListHostBatteriesFunc ListHostBatteriesFuncInvoked bool @@ -2588,6 +2593,13 @@ func (s *DataStore) ListHostDeviceMapping(ctx context.Context, id uint) ([]*flee return s.ListHostDeviceMappingFunc(ctx, id) } +func (s *DataStore) SetOrUpdateCustomHostDeviceMapping(ctx context.Context, hostID uint, email string, source string) ([]*fleet.HostDeviceMapping, error) { + s.mu.Lock() + s.SetOrUpdateCustomHostDeviceMappingFuncInvoked = true + s.mu.Unlock() + return s.SetOrUpdateCustomHostDeviceMappingFunc(ctx, hostID, email, source) +} + func (s *DataStore) ListHostBatteries(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { s.mu.Lock() s.ListHostBatteriesFuncInvoked = true diff --git a/server/service/endpoint_utils.go b/server/service/endpoint_utils.go index 72c6b60f0b..bf81d26786 100644 --- a/server/service/endpoint_utils.go +++ b/server/service/endpoint_utils.go @@ -495,6 +495,10 @@ func (e *authEndpointer) GET(path string, f handlerFunc, v interface{}) { e.handleEndpoint(path, f, v, "GET") } +func (e *authEndpointer) PUT(path string, f handlerFunc, v interface{}) { + e.handleEndpoint(path, f, v, "PUT") +} + func (e *authEndpointer) PATCH(path string, f handlerFunc, v interface{}) { e.handleEndpoint(path, f, v, "PATCH") } diff --git a/server/service/handler.go b/server/service/handler.go index f8d9ecb2dc..8123d61ec7 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -383,6 +383,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/hosts/transfer/filter", addHostsToTeamByFilterEndpoint, addHostsToTeamByFilterRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/refetch", refetchHostEndpoint, refetchHostRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", listHostDeviceMappingEndpoint, listHostDeviceMappingRequest{}) + 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{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/queries/{query_id:[0-9]+}", getHostQueryReportEndpoint, getHostQueryReportRequest{}) @@ -652,6 +653,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // endpoints are POST due to passing the device token in the JSON body. oe.POST("/api/fleet/orbit/scripts/request", getOrbitScriptEndpoint, orbitGetScriptRequest{}) oe.POST("/api/fleet/orbit/scripts/result", postOrbitScriptResultEndpoint, orbitPostScriptResultRequest{}) + oe.PUT("/api/fleet/orbit/device_mapping", putOrbitDeviceMappingEndpoint, orbitPutDeviceMappingRequest{}) oeWindowsMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM()) oeWindowsMDM.POST("/api/fleet/orbit/disk_encryption_key", postOrbitDiskEncryptionKeyEndpoint, orbitPostDiskEncryptionKeyRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index a5310d8d7a..561091e016 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1278,6 +1278,57 @@ func (svc *Service) ListHostDeviceMapping(ctx context.Context, id uint) ([]*flee return svc.ds.ListHostDeviceMapping(ctx, id) } +//////////////////////////////////////////////////////////////////////////////// +// Put Custom Host Device Mapping +//////////////////////////////////////////////////////////////////////////////// + +type putHostDeviceMappingRequest struct { + ID uint `url:"id"` + Email string `json:"email"` +} + +type putHostDeviceMappingResponse struct { + HostID uint `json:"host_id"` + DeviceMapping []*fleet.HostDeviceMapping `json:"device_mapping"` + Err error `json:"error,omitempty"` +} + +func (r putHostDeviceMappingResponse) error() error { return r.Err } + +func putHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*putHostDeviceMappingRequest) + dms, err := svc.SetCustomHostDeviceMapping(ctx, req.ID, req.Email) + 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) { + isInstallerSource := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnOrbitToken) + if !isInstallerSource { + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return nil, err + } + + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host") + } + + // Authorize again with team loaded now that we have team_id + if err := svc.authz.Authorize(ctx, host, fleet.ActionWrite); err != nil { + return nil, err + } + } + + source := fleet.DeviceMappingCustomOverride + if isInstallerSource { + source = fleet.DeviceMappingCustomInstaller + } + return svc.ds.SetOrUpdateCustomHostDeviceMapping(ctx, hostID, email, source) +} + //////////////////////////////////////////////////////////////////////////////// // MDM //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 69caf12377..6595b28c35 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -582,6 +582,9 @@ func TestHostAuth(t *testing.T) { ds.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) { return nil, nil } + ds.SetOrUpdateCustomHostDeviceMappingFunc = func(ctx context.Context, hostID uint, email, source string) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } testCases := []struct { name string @@ -615,6 +618,14 @@ func TestHostAuth(t *testing.T) { true, false, }, + { + "team admin, belongs to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + true, + true, + false, + false, + }, { "team maintainer, belongs to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, @@ -631,6 +642,14 @@ func TestHostAuth(t *testing.T) { true, false, }, + { + "team admin, DOES NOT belong to team", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, + true, + true, + true, + true, + }, { "team maintainer, DOES NOT belong to team", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, @@ -694,6 +713,12 @@ func TestHostAuth(t *testing.T) { err = svc.RefetchHost(ctx, 1) checkAuthErr(t, tt.shouldFailTeamRead, err) + + _, err = svc.SetCustomHostDeviceMapping(ctx, 1, "a@b.c") + checkAuthErr(t, tt.shouldFailTeamWrite, err) + + _, err = svc.SetCustomHostDeviceMapping(ctx, 2, "a@b.c") + checkAuthErr(t, tt.shouldFailGlobalWrite, err) }) } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index ea25d60f07..4b2017d5e0 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -3023,6 +3023,7 @@ func (s *integrationTestSuite) TestHostDeviceMapping() { t := s.T() ctx := context.Background() + orbitHost := createOrbitEnrolledHost(t, "windows", "device_mapping", s.ds) hosts := s.createHosts(t) // get host device mappings of invalid host @@ -3033,21 +3034,32 @@ func (s *integrationTestSuite) TestHostDeviceMapping() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", hosts[0].ID), nil, http.StatusOK, &listResp) require.Len(t, listResp.DeviceMapping, 0) - // create some mappings + // create a custom mapping of a non-existing host + var putResp putHostDeviceMappingResponse + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", hosts[2].ID+1), nil, http.StatusNotFound, &putResp) + + // create some google mappings require.NoError(t, s.ds.ReplaceHostDeviceMapping(ctx, hosts[0].ID, []*fleet.HostDeviceMapping{ - {HostID: hosts[0].ID, Email: "a@b.c", Source: "google_chrome_profiles"}, - {HostID: hosts[0].ID, Email: "b@b.c", Source: "google_chrome_profiles"}, - }, "google_chrome_profiles")) + {HostID: hosts[0].ID, Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {HostID: hosts[0].ID, Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + }, fleet.DeviceMappingGoogleChromeProfiles)) + + // create a custom mapping + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", hosts[0].ID), putHostDeviceMappingRequest{Email: "c@b.c"}, http.StatusOK, &putResp) + require.Equal(t, hosts[0].ID, putResp.HostID) + require.ElementsMatch(t, putResp.DeviceMapping, []*fleet.HostDeviceMapping{ + {Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "c@b.c", Source: fleet.DeviceMappingCustomReplacement}, + }) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", hosts[0].ID), nil, http.StatusOK, &listResp) - require.Len(t, listResp.DeviceMapping, 2) - require.Equal(t, "a@b.c", listResp.DeviceMapping[0].Email) - require.Equal(t, "google_chrome_profiles", listResp.DeviceMapping[0].Source) - require.Zero(t, listResp.DeviceMapping[0].HostID) - require.Equal(t, "b@b.c", listResp.DeviceMapping[1].Email) - require.Equal(t, "google_chrome_profiles", listResp.DeviceMapping[1].Source) - require.Zero(t, listResp.DeviceMapping[1].HostID) require.Equal(t, hosts[0].ID, listResp.HostID) + require.ElementsMatch(t, listResp.DeviceMapping, []*fleet.HostDeviceMapping{ + {Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "c@b.c", Source: fleet.DeviceMappingCustomReplacement}, + }) // other host still has none s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", hosts[1].ID), nil, http.StatusOK, &listResp) @@ -3056,7 +3068,7 @@ func (s *integrationTestSuite) TestHostDeviceMapping() { var listHosts listHostsResponse // list hosts response includes device mappings s.DoJSON("GET", "/api/latest/fleet/hosts?device_mapping=true", nil, http.StatusOK, &listHosts) - require.Len(t, listHosts.Hosts, 3) + require.Len(t, listHosts.Hosts, len(hosts)+1) hostsByID := make(map[uint]fleet.HostResponse) for _, h := range listHosts.Hosts { hostsByID[h.ID] = h @@ -3069,20 +3081,31 @@ func (s *integrationTestSuite) TestHostDeviceMapping() { err := json.Unmarshal(*hostsByID[host1.ID].DeviceMapping, &dm) require.NoError(t, err) - assert.Len(t, dm, 2) - - var emails []string - for _, e := range dm { - emails = append(emails, e.Email) - } - assert.Contains(t, emails, "a@b.c") - assert.Contains(t, emails, "b@b.c") - assert.Equal(t, "google_chrome_profiles", dm[0].Source) - assert.Equal(t, "google_chrome_profiles", dm[1].Source) + require.ElementsMatch(t, dm, []*fleet.HostDeviceMapping{ + {Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "c@b.c", Source: fleet.DeviceMappingCustomReplacement}, + }) // no device mapping for other hosts assert.Nil(t, hostsByID[hosts[1].ID].DeviceMapping) assert.Nil(t, hostsByID[hosts[2].ID].DeviceMapping) + assert.Nil(t, hostsByID[orbitHost.ID].DeviceMapping) + + // update custom email for hosts[0] + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", hosts[0].ID), putHostDeviceMappingRequest{Email: "d@b.c"}, http.StatusOK, &putResp) + require.Equal(t, hosts[0].ID, putResp.HostID) + require.ElementsMatch(t, putResp.DeviceMapping, []*fleet.HostDeviceMapping{ + {Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "d@b.c", Source: fleet.DeviceMappingCustomReplacement}, + }) + + // create a custom_installer email for orbit host + s.Do("PUT", "/api/fleet/orbit/device_mapping", orbitPutDeviceMappingRequest{ + OrbitNodeKey: *orbitHost.OrbitNodeKey, + Email: "e@b.c", + }, http.StatusOK) // search host by email address finds the corresponding host s.DoJSON("GET", "/api/latest/fleet/hosts?device_mapping=true", nil, http.StatusOK, &listHosts, "query", "a@b.c") @@ -3092,17 +3115,41 @@ func (s *integrationTestSuite) TestHostDeviceMapping() { err = json.Unmarshal(*listHosts.Hosts[0].DeviceMapping, &dm) require.NoError(t, err) - assert.Len(t, dm, 2) + require.ElementsMatch(t, putResp.DeviceMapping, []*fleet.HostDeviceMapping{ + {Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "d@b.c", Source: fleet.DeviceMappingCustomReplacement}, + }) - for _, e := range dm { - emails = append(emails, e.Email) - } - assert.Contains(t, emails, "a@b.c") - assert.Contains(t, emails, "b@b.c") - assert.Equal(t, "google_chrome_profiles", dm[0].Source) - assert.Equal(t, "google_chrome_profiles", dm[1].Source) + // search host by the custom email address finds the corresponding host + s.DoJSON("GET", "/api/latest/fleet/hosts?device_mapping=true", nil, http.StatusOK, &listHosts, "query", "d@b.c") + require.Len(t, listHosts.Hosts, 1) + require.Equal(t, hosts[0].ID, listHosts.Hosts[0].ID) - s.DoJSON("GET", "/api/latest/fleet/hosts?device_mapping=true", nil, http.StatusOK, &listHosts, "query", "c@b.c") + s.DoJSON("GET", "/api/latest/fleet/hosts?device_mapping=true", nil, http.StatusOK, &listHosts, "query", "e@b.c") + require.Len(t, listHosts.Hosts, 1) + require.Equal(t, orbitHost.ID, listHosts.Hosts[0].ID) + + // override the custom email for the orbit host + s.DoJSON("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", orbitHost.ID), putHostDeviceMappingRequest{Email: "f@b.c"}, http.StatusOK, &putResp) + + // update the custom_installer email for orbit host, will get ignored (because a custom_override exists) + s.Do("PUT", "/api/fleet/orbit/device_mapping", orbitPutDeviceMappingRequest{ + OrbitNodeKey: *orbitHost.OrbitNodeKey, + Email: "g@b.c", + }, http.StatusOK) + + // searching by the old custom installer email doesn't work anymore + s.DoJSON("GET", "/api/latest/fleet/hosts?device_mapping=true", nil, http.StatusOK, &listHosts, "query", "e@b.c") + require.Len(t, listHosts.Hosts, 0) + + // searching by the new custom email address finds it + s.DoJSON("GET", "/api/latest/fleet/hosts?device_mapping=true", nil, http.StatusOK, &listHosts, "query", "f@b.c") + require.Len(t, listHosts.Hosts, 1) + require.Equal(t, orbitHost.ID, listHosts.Hosts[0].ID) + + // searching by a never-used email returns nothing + s.DoJSON("GET", "/api/latest/fleet/hosts?device_mapping=true", nil, http.StatusOK, &listHosts, "query", "Z@b.c") require.Len(t, listHosts.Hosts, 0) } diff --git a/server/service/integration_desktop_test.go b/server/service/integration_desktop_test.go index ab42b4c097..d3d994fd05 100644 --- a/server/service/integration_desktop_test.go +++ b/server/service/integration_desktop_test.go @@ -27,9 +27,11 @@ func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() { // create some mappings and MDM/Munki data require.NoError(t, s.ds.ReplaceHostDeviceMapping(context.Background(), hosts[0].ID, []*fleet.HostDeviceMapping{ - {HostID: hosts[0].ID, Email: "a@b.c", Source: "google_chrome_profiles"}, - {HostID: hosts[0].ID, Email: "b@b.c", Source: "google_chrome_profiles"}, - }, "google_chrome_profiles")) + {HostID: hosts[0].ID, Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {HostID: hosts[0].ID, Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + }, fleet.DeviceMappingGoogleChromeProfiles)) + _, err = s.ds.SetOrUpdateCustomHostDeviceMapping(context.Background(), hosts[0].ID, "c@b.c", fleet.DeviceMappingCustomInstaller) + require.NoError(t, err) require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), hosts[0].ID, false, true, "url", false, "", "")) require.NoError(t, s.ds.SetOrUpdateMunkiInfo(context.Background(), hosts[0].ID, "1.3.0", nil, nil)) // create a battery for hosts[0] @@ -107,7 +109,12 @@ func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() { require.NoError(t, json.NewDecoder(res.Body).Decode(&listDMResp)) require.NoError(t, res.Body.Close()) require.Equal(t, hosts[0].ID, listDMResp.HostID) - require.Len(t, listDMResp.DeviceMapping, 2) + require.Len(t, listDMResp.DeviceMapping, 3) + require.ElementsMatch(t, listDMResp.DeviceMapping, []*fleet.HostDeviceMapping{ + {Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "c@b.c", Source: fleet.DeviceMappingCustomReplacement}, + }) devDMs := listDMResp.DeviceMapping // compare response with standard list device mapping API for that same host diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 2ce082bf43..3a798546d1 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -6730,7 +6730,7 @@ func (s *integrationMDMTestSuite) TestSSO() { } source, ok := sourceByEmail["sso_user@example.com"] require.True(t, ok) - require.Equal(t, "mdm_idp_accounts", source) + require.Equal(t, fleet.DeviceMappingMDMIdpAccounts, source) source, ok = sourceByEmail["g1@example.com"] require.True(t, ok) require.Equal(t, "google_chrome_profiles", source) @@ -6762,7 +6762,7 @@ func (s *integrationMDMTestSuite) TestSSO() { s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/device_mapping", hostResp.Host.ID), nil, http.StatusOK, &dmResp) require.Len(t, dmResp.DeviceMapping, 1) require.Equal(t, "sso_user@example.com", dmResp.DeviceMapping[0].Email) - require.Equal(t, "mdm_idp_accounts", dmResp.DeviceMapping[0].Source) + require.Equal(t, fleet.DeviceMappingMDMIdpAccounts, dmResp.DeviceMapping[0].Source) hostsResp = listHostsResponse{} s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts?query=%s&device_mapping=true", url.QueryEscape("sso_user@example.com")), nil, http.StatusOK, &hostsResp) require.Len(t, hostsResp.Hosts, 1) @@ -6773,7 +6773,7 @@ func (s *integrationMDMTestSuite) TestSSO() { require.NoError(t, json.Unmarshal(*gotHost.DeviceMapping, &dm)) require.Len(t, dm, 1) require.Equal(t, "sso_user@example.com", dm[0].Email) - require.Equal(t, "mdm_idp_accounts", dm[0].Source) + require.Equal(t, fleet.DeviceMappingMDMIdpAccounts, dm[0].Source) // enrolling a different user works without problems res = s.LoginMDMSSOUser("sso_user2", "user123#") diff --git a/server/service/orbit.go b/server/service/orbit.go index cbe01b2740..37431c7e91 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -519,6 +519,44 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host return fleet.ErrMissingLicense } +///////////////////////////////////////////////////////////////////////////////// +// Post Orbit device mapping (custom email) +///////////////////////////////////////////////////////////////////////////////// + +type orbitPutDeviceMappingRequest struct { + OrbitNodeKey string `json:"orbit_node_key"` + Email string `json:"email"` +} + +// interface implementation required by the OrbitClient +func (r *orbitPutDeviceMappingRequest) setOrbitNodeKey(nodeKey string) { + r.OrbitNodeKey = nodeKey +} + +// interface implementation required by orbit authentication +func (r *orbitPutDeviceMappingRequest) orbitHostNodeKey() string { + return r.OrbitNodeKey +} + +type orbitPutDeviceMappingResponse struct { + Err error `json:"error,omitempty"` +} + +func (r orbitPutDeviceMappingResponse) error() error { return r.Err } + +func putOrbitDeviceMappingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*orbitPutDeviceMappingRequest) + + host, ok := hostctx.FromContext(ctx) + if !ok { + err := newOsqueryError("internal error: missing host from request context") + return orbitPutDeviceMappingResponse{Err: err}, nil + } + + _, err := svc.SetCustomHostDeviceMapping(ctx, host.ID, req.Email) + return orbitPutDeviceMappingResponse{Err: err}, nil +} + ///////////////////////////////////////////////////////////////////////////////// // Post Orbit disk encryption key ///////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 8d25bcf46d..97b2e10065 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -1079,10 +1079,10 @@ func directIngestChromeProfiles(ctx context.Context, logger log.Logger, host *fl mapping = append(mapping, &fleet.HostDeviceMapping{ HostID: host.ID, Email: row["email"], - Source: "google_chrome_profiles", + Source: fleet.DeviceMappingGoogleChromeProfiles, }) } - return ds.ReplaceHostDeviceMapping(ctx, host.ID, mapping, "google_chrome_profiles") + return ds.ReplaceHostDeviceMapping(ctx, host.ID, mapping, fleet.DeviceMappingGoogleChromeProfiles) } func directIngestBattery(ctx context.Context, logger log.Logger, host *fleet.Host, ds fleet.Datastore, rows []map[string]string) error {