Implement preassign endpoint as first step to match profiles and hosts to teams (#12046)

This commit is contained in:
Martin Angers
2023-05-31 09:24:22 -04:00
committed by GitHub
parent 1349a08464
commit 4322a28f5a
15 changed files with 720 additions and 11 deletions
@@ -0,0 +1 @@
* 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.
+6
View File
@@ -602,6 +602,11 @@ the way that the Fleet server works.
}
if license.IsPremium() {
var profileMatcher fleet.ProfileMatcher
if appCfg.MDM.EnabledAndConfigured {
profileMatcher = apple_mdm.NewProfileMatcher(redisPool)
}
svc, err = eeservice.NewService(
svc,
ds,
@@ -613,6 +618,7 @@ the way that the Fleet server works.
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
mdmPushCertTopic,
ssoSessionStore,
profileMatcher,
)
if err != nil {
initFatal(err, "initial Fleet Premium service")
+68
View File
@@ -529,6 +529,8 @@ The MDM endpoints exist to support the related command-line interface sub-comman
- [Batch-apply Apple MDM custom settings](#batch-apply-apple-mdm-custom-settings)
- [Initiate SSO during DEP enrollment](#initiate-sso-during-dep-enrollment)
- [Complete SSO during DEP enrollment](#complete-sso-during-dep-enrollment)
- [Preassign profiles to devices](#preassign-profiles-to-devices)
- [Match preassigned profiles](#match-preassigned-profiles)
### Generate Apple DEP Key Pair
@@ -659,6 +661,72 @@ If the credentials are valid, the server redirects the client to the Fleet UI. T
- `profile_token` is a token that can be used to download an enrollment profile (.mobileconfig).
- `eula_token` (optional) if an EULA was uploaded, this contains a token that can be used to view the EULA document.
### Preassign profiles to devices
_Available in Fleet Premium_
This endpoint stores a profile to be assigned to a host at some point in the future. The actual assignment happens when the [Match preassigned profiles](#match-preassigned-profiles) endpoint is called. The reason for this "pre-assign" step is to collect all profiles that are meant to be assigned to a host, and match the list of profiles to an existing team (or create one with that set of profiles if none exist) so that the host can be assigned to that team and inherit its list of profiles.
`POST /api/v1/fleet/mdm/apple/profiles/preassign`
#### Parameters
| Name | Type | In | Description |
| ------------ | ------ | ---- | ----------------------------------------------------------- |
| external_host_identifier | string | body | **Required**. The identifier of the host as generated by the external service (e.g. Puppet). |
| host_uuid | string | body | **Required**. The UUID of the host. |
| profile | string | body | **Required**. The base64-encoded .mobileconfig content of the MDM profile. |
| group | string | body | The group label associated with that profile. This information is used to generate team names if they need to be created. |
#### Example
`POST /api/v1/fleet/mdm/apple/profiles/preassign`
##### Request body
```json
{
"external_host_identifier": "id-01234",
"host_uuid": "c0532a64-bec2-4cf9-aa37-96fe47ead814",
"profile": "<base64-encoded profile>",
"group": "Workstations"
}
```
##### Default response
`Status: 204`
### Match preassigned profiles
_Available in Fleet Premium_
This endpoint uses the profiles stored by the [Preassign profiles to devices][#preassign-profiles-to-devices] endpoint to match the set of profiles to an existing team if possible, creating one if none exists. It then assigns the host to that team so that it receives the associated profiles. It is meant to be called only once all desired profiles have been pre-assigned to the host.
`POST /api/v1/fleet/mdm/apple/profiles/match`
#### Parameters
| Name | Type | In | Description |
| ------------ | ------ | ---- | ----------------------------------------------------------- |
| external_host_identifier | string | body | **Required**. The identifier of the host as generated by the external service (e.g. Puppet). |
#### Example
`POST /api/v1/fleet/mdm/apple/profiles/match`
##### Request body
```json
{
"external_host_identifier": "id-01234"
}
```
##### Default response
`Status: 204`
## Get or apply configuration files
These API routes are used by the `fleetctl` CLI tool. Users can manage Fleet with `fleetctl` and [configuration files in YAML syntax](https://fleetdm.com/docs/using-fleet/configuration-files/).
+32
View File
@@ -714,3 +714,35 @@ func (svc *Service) getAutomaticEnrollmentProfile(ctx context.Context) (*fleet.M
}
return prof, nil
}
func (svc *Service) MDMApplePreassignProfile(ctx context.Context, payload fleet.MDMApplePreassignProfilePayload) error {
// for the preassign and match features, we don't know yet what team(s) will
// be affected, so we authorize only users with write-access to the no-team
// config profiles and with team-write access.
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleConfigProfile{}, fleet.ActionWrite); err != nil {
return ctxerr.Wrap(ctx, err)
}
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionWrite); err != nil {
return ctxerr.Wrap(ctx, err)
}
if err := svc.profileMatcher.PreassignProfile(ctx, payload); err != nil {
return ctxerr.Wrap(ctx, err, "preassign profile")
}
return nil
}
func (svc *Service) MDMAppleMatchPreassignment(ctx context.Context, externalHostIdentifier string) error {
// for the preassign and match features, we don't know yet what team(s) will
// be affected, so we authorize only users with write-access to the no-team
// config profiles and with team-write access.
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleConfigProfile{}, fleet.ActionWrite); err != nil {
return ctxerr.Wrap(ctx, err)
}
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionWrite); err != nil {
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)
return nil
}
+3
View File
@@ -27,6 +27,7 @@ type Service struct {
mdmPushCertTopic string
ssoSessionStore sso.SessionStore
depService *apple_mdm.DEPService
profileMatcher fleet.ProfileMatcher
}
func NewService(
@@ -40,6 +41,7 @@ func NewService(
mdmAppleCommander fleet.MDMAppleCommandIssuer,
mdmPushCertTopic string,
sso sso.SessionStore,
profileMatcher fleet.ProfileMatcher,
) (*Service, error) {
authorizer, err := authz.NewAuthorizer()
if err != nil {
@@ -58,6 +60,7 @@ func NewService(
mdmPushCertTopic: mdmPushCertTopic,
ssoSessionStore: sso,
depService: apple_mdm.NewDEPService(ds, depStorage, logger),
profileMatcher: profileMatcher,
}
// Override methods that can't be easily overriden via
+71
View File
@@ -59,6 +59,7 @@ func TestMDMApple(t *testing.T) {
{"TestMDMAppleEnrollmentProfile", testMDMAppleEnrollmentProfile},
{"TestListMDMAppleSerials", testListMDMAppleSerials},
{"TestMDMAppleDefaultSetupAssistant", testMDMAppleDefaultSetupAssistant},
{"TestMDMAppleConfigProfileHash", testMDMAppleConfigProfileHash},
}
for _, c := range cases {
@@ -3816,3 +3817,73 @@ func TestHostDEPAssignments(t *testing.T) {
require.False(t, *h.DEPAssignedToFleet)
})
}
func testMDMAppleConfigProfileHash(t *testing.T, ds *Datastore) {
// test that the mysql md5 hash exactly matches the hash produced by Go in
// the preassign profiles logic (no corner cases with extra whitespace, etc.)
ctx := context.Background()
// sprintf placeholders for prefix, content and suffix
const base = `%s<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Inc//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
%s
</plist>%s`
cases := []struct {
prefix, content, suffix string
}{
{"", "", ""},
{" ", "", ""},
{"", "", " "},
{"\t\n ", "", "\t\n "},
{"", `<dict>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadUUID</key>
<string>Ignored</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadIdentifier</key>
<string>Ignored</string>
</dict>`, ""},
{" ", `<dict>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadUUID</key>
<string>Ignored</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadIdentifier</key>
<string>Ignored</string>
</dict>`, "\r\n"},
}
for i, c := range cases {
t.Run(fmt.Sprintf("%q %q %q", c.prefix, c.content, c.suffix), func(t *testing.T) {
mc := mobileconfig.Mobileconfig(fmt.Sprintf(base, c.prefix, c.content, c.suffix))
prof, err := ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{
Name: fmt.Sprintf("profile-%d", i),
Identifier: fmt.Sprintf("profile-%d", i),
TeamID: nil,
Mobileconfig: mc,
})
require.NoError(t, err)
t.Cleanup(func() {
err := ds.DeleteMDMAppleConfigProfile(ctx, prof.ProfileID)
require.NoError(t, err)
})
goProf := fleet.MDMApplePreassignProfilePayload{Profile: mc}
goHash := goProf.HexMD5Hash()
require.NotEmpty(t, goHash)
var id uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &id, `SELECT profile_id FROM mdm_apple_configuration_profiles WHERE checksum = UNHEX(?)`, goHash)
})
require.Equal(t, prof.ProfileID, id)
})
}
}
+30
View File
@@ -2,6 +2,8 @@ package fleet
import (
"context"
"crypto/md5" // nolint: gosec
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
@@ -424,6 +426,26 @@ type MDMAppleFleetdConfig struct {
EnrollSecret string
}
// MDMApplePreassignProfilePayload is the payload accepted by the endpoint that
// preassigns profiles to hosts before generating corresponding teams for each
// unique set of profiles and assigning hosts to those teams and profiles. For
// example, puppet scripts use this.
type MDMApplePreassignProfilePayload struct {
ExternalHostIdentifier string `json:"external_host_identifier"`
HostUUID string `json:"host_uuid"`
Profile []byte `json:"profile"`
Group string `json:"group"`
}
// HexMD5Hash returns the hex-encoded MD5 hash of the profile. Note that MD5 is
// broken and we should consider moving to a better hash, but it needs to match
// the hashing algorithm used by the Mysql database for profiles (SHA2 would be
// 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[:])
}
// MDMAppleSettingsPayload describes the payload accepted by the endpoint to
// update specific MDM macos settings for a team (or no team).
type MDMAppleSettingsPayload struct {
@@ -528,3 +550,11 @@ type MDMAppleSetupAssistant struct {
func (a MDMAppleSetupAssistant) AuthzType() string {
return "mdm_apple_setup_assistant"
}
// ProfileMatcher defines the methods required to preassign and retrieve MDM
// profiles for matching with teams and associating with hosts. A Redis-based
// implementation is used in production.
type ProfileMatcher interface {
PreassignProfile(ctx context.Context, payload MDMApplePreassignProfilePayload) error
RetrieveProfiles(ctx context.Context, externalHostIdentifier string) error
}
+10
View File
@@ -661,6 +661,16 @@ type Service interface {
// team or for hosts with no team.
BatchSetMDMAppleProfiles(ctx context.Context, teamID *uint, teamName *string, profiles [][]byte, dryRun bool) error
// MDMApplePreassignProfile preassigns a profile to a host, pending the match
// request that will match the profiles to a team (or create one if needed),
// assign the host to that team and assign the profiles to the host.
MDMApplePreassignProfile(ctx context.Context, payload MDMApplePreassignProfilePayload) error
// MDMAppleMatchPreassignment matches the existing preassigned profiles to a
// team, creating one if none match, assigns the corresponding host to that
// team and assigns the matched team's profiles to the host.
MDMAppleMatchPreassignment(ctx context.Context, externalHostIdentifier string) error
// MDMAppleDeviceLock remote locks a host
MDMAppleDeviceLock(ctx context.Context, hostID uint) error
+113
View File
@@ -0,0 +1,113 @@
package apple_mdm
import (
"context"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/datastore/redis"
"github.com/fleetdm/fleet/v4/server/fleet"
redigo "github.com/gomodule/redigo/redis"
)
const (
preassignKeyPrefix = "mdm:preassign:"
// must be reasonably longer than the expected time to make all
// PreassignProfile calls and the final matcher call.
preassignKeyExpiration = 1 * time.Hour
)
type profileMatcher struct {
pool fleet.RedisPool
}
// NewProfileMatcher creates a new MDM profile matcher based on Redis.
func NewProfileMatcher(pool fleet.RedisPool) fleet.ProfileMatcher {
return &profileMatcher{pool: pool}
}
// PreassignProfile stores the profile associated with the host in Redis for
// later retrieval and matching to a team. Note that to keep this logic fast,
// we avoid accessing the mysql database, so the host is not validated at this
// stage (i.e. checking that it is valid, enrolled in MDM, etc.). It is done in
// the matching stage.
func (p *profileMatcher) PreassignProfile(ctx context.Context, payload fleet.MDMApplePreassignProfilePayload) error {
var invArg fleet.InvalidArgumentError
if payload.ExternalHostIdentifier == "" {
invArg.Append("external_host_identifier", "required")
}
if payload.HostUUID == "" {
invArg.Append("host_uuid", "required")
}
if len(payload.Profile) == 0 {
invArg.Append("profile", "required")
} else {
// team ID is not relevant at this stage, this is just for validation
if cp, err := fleet.NewMDMAppleConfigProfile(payload.Profile, nil); err != nil {
invArg.Append("profile", err.Error())
} else if err := cp.ValidateUserProvided(); err != nil {
invArg.Append("profile", err.Error())
}
}
if invArg.HasErrors() {
return ctxerr.Wrap(ctx, invArg)
}
md5Hash := payload.HexMD5Hash()
// 2 fields set if the top-level Redis hash key was newly created: host uuid
// and profile. If a group is provided, then it's 3 fields.
expectOnCreate := 2
args := []any{
// key is the prefix + the external identifier, all of this host's profiles
// will be stored under that hash, keyed by the md5-hash.
keyForExternalHostIdentifier(payload.ExternalHostIdentifier),
// the host uuid must be stored (cannot clash with other fields as they are
// hex-encoded hashes), will be a no-op if it was already stored (i.e. not
// the first profile for this host).
"host_uuid", payload.HostUUID,
// the profile itself is stored under its md5-hash field, no-op if it
// already existed.
md5Hash, payload.Profile,
}
if payload.Group != "" {
args = append(args, md5Hash+"_group", payload.Group)
expectOnCreate++
}
conn := redis.ConfigureDoer(p.pool, p.pool.Get())
defer conn.Close()
res, err := redigo.Int(conn.Do("HSET", args...))
if err != nil {
return ctxerr.Wrap(ctx, err, "execute redis HSET")
}
if res >= expectOnCreate {
// the key was created, set a TTL
if _, err := conn.Do("EXPIRE", args[0], preassignKeyExpiration.Seconds()); err != nil {
return ctxerr.Wrap(ctx, err, "execute redis EXPIRE")
}
}
return nil
}
// 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 {
// 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
}
func keyForExternalHostIdentifier(externalHostIdentifier string) string {
return preassignKeyPrefix + externalHostIdentifier
}
+265
View File
@@ -0,0 +1,265 @@
package apple_mdm
import (
"context"
"fmt"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/datastore/redis"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
redigo "github.com/gomodule/redigo/redis"
"github.com/stretchr/testify/require"
)
func TestPreassignProfile(t *testing.T) {
runTest := func(t *testing.T, pool fleet.RedisPool) {
ctx := context.Background()
matcher := NewProfileMatcher(pool)
// preassign a profile
p1 := fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: generateProfile("p1", "p1", "Configuration", "p1"),
Group: "g1",
}
err := matcher.PreassignProfile(ctx, p1)
require.NoError(t, err)
// should've set a TTL on the key
conn := redis.ConfigureDoer(pool, pool.Get())
defer conn.Close()
ttl1, err := redigo.Int(conn.Do("TTL", keyForExternalHostIdentifier("abcd")))
require.NoError(t, err)
require.NotZero(t, ttl1)
require.LessOrEqual(t, ttl1, int(preassignKeyExpiration.Seconds()))
// sleep a second to see the existing ttl go down
time.Sleep(time.Second)
// preassign another profile on the same host
p2 := fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: generateProfile("p2", "p2", "Configuration", "p2"),
Group: "g2",
}
err = matcher.PreassignProfile(ctx, p2)
require.NoError(t, err)
// key already existed, so it did not reset the ttl
ttl2, err := redigo.Int(conn.Do("TTL", keyForExternalHostIdentifier("abcd")))
require.NoError(t, err)
require.NotZero(t, ttl2)
require.Less(t, ttl2, ttl1)
// preassign another profile on the same host, without a group
p3 := fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: generateProfile("p3", "p3", "Configuration", "p3"),
}
err = matcher.PreassignProfile(ctx, p3)
require.NoError(t, err)
// key already existed, so it did not reset the ttl
ttl3, err := redigo.Int(conn.Do("TTL", keyForExternalHostIdentifier("abcd")))
require.NoError(t, err)
require.NotZero(t, ttl3)
require.Less(t, ttl3, ttl1)
// preassign the same profile on the same host, no change
err = matcher.PreassignProfile(ctx, p3)
require.NoError(t, err)
// key already existed, so it did not reset the ttl
ttl4, err := redigo.Int(conn.Do("TTL", keyForExternalHostIdentifier("abcd")))
require.NoError(t, err)
require.NotZero(t, ttl4)
require.Less(t, ttl4, ttl1)
// preassign a profile on a different host
p4 := fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "efgh",
HostUUID: "5678",
Profile: generateProfile("p4", "p4", "Configuration", "p4"),
Group: "g4",
}
err = matcher.PreassignProfile(ctx, p4)
require.NoError(t, err)
// original host's key unchanged
ttl5, err := redigo.Int(conn.Do("TTL", keyForExternalHostIdentifier("abcd")))
require.NoError(t, err)
require.NotZero(t, ttl5)
require.Less(t, ttl5, ttl1)
// new host's ttl is set
ttl1, err = redigo.Int(conn.Do("TTL", keyForExternalHostIdentifier("efgh")))
require.NoError(t, err)
require.NotZero(t, ttl1)
require.LessOrEqual(t, ttl1, int(preassignKeyExpiration.Seconds()))
// stored 3 profiles in original host
profs, err := redigo.StringMap(conn.Do("HGETALL", keyForExternalHostIdentifier("abcd")))
require.NoError(t, err)
require.Equal(t, map[string]string{
"host_uuid": "1234",
p1.HexMD5Hash(): string(p1.Profile),
p1.HexMD5Hash() + "_group": "g1",
p2.HexMD5Hash(): string(p2.Profile),
p2.HexMD5Hash() + "_group": "g2",
p3.HexMD5Hash(): string(p3.Profile),
}, profs)
// stored 1 profile in new host
profs, err = redigo.StringMap(conn.Do("HGETALL", keyForExternalHostIdentifier("efgh")))
require.NoError(t, err)
require.Equal(t, map[string]string{
"host_uuid": "5678",
p4.HexMD5Hash(): string(p4.Profile),
p4.HexMD5Hash() + "_group": "g4",
}, profs)
}
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)
matcher := NewProfileMatcher(pool)
cases := []struct {
desc string
payload fleet.MDMApplePreassignProfilePayload
err string
}{
{
"empty external host identifier",
fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "",
HostUUID: "1234",
Profile: generateProfile("p1", "p1", "Configuration", "p1"),
},
"external_host_identifier required",
},
{
"empty host uuid",
fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "",
Profile: generateProfile("p1", "p1", "Configuration", "p1"),
},
"host_uuid required",
},
{
"empty profile",
fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: nil,
},
"profile required",
},
{
"invalid profile",
fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: []byte(`abcd`),
},
"mobileconfig is not XML nor PKCS7",
},
{
"invalid profile type",
fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: generateProfile("p1", "p1", "abcd", "p1"),
},
"invalid PayloadType: abcd",
},
{
"empty payload identifier",
fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: generateProfile("p1", "", "Configuration", "p1"),
},
"empty PayloadIdentifier in profile",
},
{
"empty payload name",
fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: generateProfile("", "p1", "Configuration", "p1"),
},
"empty PayloadDisplayName in profile",
},
{
"invalid payload identifier",
fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: generateProfile("p1", mobileconfig.FleetFileVaultPayloadIdentifier, "Configuration", "p1"),
},
"payload identifier com.fleetdm.fleet.mdm.filevault is not allowed",
},
{
"valid",
fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: "abcd",
HostUUID: "1234",
Profile: generateProfile("p1", "p1", "Configuration", "p1"),
Group: "g1",
},
"",
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
err := matcher.PreassignProfile(ctx, c.payload)
if c.err != "" {
require.ErrorContains(t, err, c.err)
} else {
require.NoError(t, err)
}
})
}
}
func generateProfile(name, ident, typ, uuid 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">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array/>
<key>PayloadDisplayName</key>
<string>%s</string>
<key>PayloadIdentifier</key>
<string>%s</string>
<key>PayloadType</key>
<string>%s</string>
<key>PayloadUUID</key>
<string>%s</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
`, name, ident, typ, uuid))
}
+64
View File
@@ -1547,6 +1547,70 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm
return nil
}
////////////////////////////////////////////////////////////////////////////////
// Preassign a profile to a host
////////////////////////////////////////////////////////////////////////////////
type preassignMDMAppleProfileRequest struct {
fleet.MDMApplePreassignProfilePayload
}
type preassignMDMAppleProfileResponse struct {
Err error `json:"error,omitempty"`
}
func (r preassignMDMAppleProfileResponse) error() error { return r.Err }
func (r preassignMDMAppleProfileResponse) Status() int { return http.StatusNoContent }
func preassignMDMAppleProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*preassignMDMAppleProfileRequest)
if err := svc.MDMApplePreassignProfile(ctx, req.MDMApplePreassignProfilePayload); err != nil {
return preassignMDMAppleProfileResponse{Err: err}, nil
}
return preassignMDMAppleProfileResponse{}, nil
}
func (svc *Service) MDMApplePreassignProfile(ctx context.Context, payload fleet.MDMApplePreassignProfilePayload) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// Match a set of pre-assigned profiles with a team
////////////////////////////////////////////////////////////////////////////////
type matchMDMApplePreassignmentRequest struct {
ExternalHostIdentifier string `json:"external_host_identifier"`
}
type matchMDMApplePreassignmentResponse struct {
Err error `json:"error,omitempty"`
}
func (r matchMDMApplePreassignmentResponse) error() error { return r.Err }
func (r matchMDMApplePreassignmentResponse) Status() int { return http.StatusNoContent }
func matchMDMApplePreassignmentEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*matchMDMApplePreassignmentRequest)
if err := svc.MDMAppleMatchPreassignment(ctx, req.ExternalHostIdentifier); err != nil {
return matchMDMApplePreassignmentResponse{Err: err}, nil
}
return matchMDMApplePreassignmentResponse{}, nil
}
func (svc *Service) MDMAppleMatchPreassignment(ctx context.Context, ref string) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// Update MDM Apple Settings
////////////////////////////////////////////////////////////////////////////////
+3
View File
@@ -486,6 +486,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
mdm.GET("/api/_version_/fleet/mdm/apple/setup/eula/metadata", getMDMAppleEULAMetadataEndpoint, getMDMAppleEULAMetadataRequest{})
mdm.DELETE("/api/_version_/fleet/mdm/apple/setup/eula/{token}", deleteMDMAppleEULAEndpoint, deleteMDMAppleEULARequest{})
mdm.POST("/api/_version_/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileEndpoint, preassignMDMAppleProfileRequest{})
mdm.POST("/api/_version_/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentEndpoint, matchMDMApplePreassignmentRequest{})
// the following set of mdm endpoints must always be accessible (even
// if MDM is not configured) as it bootstraps the setup of MDM
// (generates CSR request for APNs, plus the SCEP and ABM keypairs).
+7 -2
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/http/httptest"
"net/url"
@@ -6332,8 +6333,12 @@ func (s *integrationTestSuite) TestAppleMDMNotConfigured() {
createHostAndDeviceToken(t, s.ds, tkn)
for _, route := range mdmAppleConfigurationRequiredEndpoints() {
which := fmt.Sprintf("%s %s", route.method, route.path)
log.Print(which)
var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured
if route.premiumOnly {
if route.premiumOnly && route.deviceAuthenticated {
// user-authenticated premium-only routes will never see the ErrMissingLicense error
// if mdm is not configured, as the MDM middleware will intercept and fail the call.
expectedErr = fleet.ErrMissingLicense
}
path := route.path
@@ -6342,7 +6347,7 @@ func (s *integrationTestSuite) TestAppleMDMNotConfigured() {
}
res := s.Do(route.method, path, nil, expectedErr.StatusCode())
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, expectedErr.Error())
assert.Contains(t, errMsg, expectedErr.Error(), which)
}
fleetdmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+33
View File
@@ -531,6 +531,39 @@ func (s *integrationMDMTestSuite) TestProfileManagement() {
require.Equal(t, uint(0), noTeamSummaryResp.Verifying)
}
func (s *integrationMDMTestSuite) TestPuppetPreassignProfiles() {
t := s.T()
// create a host enrolled in fleet
mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.runWorker()
// create a host that's not enrolled into MDM
nonMDMHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
OsqueryHostID: ptr.String("not-mdm-enrolled"),
NodeKey: ptr.String("not-mdm-enrolled"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
Platform: "darwin",
})
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)
// 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)
// 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)
// 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)
// TODO(mna): when matching is implemented, add test cases to check team creation and host assignment
_ = mdmDevice
}
func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testing.T) (*fleet.Host, *mdmtest.TestMDMClient) {
desktopToken := uuid.New().String()
mdmDevice := mdmtest.NewTestMDMClientDesktopManual(fleetServerURL, desktopToken)
+14 -9
View File
@@ -53,18 +53,19 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
osqlogger := &OsqueryLogger{Status: writer, Result: writer}
logger := kitlog.NewNopLogger()
var ssoStore sso.SessionStore
var (
failingPolicySet fleet.FailingPolicySet = NewMemFailingPolicySet()
enrollHostLimiter fleet.EnrollHostLimiter = nopEnrollHostLimiter{}
is fleet.InstallerStore
mdmStorage nanomdm_storage.AllStorage
failingPolicySet fleet.FailingPolicySet = NewMemFailingPolicySet()
enrollHostLimiter fleet.EnrollHostLimiter = nopEnrollHostLimiter{}
depStorage nanodep_storage.AllStorage = &nanodep_mock.Storage{}
mdmPusher nanomdm_push.Pusher
mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }}
mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }}
c clock.Clock = clock.C
is fleet.InstallerStore
mdmStorage nanomdm_storage.AllStorage
mdmPusher nanomdm_push.Pusher
ssoStore sso.SessionStore
profMatcher fleet.ProfileMatcher
)
var c clock.Clock = clock.C
if len(opts) > 0 {
if opts[0].Clock != nil {
c = opts[0].Clock
@@ -89,6 +90,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
}
if opts[0].Pool != nil {
ssoStore = sso.NewSessionStore(opts[0].Pool)
profMatcher = apple_mdm.NewProfileMatcher(opts[0].Pool)
}
if opts[0].FailingPolicySet != nil {
failingPolicySet = opts[0].FailingPolicySet
@@ -166,6 +168,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher),
"",
ssoStore,
profMatcher,
)
if err != nil {
panic(err)
@@ -602,5 +605,7 @@ func mdmAppleConfigurationRequiredEndpoints() []struct {
{"POST", "/api/latest/fleet/mdm/apple/enrollment_profile", false, false},
{"DELETE", "/api/latest/fleet/mdm/apple/enrollment_profile", false, false},
{"POST", "/api/latest/fleet/device/%s/migrate_mdm", true, true},
{"POST", "/api/latest/fleet/mdm/apple/profiles/preassign", false, true},
{"POST", "/api/latest/fleet/mdm/apple/profiles/match", false, true},
}
}