Skip to content

Commit

Permalink
Full support of secret variables in Apple configuration profiles (#24925
Browse files Browse the repository at this point in the history
)

For secrets subtask #24548

Fixed secret variables support in Apple configuration profiles.

# Checklist for submitter

- [x] Added/updated tests
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Manual QA for all new/changed functionality
  • Loading branch information
getvictor authored Dec 20, 2024
1 parent fe5834d commit ad6edec
Show file tree
Hide file tree
Showing 27 changed files with 425 additions and 133 deletions.
1 change: 1 addition & 0 deletions cmd/fleet/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,7 @@ the way that the Fleet server works.
logger,
mdmCheckinAndCommandService,
ddmService,
commander,
); err != nil {
initFatal(err, "setup mdm apple services")
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/fleetctl/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ func TestApplyTeamSpecs(t *testing.T) {
ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error {
return nil
}
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

filename := writeTmpYml(t, `
---
Expand Down Expand Up @@ -1359,6 +1362,9 @@ func TestApplyAsGitOps(t *testing.T) {
ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
return []*fleet.VPPTokenDB{}, nil
}
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

// Apply global config.
name := writeTmpYml(t, `---
Expand Down
11 changes: 11 additions & 0 deletions cmd/fleetctl/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,10 @@ func TestGitOpsFullGlobal(t *testing.T) {
return []*fleet.ABMToken{}, nil
}

ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

const (
fleetServerURL = "https://fleet.example.com"
orgName = "GitOps Test"
Expand Down Expand Up @@ -861,6 +865,10 @@ func TestGitOpsFullTeam(t *testing.T) {
return nil
}

ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

// Queries
query := fleet.Query{}
query.ID = 1
Expand Down Expand Up @@ -2551,6 +2559,9 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error {
return nil
}
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

t.Setenv("FLEET_SERVER_URL", fleetServerURL)
t.Setenv("ORG_NAME", orgName)
Expand Down
2 changes: 1 addition & 1 deletion cmd/fleetctl/mdm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ func TestMDMRunCommand(t *testing.T) {
return res, nil
}

enqueuer.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
enqueuer.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
return map[string]error{}, nil
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package tables

import (
"database/sql"
"fmt"

"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)

func init() {
MigrationClient.AddMigration(Up_20241220100000, Down_20241220100000)
}

func Up_20241220100000(tx *sql.Tx) error {
if !columnExists(tx, "nano_commands", "subtype") {
_, err := tx.Exec(fmt.Sprintf(`
ALTER TABLE nano_commands
ADD COLUMN subtype enum('%s','%s') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '%s'`,
mdm.CommandSubtypeNone, mdm.CommandSubtypeProfileWithSecrets, mdm.CommandSubtypeNone))
if err != nil {
return fmt.Errorf("failed to create nano_commands.subtype column: %w", err)
}
}

// With secret variable support, it is possible to have the whole profile as one secret ($FLEET_SECRET_PROFILE),
// which will not be XML when stored. It is cleaner to remove the check than to add a special caveat to documentation.
if constraintExists(tx, "nano_commands", "nano_commands_chk_3") {
_, err := tx.Exec(`ALTER TABLE nano_commands DROP CONSTRAINT nano_commands_chk_3`)
if err != nil {
return fmt.Errorf("failed to drop nano_commands_chk_3 constraint: %w", err)
}
}

return nil
}

func Down_20241220100000(_ *sql.Tx) error {
return nil
}
16 changes: 16 additions & 0 deletions server/datastore/mysql/migrations/tables/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ AND CONSTRAINT_NAME = ?
return count > 0
}

func constraintExists(tx *sql.Tx, table, name string) bool {
var count int
err := tx.QueryRow(`
SELECT COUNT(1)
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = ?
`, table, name).Scan(&count)
if err != nil {
return false
}

return count > 0
}

func columnExists(tx *sql.Tx, table, column string) bool {
return columnsExists(tx, table, column)
}
Expand Down
8 changes: 4 additions & 4 deletions server/datastore/mysql/schema.sql

Large diffs are not rendered by default.

40 changes: 33 additions & 7 deletions server/mdm/apple/commander.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,18 @@ func NewMDMAppleCommander(mdmStorage fleet.MDMAppleStore, mdmPushService nanomdm
// InstallProfile sends the homonymous MDM command to the given hosts, it also
// takes care of the base64 encoding of the provided profile bytes.
func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error {
raw, err := svc.SignAndEncodeInstallProfile(ctx, profile, uuid)
if err != nil {
return err
}
err = svc.EnqueueCommand(ctx, hostUUIDs, raw)
return ctxerr.Wrap(ctx, err, "commander install profile")
}

func (svc *MDMAppleCommander) SignAndEncodeInstallProfile(ctx context.Context, profile []byte, commandUUID string) (string, error) {
signedProfile, err := mdmcrypto.Sign(ctx, profile, svc.storage)
if err != nil {
return ctxerr.Wrap(ctx, err, "signing profile")
return "", ctxerr.Wrap(ctx, err, "signing profile")
}

base64Profile := base64.StdEncoding.EncodeToString(signedProfile)
Expand All @@ -66,12 +75,11 @@ func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []st
<data>%s</data>
</dict>
</dict>
</plist>`, uuid, base64Profile)
err = svc.EnqueueCommand(ctx, hostUUIDs, raw)
return ctxerr.Wrap(ctx, err, "commander install profile")
</plist>`, commandUUID, base64Profile)
return raw, nil
}

// InstallProfile sends the homonymous MDM command to the given hosts.
// RemoveProfile sends the homonymous MDM command to the given hosts.
func (svc *MDMAppleCommander) RemoveProfile(ctx context.Context, hostUUIDs []string, profileIdentifier string, uuid string) error {
raw := 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">
Expand Down Expand Up @@ -366,17 +374,35 @@ func (svc *MDMAppleCommander) EnqueueCommand(ctx context.Context, hostUUIDs []st
return ctxerr.Wrap(ctx, err, "decoding command")
}

if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs, cmd); err != nil {
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone)
}

func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []string, cmd *mdm.Command,
subtype mdm.CommandSubtype) error {
if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs,
&mdm.CommandWithSubtype{Command: *cmd, Subtype: subtype}); err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing command")
}

if err := svc.SendNotifications(ctx, hostUUIDs); err != nil {
return ctxerr.Wrap(ctx, err, "sending notifications")
}

return nil
}

