Custom email device-mapping: implement the REST API changes (#15748)

This commit is contained in:
Martin Angers 2023-12-21 12:21:39 -05:00 committed by GitHub
parent 6a3b7b8315
commit 235d2cf2dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 430 additions and 55 deletions

View file

@ -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.

View file

@ -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,
)
}

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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")
}

View file

@ -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{})

View file

@ -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
////////////////////////////////////////////////////////////////////////////////

View file

@ -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)
})
}

View file

@ -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)
}

View file

@ -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

View file

@ -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#")

View file

@ -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
/////////////////////////////////////////////////////////////////////////////////

View file

@ -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 {