Implement preassign endpoint as first step to match profiles and hosts to teams (#12046)
This commit is contained in:
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user