// EnqueueCommandInstallProfileWithSecrets is a special case of EnqueueCommand that does not expand secret variables.
// Secret variables are expanded when the command is sent to the device, and secrets are never stored in the database unencrypted.
func (svc *MDMAppleCommander) EnqueueCommandInstallProfileWithSecrets(ctx context.Context, hostUUIDs []string,
rawCommand mobileconfig.Mobileconfig, commandUUID string) error {
cmd := &mdm.Command{
CommandUUID: commandUUID,
Raw: []byte(rawCommand),
}
cmd.Command.RequestType = "InstallProfile"

return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeProfileWithSecrets)
}

func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs []string) error {
apnsResponses, err := svc.pusher.Push(ctx, hostUUIDs)
if err != nil {
Expand Down
12 changes: 6 additions & 6 deletions server/mdm/apple/commander_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ func TestMDMAppleCommander(t *testing.T) {
payloadIdentifier := "com-foo-bar"
mc := mobileconfigForTest(payloadName, payloadIdentifier)

mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
require.Equal(t, cmd.Command.RequestType, "InstallProfile")
require.Equal(t, cmd.Command.Command.RequestType, "InstallProfile")
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload)
Expand Down Expand Up @@ -96,9 +96,9 @@ func TestMDMAppleCommander(t *testing.T) {
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
mdmStorage.RetrievePushInfoFuncInvoked = false

mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
require.Equal(t, "RemoveProfile", cmd.Command.RequestType)
require.Equal(t, "RemoveProfile", cmd.Command.Command.RequestType)
require.Contains(t, string(cmd.Raw), payloadIdentifier)
return nil, nil
}
Expand All @@ -111,9 +111,9 @@ func TestMDMAppleCommander(t *testing.T) {
require.NoError(t, err)

cmdUUID = uuid.New().String()
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType)
require.Equal(t, "InstallEnterpriseApplication", cmd.Command.Command.RequestType)
require.Contains(t, string(cmd.Raw), "http://test.example.com")
require.Contains(t, string(cmd.Raw), cmdUUID)
return nil, nil
Expand Down
2 changes: 1 addition & 1 deletion server/mdm/nanomdm/http/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ func RawCommandEnqueueHandler(enqueuer storage.CommandEnqueuer, pusher push.Push
logs := []interface{}{
"msg", "enqueue",
}
idErrs, err := enqueuer.EnqueueCommand(ctx, ids, command)
idErrs, err := enqueuer.EnqueueCommand(ctx, ids, &mdm.CommandWithSubtype{Command: *command, Subtype: mdm.CommandSubtypeNone})
ct := len(ids) - len(idErrs)
if err != nil {
logs = append(logs, "err", err)
Expand Down
12 changes: 12 additions & 0 deletions server/mdm/nanomdm/mdm/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ func DecodeCommandResults(rawResults []byte) (results *CommandResults, err error
return
}

type CommandSubtype string

const (
CommandSubtypeNone CommandSubtype = "None"
CommandSubtypeProfileWithSecrets CommandSubtype = "ProfileWithSecrets"
)

// Command represents a generic MDM command without command-specific fields.
type Command struct {
CommandUUID string
Expand All @@ -52,6 +59,11 @@ type Command struct {
Raw []byte `plist:"-"` // Original command XML plist
}

type CommandWithSubtype struct {
Command
Subtype CommandSubtype
}

// DecodeCommand unmarshals rawCommand into command
func DecodeCommand(rawCommand []byte) (command *Command, err error) {
command = new(Command)
Expand Down
56 changes: 40 additions & 16 deletions server/mdm/nanomdm/service/nanomdm/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ type Service struct {

// GetToken handler
gt service.GetToken

// ProfileService
ps service.ProfileService
}

// normalize generates enrollment IDs that are used by other
Expand Down Expand Up @@ -71,6 +74,12 @@ func WithDeclarativeManagement(dm service.DeclarativeManagement) Option {
}
}

func WithProfileService(ps service.ProfileService) Option {
return func(s *Service) {
s.ps = ps
}
}

// WithUserAuthenticate configures a UserAuthenticate check-in message handler.
func WithUserAuthenticate(ua service.UserAuthenticate) Option {
return func(s *Service) {
Expand Down Expand Up @@ -253,25 +262,40 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe
if err != nil {
return nil, fmt.Errorf("retrieving next command: %w", err)
}
if cmd != nil {
if cmd == nil {
logger.Debug(
"msg", "command retrieved",
"command_uuid", cmd.CommandUUID,
"request_type", cmd.Command.RequestType,
"msg", "no command retrieved",
)
// We expand secrets in the command before returning it to the caller so that we never store unencrypted secrets in the database.
expanded, err := s.store.ExpandEmbeddedSecrets(r.Context, string(cmd.Raw))
if err != nil {
// This error is not expected since secrets should have been validated on profile upload.
logger.Info("level", "error", "msg", "expanding embedded secrets", "err", err)
// Since this error should not happen, we use the command as is, without expanding secrets.
} else {
cmd.Raw = []byte(expanded)
}
return cmd, nil
return nil, nil
}
logger.Debug(
"msg", "no command retrieved",
"msg", "command retrieved",
"command_uuid", cmd.CommandUUID,
"request_type", cmd.Command.Command.RequestType,
"subtype", cmd.Subtype,
)
return nil, nil
// We expand secrets in the command before returning it to the caller so that we never store unencrypted secrets in the database.
// User can issue a one-off MDM command that contains a secret variable, for example.
expanded, err := s.store.ExpandEmbeddedSecrets(r.Context, string(cmd.Raw))
if err != nil {
// This error should never happen since secrets have been validated on profile upload.
logger.Info("level", "error", "msg", "expanding embedded secrets", "err", err)
// Since this error should not happen, we simply use the command as is, without expanding secrets.
} else {
cmd.Raw = []byte(expanded)
}
switch cmd.Subtype {
case mdm.CommandSubtypeProfileWithSecrets:
// Secrets were expanded above. Now we need to base64 encode and sign the configuration profile before returning it to the caller.
processed, err := s.ps.SignAndEncodeInstallProfile(r.Context, cmd.Raw, cmd.CommandUUID)
if err != nil {
logger.Info("level", "error", "msg", "signing and encoding profile", "err", err)
// Since this error should not normally happen, we simply use the command as is. This way the client can fetch the next command and will not be blocked.
return &cmd.Command, nil
}
cmd.Raw = []byte(processed)
return &cmd.Command, nil
default:
return &cmd.Command, nil
}
}
7 changes: 7 additions & 0 deletions server/mdm/nanomdm/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
package service

import (
"context"

"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)

Expand Down Expand Up @@ -46,3 +48,8 @@ type CheckinAndCommandService interface {
Checkin
CommandAndReportResults
}

// ProfileService represents the interface to call specific functions from Fleet's main services.
type ProfileService interface {
SignAndEncodeInstallProfile(ctx context.Context, profile []byte, commandUUID string) (string, error)
}
7 changes: 4 additions & 3 deletions server/mdm/nanomdm/storage/allmulti/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ func (ms *MultiAllStorage) StoreCommandReport(r *mdm.Request, report *mdm.Comman
return err
}

func (ms *MultiAllStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) {
func (ms *MultiAllStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.CommandWithSubtype, error) {
val, err := ms.execStores(r.Context, func(s storage.AllStorage) (interface{}, error) {
return s.RetrieveNextCommand(r, skipNotNow)
})
return val.(*mdm.Command), err
return val.(*mdm.CommandWithSubtype), err
}

func (ms *MultiAllStorage) ClearQueue(r *mdm.Request) error {
Expand All @@ -28,7 +28,8 @@ func (ms *MultiAllStorage) ClearQueue(r *mdm.Request) error {
return err
}

func (ms *MultiAllStorage) EnqueueCommand(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
func (ms *MultiAllStorage) EnqueueCommand(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error,
error) {
val, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) {
return s.EnqueueCommand(ctx, id, cmd)
})
Expand Down
Loading

0 comments on commit ad6edec

Please sign in to comment.