From 9f064acd2e9dd4f237da7f000565fb8a24a396f1 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 5 Jun 2023 15:08:21 -0400 Subject: [PATCH] Match pre-assigned profiles to a team (or create one) and assign host to team (#12127) --- changes/issue-12019-add-preassign-profiles | 3 +- ee/server/service/mdm.go | 118 +++++++++++++++- server/datastore/mysql/apple_mdm.go | 39 ++++++ server/datastore/mysql/apple_mdm_test.go | 84 +++++++++++ server/fleet/apple_mdm.go | 20 ++- server/fleet/datastore.go | 5 + server/fleet/hosts.go | 7 + server/mdm/apple/profile_matcher.go | 47 ++++++- server/mdm/apple/profile_matcher_test.go | 79 +++++++++++ server/mock/datastore_mock.go | 12 ++ server/service/apple_mdm_test.go | 71 +++++++++- server/service/integration_mdm_test.go | 153 +++++++++++++++++++-- server/service/testing_utils.go | 4 + 13 files changed, 620 insertions(+), 22 deletions(-) diff --git a/changes/issue-12019-add-preassign-profiles b/changes/issue-12019-add-preassign-profiles index 5b1d4b55b1..981158cb73 100644 --- a/changes/issue-12019-add-preassign-profiles +++ b/changes/issue-12019-add-preassign-profiles @@ -1 +1,2 @@ -* Added the `POST /fleet/mdm/apple/preassign` endpoint to store profiles to be assigned to a host, for subsequent matching with an existing (or new) team. +* Added the `POST /fleet/mdm/apple/profiles/preassign` endpoint to store profiles to be assigned to a host, for subsequent matching with an existing (or new) team. +* Added the `POST /fleet/mdm/apple/profiles/match` endpoint to match pre-assigned profiles to an existing team or create one if needed, and assign the host to that team. diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index ebba045054..91e671338b 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -7,10 +7,13 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io" "net/http" "net/url" + "sort" "strings" + "time" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/authz" @@ -742,7 +745,118 @@ func (svc *Service) MDMAppleMatchPreassignment(ctx context.Context, externalHost return ctxerr.Wrap(ctx, err) } - // TODO(mna): TBD the exact signature, must return the list of profiles for matching - //profs, err := svc.profileMatcher.RetrieveProfiles(ctx, externalHostIdentifier) + profs, err := svc.profileMatcher.RetrieveProfiles(ctx, externalHostIdentifier) + if err != nil { + return err + } + if len(profs.Profiles) == 0 || profs.HostUUID == "" { + return nil // nothing to do + } + + // load the host and ensure it is enrolled in Fleet MDM + host, err := svc.ds.HostByIdentifier(ctx, profs.HostUUID) + if err != nil { + return err // will return a not found error if host does not exist + } + + hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID) + if err != nil || !hostMDM.IsFleetEnrolled() { + if err == nil || fleet.IsNotFound(err) { + err = errors.New("host is not enrolled in Fleet MDM") + return ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: err.Error(), + InternalErr: err, + }) + } + return err + } + + // Collect the profiles' hashes and look for a team with exactly that set. + // Also collect the profiles' groups in case we need to create a new team, + // and the list of raw profiles bytes. + hashes, groups, rawProfiles := make([]string, 0, len(profs.Profiles)), + make([]string, 0, len(profs.Profiles)), + make([][]byte, 0, len(profs.Profiles)) + for _, prof := range profs.Profiles { + hashes = append(hashes, prof.HexMD5Hash) + rawProfiles = append(rawProfiles, prof.Profile) + if prof.Group != "" { + groups = append(groups, prof.Group) + } + } + + // find a team with exactly that set of profiles + teamIDs, err := svc.ds.MatchMDMAppleConfigProfiles(ctx, hashes) + if err != nil { + return err + } + + var targetTeamID uint + if len(teamIDs) > 0 { + // if the host is already in one of those valid teams, nothing to do. + if host.TeamID != nil { + for _, tmID := range teamIDs { + if *host.TeamID == tmID { + return nil + } + } + } + // else assign the host to the first valid team + targetTeamID = teamIDs[0] + + } else { + // Create a new team with this set of profiles. Creating via the service + // call so that it properly assigns the agent options and creates audit + // activities, etc. + teamName := teamNameFromPreassignGroups(groups) + tm, err := svc.NewTeam(ctx, fleet.TeamPayload{Name: &teamName}) + if err != nil { + return err + } + + // create profiles for that team via the service call, so that uniqueness + // of profile identifier/name is verified, activity created, etc. + // NOTE: this will use the read replica to load the team, which was just + // created above, could lead to not found issues with slow replication. + if err := svc.BatchSetMDMAppleProfiles(ctx, &tm.ID, nil, rawProfiles, false); err != nil { + return err + } + + targetTeamID = tm.ID + } + + // assign host to that team via the service call, which will trigger + // deployment of the profiles. + if err := svc.AddHostsToTeam(ctx, &targetTeamID, []uint{host.ID}); err != nil { + return err + } return nil } + +// teamNameFromPreassignGroups returns the team name to use for a new team +// created to match the set of profiles preassigned to a host. The team name is +// derived from the "group" field provided with each request to pre-assign a +// profile to a host (in fleet.MDMApplePreassignProfilePayload). That field is +// optional, and empty groups are ignored. The current timestamp is appended to +// the team's name to help avoid duplicates. +func teamNameFromPreassignGroups(groups []string) string { + const defaultName = "default" + + dedupeGroups := make(map[string]struct{}, len(groups)) + for _, group := range groups { + if group != "" { + dedupeGroups[group] = struct{}{} + } + } + groups = groups[:0] + for group := range dedupeGroups { + groups = append(groups, group) + } + sort.Strings(groups) + + if len(groups) == 0 { + groups = []string{defaultName} + } + + return fmt.Sprintf("%s (%s)", strings.Join(groups, " - "), time.Now().UTC().Format("2006-01-02:15:04:05")) +} diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 7c540eef37..685d4928b2 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -107,6 +107,45 @@ ORDER BY name` return res, nil } +func (ds *Datastore) MatchMDMAppleConfigProfiles(ctx context.Context, hexMD5Hashes []string) ([]uint, error) { + // as a special-case, should never be called without at least one hash but if + // so, never matches anything. + if len(hexMD5Hashes) == 0 { + return nil, nil + } + + stmt := ` +SELECT + p1.team_id +FROM + mdm_apple_configuration_profiles p1 +WHERE + NOT EXISTS ( + SELECT + 1 + FROM + mdm_apple_configuration_profiles p2 + WHERE + p1.team_id = p2.team_id AND + HEX(p2.checksum) NOT IN (?) + ) +GROUP BY + p1.team_id +HAVING + COUNT(*) = ?` + + stmt, args, err := sqlx.In(stmt, hexMD5Hashes, len(hexMD5Hashes)) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "prepare query arguments") + } + + var teamIDs []uint + if err := sqlx.SelectContext(ctx, ds.reader, &teamIDs, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "execute query") + } + return teamIDs, nil +} + func (ds *Datastore) GetMDMAppleConfigProfile(ctx context.Context, profileID uint) (*fleet.MDMAppleConfigProfile, error) { stmt := ` SELECT diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 0b991c5de3..0b3a52b1be 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -61,6 +61,7 @@ func TestMDMApple(t *testing.T) { {"TestMDMAppleDefaultSetupAssistant", testMDMAppleDefaultSetupAssistant}, {"TestSetVerifiedMacOSProfiles", testSetVerifiedMacOSProfiles}, {"TestMDMAppleConfigProfileHash", testMDMAppleConfigProfileHash}, + {"TestMatchMDMAppleConfigProfiles", testMatchMDMAppleConfigProfiles}, } for _, c := range cases { @@ -4240,3 +4241,86 @@ func testMDMAppleConfigProfileHash(t *testing.T, ds *Datastore) { }) } } + +func testMatchMDMAppleConfigProfiles(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create some teams with different sets of profiles + tmNoProf, err := ds.NewTeam(ctx, &fleet.Team{Name: "no-prof"}) + require.NoError(t, err) + require.NotNil(t, tmNoProf) + + tmProfA, err := ds.NewTeam(ctx, &fleet.Team{Name: "prof-a"}) + require.NoError(t, err) + + tmProfAB, err := ds.NewTeam(ctx, &fleet.Team{Name: "prof-ab"}) + require.NoError(t, err) + + tmProfBC, err := ds.NewTeam(ctx, &fleet.Team{Name: "prof-bc"}) + require.NoError(t, err) + + tmProfABC, err := ds.NewTeam(ctx, &fleet.Team{Name: "prof-abc"}) + require.NoError(t, err) + + // create another team with profile A + tmProfA2, err := ds.NewTeam(ctx, &fleet.Team{Name: "prof-a2"}) + require.NoError(t, err) + + profA := configProfileForTest(t, "A", "A", "A") + profB := configProfileForTest(t, "B", "B", "B") + profC := configProfileForTest(t, "C", "C", "C") + + profA.TeamID = &tmProfA.ID + _, err = ds.NewMDMAppleConfigProfile(ctx, *profA) + require.NoError(t, err) + profA.TeamID = &tmProfA2.ID + _, err = ds.NewMDMAppleConfigProfile(ctx, *profA) + require.NoError(t, err) + profA.TeamID = &tmProfAB.ID + _, err = ds.NewMDMAppleConfigProfile(ctx, *profA) + require.NoError(t, err) + profA.TeamID = &tmProfABC.ID + _, err = ds.NewMDMAppleConfigProfile(ctx, *profA) + require.NoError(t, err) + profB.TeamID = &tmProfAB.ID + _, err = ds.NewMDMAppleConfigProfile(ctx, *profB) + require.NoError(t, err) + profB.TeamID = &tmProfBC.ID + _, err = ds.NewMDMAppleConfigProfile(ctx, *profB) + require.NoError(t, err) + profB.TeamID = &tmProfABC.ID + _, err = ds.NewMDMAppleConfigProfile(ctx, *profB) + require.NoError(t, err) + profC.TeamID = &tmProfBC.ID + _, err = ds.NewMDMAppleConfigProfile(ctx, *profC) + require.NoError(t, err) + profC.TeamID = &tmProfABC.ID + _, err = ds.NewMDMAppleConfigProfile(ctx, *profC) + require.NoError(t, err) + + // get the hashes for each profile, the same way the matching logic would + profAHash := (fleet.MDMApplePreassignProfilePayload{Profile: profA.Mobileconfig}).HexMD5Hash() + profBHash := (fleet.MDMApplePreassignProfilePayload{Profile: profB.Mobileconfig}).HexMD5Hash() + profCHash := (fleet.MDMApplePreassignProfilePayload{Profile: profC.Mobileconfig}).HexMD5Hash() + + cases := []struct { + hashes []string + teamIDs []uint + }{ + {nil, nil}, + {[]string{profAHash}, []uint{tmProfA.ID, tmProfA2.ID}}, + {[]string{profBHash}, nil}, + {[]string{profCHash}, nil}, + {[]string{profAHash, profBHash}, []uint{tmProfAB.ID}}, + {[]string{profAHash, profBHash, profCHash}, []uint{tmProfABC.ID}}, + {[]string{profBHash, profCHash}, []uint{tmProfBC.ID}}, + {[]string{profAHash, profCHash}, nil}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%v", c.hashes), func(t *testing.T) { + matches, err := ds.MatchMDMAppleConfigProfiles(ctx, c.hashes) + require.NoError(t, err) + require.ElementsMatch(t, c.teamIDs, matches) + }) + } +} diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 9bbe163b20..ca4ac6362d 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -435,7 +435,23 @@ type MDMApplePreassignProfilePayload struct { // an option: https://dev.mysql.com/doc/refman/5.7/en/encryption-functions.html#function_sha2). func (p MDMApplePreassignProfilePayload) HexMD5Hash() string { sum := md5.Sum(p.Profile) //nolint: gosec - return hex.EncodeToString(sum[:]) + + // mysql's HEX function returns uppercase + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +// MDMApplePreassignHostProfiles represents the set of profiles that were +// pre-assigned to a given host identified by its UUID. +type MDMApplePreassignHostProfiles struct { + HostUUID string + Profiles []MDMApplePreassignProfile +} + +// MDMApplePreassignProfile represents a single profile pre-assigned to a host. +type MDMApplePreassignProfile struct { + Profile []byte + Group string + HexMD5Hash string } // MDMAppleSettingsPayload describes the payload accepted by the endpoint to @@ -548,5 +564,5 @@ func (a MDMAppleSetupAssistant) AuthzType() string { // implementation is used in production. type ProfileMatcher interface { PreassignProfile(ctx context.Context, payload MDMApplePreassignProfilePayload) error - RetrieveProfiles(ctx context.Context, externalHostIdentifier string) error + RetrieveProfiles(ctx context.Context, externalHostIdentifier string) (MDMApplePreassignHostProfiles, error) } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 352598914f..5502e263cc 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -764,6 +764,11 @@ type Datastore interface { // For global config profiles, specify nil as the team id. ListMDMAppleConfigProfiles(ctx context.Context, teamID *uint) ([]*MDMAppleConfigProfile, error) + // MatchMDMAppleConfigProfiles returns the list of team ids that have the + // exact set of configuration profiles as those specified by their + // hex-encoded md5 hashes. + MatchMDMAppleConfigProfiles(ctx context.Context, hexMD5Hashes []string) ([]uint, error) + // DeleteMDMAppleConfigProfile deletes the mdm config profile corresponding // to the specified profile id. DeleteMDMAppleConfigProfile(ctx context.Context, profileID uint) error diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index b799a3ef72..07a7266f08 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -803,6 +803,13 @@ func (h *HostMDM) IsManualFleetEnrolled() bool { h.Name == WellKnownMDMFleet } +// IsFleetEnrolled returns true if the host's MDM information indicates that +// it is in enrolled state for Fleet MDM, regardless of automatic or manual +// enrollment method. +func (h *HostMDM) IsFleetEnrolled() bool { + return h.IsDEPFleetEnrolled() || h.IsManualFleetEnrolled() +} + // HostMunkiIssue represents a single munki issue for a host. type HostMunkiIssue struct { MunkiIssueID uint `db:"munki_issue_id" json:"id"` diff --git a/server/mdm/apple/profile_matcher.go b/server/mdm/apple/profile_matcher.go index 8f969a1385..8ce3f946a4 100644 --- a/server/mdm/apple/profile_matcher.go +++ b/server/mdm/apple/profile_matcher.go @@ -2,6 +2,8 @@ package apple_mdm import ( "context" + "encoding/hex" + "strings" "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -97,15 +99,52 @@ func (p *profileMatcher) PreassignProfile(ctx context.Context, payload fleet.MDM // RetrieveProfiles retrieves the profiles preassigned to this host for // matching with a team and assignment. -func (p *profileMatcher) RetrieveProfiles(ctx context.Context, externalHostIdentifier string) error { +func (p *profileMatcher) RetrieveProfiles(ctx context.Context, externalHostIdentifier string) (fleet.MDMApplePreassignHostProfiles, error) { + var hostProfs fleet.MDMApplePreassignHostProfiles + // Note that we do not configure the Redis connection to read from a replica // here as it may be called very soon after the PreassignProfile call and // could miss some unreplicated profiles. conn := redis.ConfigureDoer(p.pool, p.pool.Get()) defer conn.Close() - // TODO: find all profiles matching the host identifier - // TODO: cleanup all retrieved profiles? - return nil + + profs, err := redigo.StringMap(conn.Do("HGETALL", keyForExternalHostIdentifier(externalHostIdentifier))) + if err != nil { + return hostProfs, ctxerr.Wrap(ctx, err, "execute redis HGETALL") + } + if _, err := conn.Do("UNLINK", keyForExternalHostIdentifier(externalHostIdentifier)); err != nil { + return hostProfs, ctxerr.Wrap(ctx, err, "execute redis UNLINK") + } + _ = conn.Close() // release connection to the pool immediately as we're done with redis + + if hostProfs.HostUUID = profs["host_uuid"]; hostProfs.HostUUID == "" { + // unknown host/no profiles to assign, not an error but nothing to do + return hostProfs, nil + } + delete(profs, "host_uuid") + + for k, v := range profs { + if strings.HasSuffix(k, "_group") || v == "" { + // only look for profiles' hex hashes, the group information will be + // retrieved only when a profile is found. Ignore empty values (e.g. + // empty profile). + continue + } + + // if the key is not the group, then it has to be a profile hash, ensure + // that it is a valid hex-encoded value. + if _, err := hex.DecodeString(k); err != nil { + // ignore unknown/invalid fields + continue + } + + hostProfs.Profiles = append(hostProfs.Profiles, fleet.MDMApplePreassignProfile{ + Profile: []byte(v), + Group: profs[k+"_group"], + HexMD5Hash: k, + }) + } + return hostProfs, nil } func keyForExternalHostIdentifier(externalHostIdentifier string) string { diff --git a/server/mdm/apple/profile_matcher_test.go b/server/mdm/apple/profile_matcher_test.go index 8ed2866e88..bb5dc6b059 100644 --- a/server/mdm/apple/profile_matcher_test.go +++ b/server/mdm/apple/profile_matcher_test.go @@ -137,6 +137,85 @@ func TestPreassignProfile(t *testing.T) { }) } +func TestRetrieveProfiles(t *testing.T) { + runTest := func(t *testing.T, pool fleet.RedisPool) { + ctx := context.Background() + matcher := NewProfileMatcher(pool) + + // preassign a profile with a group + p1 := fleet.MDMApplePreassignProfilePayload{ + ExternalHostIdentifier: "abcd", + HostUUID: "1234", + Profile: generateProfile("p1", "p1", "Configuration", "p1"), + Group: "g1", + } + err := matcher.PreassignProfile(ctx, p1) + require.NoError(t, err) + + // preassign a profile without a group + p2 := fleet.MDMApplePreassignProfilePayload{ + ExternalHostIdentifier: "abcd", + HostUUID: "1234", + Profile: generateProfile("p2", "p2", "Configuration", "p2"), + } + err = matcher.PreassignProfile(ctx, p2) + require.NoError(t, err) + + // retrieve from unknown external host identifier + profs, err := matcher.RetrieveProfiles(ctx, "efgh") + require.NoError(t, err) + require.Empty(t, profs.HostUUID) + require.Empty(t, profs.Profiles) + + // retrieve from valid external host identifier + profs, err = matcher.RetrieveProfiles(ctx, "abcd") + require.NoError(t, err) + require.Equal(t, "1234", profs.HostUUID) + require.ElementsMatch(t, []fleet.MDMApplePreassignProfile{ + {Profile: p1.Profile, Group: p1.Group, HexMD5Hash: p1.HexMD5Hash()}, + {Profile: p2.Profile, Group: "", HexMD5Hash: p2.HexMD5Hash()}, + }, profs.Profiles) + + // after retrieval, the key is deleted + profs, err = matcher.RetrieveProfiles(ctx, "abcd") + require.NoError(t, err) + require.Empty(t, profs.HostUUID) + require.Empty(t, profs.Profiles) + + // preassign to a host and generate invalid data + p3 := fleet.MDMApplePreassignProfilePayload{ + ExternalHostIdentifier: "xyz", + HostUUID: "5678", + Profile: generateProfile("p3", "p3", "Configuration", "p3"), + } + err = matcher.PreassignProfile(ctx, p3) + require.NoError(t, err) + + conn := redis.ConfigureDoer(pool, pool.Get()) + defer conn.Close() + _, err = conn.Do("HSET", keyForExternalHostIdentifier("xyz"), "123ABC", "", "not-hex", "foo") + require.NoError(t, err) + + // retrieves only the valid data for that host + profs, err = matcher.RetrieveProfiles(ctx, "xyz") + require.NoError(t, err) + require.Equal(t, "5678", profs.HostUUID) + require.ElementsMatch(t, []fleet.MDMApplePreassignProfile{ + {Profile: p3.Profile, Group: "", HexMD5Hash: p3.HexMD5Hash()}, + }, profs.Profiles) + } + + t.Run("standalone", func(t *testing.T) { + pool := redistest.SetupRedis(t, preassignKeyPrefix, false, false, false) + runTest(t, pool) + }) + + t.Run("cluster", func(t *testing.T) { + pool := redistest.SetupRedis(t, preassignKeyPrefix, true, true, false) + runTest(t, pool) + }) +} + func TestPreassignProfileValidation(t *testing.T) { ctx := context.Background() pool := redistest.SetupRedis(t, preassignKeyPrefix, false, false, false) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 5d34f3392e..422a42619c 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -528,6 +528,8 @@ type GetMDMAppleConfigProfileFunc func(ctx context.Context, profileID uint) (*fl type ListMDMAppleConfigProfilesFunc func(ctx context.Context, teamID *uint) ([]*fleet.MDMAppleConfigProfile, error) +type MatchMDMAppleConfigProfilesFunc func(ctx context.Context, hexMD5Hashes []string) ([]uint, error) + type DeleteMDMAppleConfigProfileFunc func(ctx context.Context, profileID uint) error type DeleteMDMAppleConfigProfileByTeamAndIdentifierFunc func(ctx context.Context, teamID *uint, profileIdentifier string) error @@ -1405,6 +1407,9 @@ type DataStore struct { ListMDMAppleConfigProfilesFunc ListMDMAppleConfigProfilesFunc ListMDMAppleConfigProfilesFuncInvoked bool + MatchMDMAppleConfigProfilesFunc MatchMDMAppleConfigProfilesFunc + MatchMDMAppleConfigProfilesFuncInvoked bool + DeleteMDMAppleConfigProfileFunc DeleteMDMAppleConfigProfileFunc DeleteMDMAppleConfigProfileFuncInvoked bool @@ -3362,6 +3367,13 @@ func (s *DataStore) ListMDMAppleConfigProfiles(ctx context.Context, teamID *uint return s.ListMDMAppleConfigProfilesFunc(ctx, teamID) } +func (s *DataStore) MatchMDMAppleConfigProfiles(ctx context.Context, hexMD5Hashes []string) ([]uint, error) { + s.mu.Lock() + s.MatchMDMAppleConfigProfilesFuncInvoked = true + s.mu.Unlock() + return s.MatchMDMAppleConfigProfilesFunc(ctx, hexMD5Hashes) +} + func (s *DataStore) DeleteMDMAppleConfigProfile(ctx context.Context, profileID uint) error { s.mu.Lock() s.DeleteMDMAppleConfigProfileFuncInvoked = true diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index cabade3d45..f32dc33cc0 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -35,6 +35,16 @@ import ( "github.com/stretchr/testify/require" ) +type nopProfileMatcher struct{} + +func (nopProfileMatcher) PreassignProfile(ctx context.Context, pld fleet.MDMApplePreassignProfilePayload) error { + return nil +} + +func (nopProfileMatcher) RetrieveProfiles(ctx context.Context, extHostID string) (fleet.MDMApplePreassignHostProfiles, error) { + return fleet.MDMApplePreassignHostProfiles{}, nil +} + func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Service, context.Context, *mock.Store) { ds := new(mock.Store) cfg := config.TestConfig() @@ -62,11 +72,12 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi ) opts := &TestServerOpts{ - FleetConfig: &cfg, - MDMStorage: mdmStorage, - DEPStorage: depStorage, - MDMPusher: pusher, - License: license, + FleetConfig: &cfg, + MDMStorage: mdmStorage, + DEPStorage: depStorage, + MDMPusher: pusher, + License: license, + ProfileMatcher: nopProfileMatcher{}, } svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, opts) @@ -2458,6 +2469,56 @@ func TestMDMAppleSetupAssistant(t *testing.T) { } } +func TestMDMApplePreassignEndpoints(t *testing.T) { + svc, ctx, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + + checkAuthErr := func(t *testing.T, err error, shouldFailWithAuth bool) { + t.Helper() + + if shouldFailWithAuth { + require.Error(t, err) + require.Contains(t, err.Error(), authz.ForbiddenErrorMessage) + } else { + require.NoError(t, err) + } + } + + testCases := []struct { + name string + user *fleet.User + shouldFail bool + }{ + {"no role", test.UserNoRoles, true}, + {"global admin", test.UserAdmin, false}, + {"global maintainer", test.UserMaintainer, true}, + {"global observer", test.UserObserver, true}, + {"global observer+", test.UserObserverPlus, true}, + {"global gitops", test.UserGitOps, false}, + {"team admin", test.UserTeamAdminTeam1, true}, + {"team maintainer", test.UserTeamMaintainerTeam1, true}, + {"team observer", test.UserTeamObserverTeam1, true}, + {"team observer+", test.UserTeamObserverPlusTeam1, true}, + {"team gitops", test.UserTeamGitOpsTeam1, true}, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + // prepare the context with the user + ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + + err := svc.MDMApplePreassignProfile(ctx, fleet.MDMApplePreassignProfilePayload{ + ExternalHostIdentifier: "test", + HostUUID: "test", + Profile: mobileconfigForTest("N1", "I1"), + }) + checkAuthErr(t, err, tt.shouldFail) + + err = svc.MDMAppleMatchPreassignment(ctx, "test") + checkAuthErr(t, err, tt.shouldFail) + }) + } +} + func mobileconfigForTest(name, identifier string) []byte { return []byte(fmt.Sprintf(` diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 04a698ae91..56ac201bb4 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -539,11 +539,12 @@ func (s *integrationMDMTestSuite) TestProfileManagement() { require.Equal(t, uint(0), noTeamSummaryResp.Verified) } -func (s *integrationMDMTestSuite) TestPuppetPreassignProfiles() { +func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { + ctx := context.Background() t := s.T() // create a host enrolled in fleet - mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) + mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) s.runWorker() // create a host that's not enrolled into MDM @@ -557,19 +558,155 @@ func (s *integrationMDMTestSuite) TestPuppetPreassignProfiles() { require.NoError(t, err) // preassign an empty profile, fails - s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "a", HostUUID: nonMDMHost.UUID, Profile: nil}}, http.StatusUnprocessableEntity) + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "empty", HostUUID: nonMDMHost.UUID, Profile: nil}}, http.StatusUnprocessableEntity) // preassign a valid profile to the MDM host - s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "a", HostUUID: mdmHost.UUID, Profile: mobileconfigForTest("n1", "i1")}}, http.StatusNoContent) + prof1 := mobileconfigForTest("n1", "i1") + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof1}}, http.StatusNoContent) // preassign another valid profile to the MDM host - s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "b", HostUUID: mdmHost.UUID, Profile: mobileconfigForTest("n2", "i2"), Group: "g1"}}, http.StatusNoContent) + prof2 := mobileconfigForTest("n2", "i2") + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof2, Group: "g1"}}, http.StatusNoContent) // preassign a valid profile to the non-MDM host, still works as the host is not validated in this call - s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "c", HostUUID: nonMDMHost.UUID, Profile: mobileconfigForTest("n3", "i3"), Group: "g2"}}, http.StatusNoContent) + prof3 := mobileconfigForTest("n3", "i3") + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "non-mdm", HostUUID: nonMDMHost.UUID, Profile: prof3, Group: "g2"}}, http.StatusNoContent) - // TODO(mna): when matching is implemented, add test cases to check team creation and host assignment - _ = mdmDevice + // match with an invalid external host id, succeeds as it is the same as if + // there was no matching to do (no preassignment was done) + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "no-such-id"}, http.StatusNoContent) + + // match with the non-mdm host fails + res := s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "non-mdm"}, http.StatusBadRequest) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "host is not enrolled in Fleet MDM") + + // match with the mdm host succeeds and creates a team based on the group labels + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm1"}, http.StatusNoContent) + + // the host is now part of that team + h, err := s.ds.Host(ctx, mdmHost.ID) + require.NoError(t, err) + require.NotNil(t, h.TeamID) + tm1, err := s.ds.Team(ctx, *h.TeamID) + require.NoError(t, err) + require.Regexp(t, `^g1 \(\d+-\d+-\d+:\d+:\d+:\d+\)$`, tm1.Name) + + // and the team has the expected profiles + profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID) + require.NoError(t, err) + require.Len(t, profs, 2) + // order is guaranteed by profile name + require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) + require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) + + // create a team and set profiles to it + tm2, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team2_" + t.Name(), + }) + require.NoError(t, err) + prof4 := mobileconfigForTest("n4", "i4") + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ + prof1, prof4, + }}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID)) + + // create another team with a superset of profiles + tm3, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team3_" + t.Name(), + }) + require.NoError(t, err) + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ + prof1, prof2, prof4, + }}, http.StatusNoContent, "team_id", fmt.Sprint(tm3.ID)) + + // and yet another team with the same profiles as tm3 + tm4, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team4_" + t.Name(), + }) + require.NoError(t, err) + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ + prof1, prof2, prof4, + }}, http.StatusNoContent, "team_id", fmt.Sprint(tm4.ID)) + + // preassign the MDM host to prof1 and prof4, should match existing team tm2 + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent) + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent) + + // match with the mdm host succeeds and assigns it to tm2 + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm1"}, http.StatusNoContent) + + // the host is now part of that team + h, err = s.ds.Host(ctx, mdmHost.ID) + require.NoError(t, err) + require.NotNil(t, h.TeamID) + require.Equal(t, tm2.ID, *h.TeamID) + + // the host's profiles are the same as the team's and are pending, and prof2 is pending removal + hostProfs, err := s.ds.GetHostMDMProfiles(ctx, mdmHost.UUID) + require.NoError(t, err) + require.Len(t, hostProfs, 3) + + sort.Slice(hostProfs, func(i, j int) bool { + l, r := hostProfs[i], hostProfs[j] + return l.Name < r.Name + }) + require.Equal(t, "n1", hostProfs[0].Name) + require.NotNil(t, hostProfs[0].Status) + require.Equal(t, fleet.MDMAppleDeliveryPending, *hostProfs[0].Status) + require.Equal(t, fleet.MDMAppleOperationTypeInstall, hostProfs[0].OperationType) + require.Equal(t, "n2", hostProfs[1].Name) + require.NotNil(t, hostProfs[1].Status) + require.Equal(t, fleet.MDMAppleDeliveryPending, *hostProfs[1].Status) + require.Equal(t, fleet.MDMAppleOperationTypeRemove, hostProfs[1].OperationType) + require.Equal(t, "n4", hostProfs[2].Name) + require.NotNil(t, hostProfs[2].Status) + require.Equal(t, fleet.MDMAppleDeliveryPending, *hostProfs[2].Status) + require.Equal(t, fleet.MDMAppleOperationTypeInstall, hostProfs[2].OperationType) + + // create a new mdm host enrolled in fleet + mdmHost2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) + s.runWorker() + // make it part of team 4 + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &tm4.ID, HostIDs: []uint{mdmHost2.ID}}, http.StatusOK) + + // simulate having its profiles installed + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.MacOSSettingsVerifying, mdmHost2.UUID) + return err + }) + + // preassign the MDM host to prof1, prof2 and prof4, should match existing + // team tm3 and tm4, and nothing be done since the host is already in tm4 + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent) + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof2, Group: "g2"}}, http.StatusNoContent) + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent) + s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm2"}, http.StatusNoContent) + + // the host is still part of tm4 + h, err = s.ds.Host(ctx, mdmHost2.ID) + require.NoError(t, err) + require.NotNil(t, h.TeamID) + require.Equal(t, tm4.ID, *h.TeamID) + + // and its profiles have been left untouched + hostProfs, err = s.ds.GetHostMDMProfiles(ctx, mdmHost2.UUID) + require.NoError(t, err) + require.Len(t, hostProfs, 3) + + sort.Slice(hostProfs, func(i, j int) bool { + l, r := hostProfs[i], hostProfs[j] + return l.Name < r.Name + }) + require.Equal(t, "n1", hostProfs[0].Name) + require.NotNil(t, hostProfs[0].Status) + require.Equal(t, fleet.MDMAppleDeliveryVerifying, *hostProfs[0].Status) + require.Equal(t, "n2", hostProfs[1].Name) + require.NotNil(t, hostProfs[1].Status) + require.Equal(t, fleet.MDMAppleDeliveryVerifying, *hostProfs[1].Status) + require.Equal(t, "n4", hostProfs[2].Name) + require.NotNil(t, hostProfs[2].Status) + require.Equal(t, fleet.MDMAppleDeliveryVerifying, *hostProfs[2].Status) } func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testing.T) (*fleet.Host, *mdmtest.TestMDMClient) { diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index ac21c9d3ce..98cbce1a28 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -92,6 +92,9 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf ssoStore = sso.NewSessionStore(opts[0].Pool) profMatcher = apple_mdm.NewProfileMatcher(opts[0].Pool) } + if opts[0].ProfileMatcher != nil { + profMatcher = opts[0].ProfileMatcher + } if opts[0].FailingPolicySet != nil { failingPolicySet = opts[0].FailingPolicySet } @@ -271,6 +274,7 @@ type TestServerOpts struct { StartCronSchedules []TestNewScheduleFunc UseMailService bool APNSTopic string + ProfileMatcher fleet.ProfileMatcher } func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) {