Match pre-assigned profiles to a team (or create one) and assign host to team (#12127)
This commit is contained in:
@@ -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.
|
||||
|
||||
+116
-2
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user