diff --git a/changes/issue-9643-fleetctl-mdm-run-command b/changes/issue-9643-fleetctl-mdm-run-command new file mode 100644 index 0000000000..efa70aeb5b --- /dev/null +++ b/changes/issue-9643-fleetctl-mdm-run-command @@ -0,0 +1 @@ +* Added the `fleetctl mdm run-command` command, to run any of the [Apple-supported MDM commands](https://developer.apple.com/documentation/devicemanagement/commands_and_queries) on a host. diff --git a/cmd/fleetctl/apple_mdm.go b/cmd/fleetctl/apple_mdm.go index a706f3e1f3..04c1fce012 100644 --- a/cmd/fleetctl/apple_mdm.go +++ b/cmd/fleetctl/apple_mdm.go @@ -11,7 +11,6 @@ import ( "strings" "github.com/fleetdm/fleet/v4/server/fleet" - apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/service" "github.com/groob/plist" "github.com/micromdm/micromdm/mdm/appmanifest" @@ -50,172 +49,6 @@ func appleMDMCommand() *cli.Command { } } -func generateCommand() *cli.Command { - return &cli.Command{ - Name: "generate", - // TODO: Remove when Apple MDM is production ready. - Hidden: true, - Flags: []cli.Flag{ - configFlag(), - contextFlag(), - debugFlag(), - }, - Subcommands: []*cli.Command{ - generateMDMAppleCommand(), - generateMDMAppleBMCommand(), - }, - } -} - -func generateMDMAppleCommand() *cli.Command { - return &cli.Command{ - Name: "mdm-apple", - Aliases: []string{"mdm_apple"}, - Usage: "Generates certificate signing request (CSR) and key for Apple Push Notification Service (APNs) and certificate and key for Simple Certificate Enrollment Protocol (SCEP) to turn on MDM features.", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "email", - Usage: "The email address to send the signed APNS csr to.", - Required: true, - }, - &cli.StringFlag{ - Name: "org", - Usage: "The organization requesting the signed APNS csr.", - Required: true, - }, - &cli.StringFlag{ - Name: "apns-key", - Usage: "The output path for the APNs private key.", - Value: apnsKeyPath, - }, - &cli.StringFlag{ - Name: "scep-cert", - Usage: "The output path for the SCEP CA certificate.", - Value: scepCACertPath, - }, - &cli.StringFlag{ - Name: "scep-key", - Usage: "The output path for the SCEP CA private key.", - Value: scepCAKeyPath, - }, - }, - Action: func(c *cli.Context) error { - email := c.String("email") - org := c.String("org") - apnsKeyPath := c.String("apns-key") - scepCACertPath := c.String("scep-cert") - scepCAKeyPath := c.String("scep-key") - - // get the fleet API client first, so that any login requirement are met - // before printing the CSR output message. - client, err := clientFromCLI(c) - if err != nil { - return err - } - - fmt.Fprintf( - c.App.Writer, - `Sending certificate signing request (CSR) for Apple Push Notification service (APNs) to %s... -Generating APNs key, Simple Certificate Enrollment Protocol (SCEP) certificate, and SCEP key... - -`, - email, - ) - - csr, err := client.RequestAppleCSR(email, org) - if err != nil { - return err - } - - if err := os.WriteFile(apnsKeyPath, csr.APNsKey, 0600); err != nil { - return fmt.Errorf("failed to write APNs private key: %w", err) - } - if err := os.WriteFile(scepCACertPath, csr.SCEPCert, 0600); err != nil { - return fmt.Errorf("failed to write SCEP CA certificate: %w", err) - } - if err := os.WriteFile(scepCAKeyPath, csr.SCEPKey, 0600); err != nil { - return fmt.Errorf("failed to write SCEP CA private key: %w", err) - } - - fmt.Fprintf( - c.App.Writer, - `Success! - -Generated your APNs key at %s - -Generated your SCEP certificate at %s - -Generated your SCEP key at %s - -Go to your email to download a CSR from Fleet. Then, visit https://identity.apple.com/pushcert to upload the CSR. You should receive an APNs certificate in return from Apple. - -Next, use the generated certificates to deploy Fleet with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm -`, - apnsKeyPath, - scepCACertPath, - scepCAKeyPath, - ) - - return nil - }, - } -} - -func generateMDMAppleBMCommand() *cli.Command { - return &cli.Command{ - Name: "mdm-apple-bm", - Aliases: []string{"mdm_apple_bm"}, - Usage: "Generate Apple Business Manager public and private keys to enable automatic enrollment for macOS hosts.", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "public-key", - Usage: "The output path for the Apple Business Manager public key certificate.", - Value: bmPublicKeyCertPath, - }, - &cli.StringFlag{ - Name: "private-key", - Usage: "The output path for the Apple Business Manager private key.", - Value: bmPrivateKeyPath, - }, - }, - Action: func(c *cli.Context) error { - publicKeyPath := c.String("public-key") - privateKeyPath := c.String("private-key") - - publicKeyPEM, privateKeyPEM, err := apple_mdm.NewDEPKeyPairPEM() - if err != nil { - return fmt.Errorf("generate key pair: %w", err) - } - - if err := os.WriteFile(publicKeyPath, publicKeyPEM, defaultFileMode); err != nil { - return fmt.Errorf("write public key: %w", err) - } - - if err := os.WriteFile(privateKeyPath, privateKeyPEM, defaultFileMode); err != nil { - return fmt.Errorf("write private key: %w", err) - } - - fmt.Fprintf( - c.App.Writer, - `Success! - -Generated your public key at %s - -Generated your private key at %s - -Visit https://business.apple.com/ and create a new MDM server with the public key. Then, download the new MDM server's token. - -Next, deploy Fleet with with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm -`, - publicKeyPath, - privateKeyPath, - ) - - return nil - }, - } -} - func appleMDMEnrollmentProfilesCommand() *cli.Command { return &cli.Command{ Name: "enrollment-profiles", diff --git a/cmd/fleetctl/fleetctl.go b/cmd/fleetctl/fleetctl.go index 2b1bd006ed..f817d031df 100644 --- a/cmd/fleetctl/fleetctl.go +++ b/cmd/fleetctl/fleetctl.go @@ -101,6 +101,7 @@ func createApp(reader io.Reader, writer io.Writer, exitErrHandler cli.ExitErrHan }, }, triggerCommand(), + mdmCommand(), } return app } diff --git a/cmd/fleetctl/generate.go b/cmd/fleetctl/generate.go new file mode 100644 index 0000000000..79a4357292 --- /dev/null +++ b/cmd/fleetctl/generate.go @@ -0,0 +1,174 @@ +package main + +import ( + "fmt" + "os" + + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/urfave/cli/v2" +) + +func generateCommand() *cli.Command { + return &cli.Command{ + Name: "generate", + Usage: "Generate certificates and keys required for MDM", + Flags: []cli.Flag{ + configFlag(), + contextFlag(), + debugFlag(), + }, + Subcommands: []*cli.Command{ + generateMDMAppleCommand(), + generateMDMAppleBMCommand(), + }, + } +} + +func generateMDMAppleCommand() *cli.Command { + return &cli.Command{ + Name: "mdm-apple", + Aliases: []string{"mdm_apple"}, + Usage: "Generates certificate signing request (CSR) and key for Apple Push Notification Service (APNs) and certificate and key for Simple Certificate Enrollment Protocol (SCEP) to turn on MDM features.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "email", + Usage: "The email address to send the signed APNS csr to.", + Required: true, + }, + &cli.StringFlag{ + Name: "org", + Usage: "The organization requesting the signed APNS csr.", + Required: true, + }, + &cli.StringFlag{ + Name: "apns-key", + Usage: "The output path for the APNs private key.", + Value: apnsKeyPath, + }, + &cli.StringFlag{ + Name: "scep-cert", + Usage: "The output path for the SCEP CA certificate.", + Value: scepCACertPath, + }, + &cli.StringFlag{ + Name: "scep-key", + Usage: "The output path for the SCEP CA private key.", + Value: scepCAKeyPath, + }, + }, + Action: func(c *cli.Context) error { + email := c.String("email") + org := c.String("org") + apnsKeyPath := c.String("apns-key") + scepCACertPath := c.String("scep-cert") + scepCAKeyPath := c.String("scep-key") + + // get the fleet API client first, so that any login requirement are met + // before printing the CSR output message. + client, err := clientFromCLI(c) + if err != nil { + return err + } + + fmt.Fprintf( + c.App.Writer, + `Sending certificate signing request (CSR) for Apple Push Notification service (APNs) to %s... +Generating APNs key, Simple Certificate Enrollment Protocol (SCEP) certificate, and SCEP key... + +`, + email, + ) + + csr, err := client.RequestAppleCSR(email, org) + if err != nil { + return err + } + + if err := os.WriteFile(apnsKeyPath, csr.APNsKey, defaultFileMode); err != nil { + return fmt.Errorf("failed to write APNs private key: %w", err) + } + if err := os.WriteFile(scepCACertPath, csr.SCEPCert, defaultFileMode); err != nil { + return fmt.Errorf("failed to write SCEP CA certificate: %w", err) + } + if err := os.WriteFile(scepCAKeyPath, csr.SCEPKey, defaultFileMode); err != nil { + return fmt.Errorf("failed to write SCEP CA private key: %w", err) + } + + fmt.Fprintf( + c.App.Writer, + `Success! + +Generated your APNs key at %s + +Generated your SCEP certificate at %s + +Generated your SCEP key at %s + +Go to your email to download a CSR from Fleet. Then, visit https://identity.apple.com/pushcert to upload the CSR. You should receive an APNs certificate in return from Apple. + +Next, use the generated certificates to deploy Fleet with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm +`, + apnsKeyPath, + scepCACertPath, + scepCAKeyPath, + ) + + return nil + }, + } +} + +func generateMDMAppleBMCommand() *cli.Command { + return &cli.Command{ + Name: "mdm-apple-bm", + Aliases: []string{"mdm_apple_bm"}, + Usage: "Generate Apple Business Manager public and private keys to enable automatic enrollment for macOS hosts.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "public-key", + Usage: "The output path for the Apple Business Manager public key certificate.", + Value: bmPublicKeyCertPath, + }, + &cli.StringFlag{ + Name: "private-key", + Usage: "The output path for the Apple Business Manager private key.", + Value: bmPrivateKeyPath, + }, + }, + Action: func(c *cli.Context) error { + publicKeyPath := c.String("public-key") + privateKeyPath := c.String("private-key") + + publicKeyPEM, privateKeyPEM, err := apple_mdm.NewDEPKeyPairPEM() + if err != nil { + return fmt.Errorf("generate key pair: %w", err) + } + + if err := os.WriteFile(publicKeyPath, publicKeyPEM, defaultFileMode); err != nil { + return fmt.Errorf("write public key: %w", err) + } + + if err := os.WriteFile(privateKeyPath, privateKeyPEM, defaultFileMode); err != nil { + return fmt.Errorf("write private key: %w", err) + } + + fmt.Fprintf( + c.App.Writer, + `Success! + +Generated your public key at %s + +Generated your private key at %s + +Visit https://business.apple.com/ and create a new MDM server with the public key. Then, download the new MDM server's token. + +Next, deploy Fleet with with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm +`, + publicKeyPath, + privateKeyPath, + ) + + return nil + }, + } +} diff --git a/cmd/fleetctl/apple_mdm_test.go b/cmd/fleetctl/generate_test.go similarity index 100% rename from cmd/fleetctl/apple_mdm_test.go rename to cmd/fleetctl/generate_test.go diff --git a/cmd/fleetctl/get.go b/cmd/fleetctl/get.go index 82ee784490..a5daa686b0 100644 --- a/cmd/fleetctl/get.go +++ b/cmd/fleetctl/get.go @@ -664,13 +664,9 @@ func getHostsCommand() *cli.Command { if c.Bool("mdm") || c.Bool("mdm-pending") { // print an error if MDM is not configured - appCfg, err := client.GetAppConfig() - if err != nil { + if err := checkMDMEnabled(client); err != nil { return err } - if !appCfg.MDM.EnabledAndConfigured { - return errors.New("MDM features aren't turned on. Use `fleetctl generate mdm-apple` and then `fleet serve` with `mdm` configuration to turn on MDM features.") - } // --mdm and --mdm-pending are mutually exclusive, return an error if // both are set (one returns the enrolled hosts, the other the pending @@ -1109,7 +1105,6 @@ func getSoftwareCommand() *cli.Command { func getMDMAppleCommand() *cli.Command { return &cli.Command{ Name: "mdm_apple", - Hidden: true, // TODO: temporary, until the MDM feature is officially released Aliases: []string{"mdm-apple"}, Usage: "Show Apple Push Notification Service (APNs) information", Flags: []cli.Flag{ @@ -1159,7 +1154,6 @@ func getMDMAppleCommand() *cli.Command { func getMDMAppleBMCommand() *cli.Command { return &cli.Command{ Name: "mdm_apple_bm", - Hidden: true, // TODO: temporary, until the MDM feature is officially released Aliases: []string{"mdm-apple-bm"}, Usage: "Show information about Apple Business Manager for automatic enrollment", Flags: []cli.Flag{ diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 6fe0537e6d..6831944f0a 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -244,9 +244,8 @@ func TestGetTeamsByName(t *testing.T) { func TestGetHosts(t *testing.T) { _, ds := runServerWithMockedDS(t) - var mdmEnabled bool ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: mdmEnabled}}, nil + return &fleet.AppConfig{}, nil } // this func is called when no host is specified i.e. `fleetctl get hosts --json` diff --git a/cmd/fleetctl/mdm.go b/cmd/fleetctl/mdm.go new file mode 100644 index 0000000000..63242eee3f --- /dev/null +++ b/cmd/fleetctl/mdm.go @@ -0,0 +1,121 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/service" + kithttp "github.com/go-kit/kit/transport/http" + "github.com/urfave/cli/v2" +) + +func mdmCommand() *cli.Command { + return &cli.Command{ + Name: "mdm", + Usage: "Run MDM commands against your hosts", + Flags: []cli.Flag{ + configFlag(), + contextFlag(), + debugFlag(), + }, + Subcommands: []*cli.Command{ + mdmRunCommand(), + }, + } +} + +func mdmRunCommand() *cli.Command { + return &cli.Command{ + Name: "run-command", + Aliases: []string{"run_command"}, + Usage: "Run a custom MDM command on one macOS host. Head to Apple's documentation for a list of available commands and example payloads here: https://developer.apple.com/documentation/devicemanagement/commands_and_queries", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "host", + Usage: "The host, specified by hostname, uuid, osquery_host_id or node_key, that you want to run the MDM command on.", + Required: true, + }, + &cli.StringFlag{ + Name: "payload", + Usage: "A path to an XML file containing the raw MDM request payload.", + Required: true, + }, + }, + Action: func(c *cli.Context) error { + client, err := clientFromCLI(c) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + + // print an error if MDM is not configured + if err := checkMDMEnabled(client); err != nil { + return err + } + + hostIdent := c.String("host") + payloadFile := c.String("payload") + payload, err := os.ReadFile(payloadFile) + if err != nil { + return fmt.Errorf("read payload: %w", err) + } + + host, err := client.HostByIdentifier(hostIdent) + if err != nil { + var nfe service.NotFoundErr + if errors.As(err, &nfe) { + return errors.New("The host doesn't exist. Please provide a valid hostname, uuid, osquery_host_id or node_key.") + } + var sce kithttp.StatusCoder + if errors.As(err, &sce) { + if sce.StatusCode() == http.StatusForbidden { + return fmt.Errorf("Permission denied. You don't have permission to run an MDM command on this host: %w", err) + } + } + return err + } + + // TODO(mna): this "On" check is brittle, but looks like it's the only + // enrollment indication we have right now... + if host.MDM.EnrollmentStatus == nil || !strings.HasPrefix(*host.MDM.EnrollmentStatus, "On") || + host.MDM.Name != fleet.WellKnownMDMFleet { + return errors.New("Can't run the MDM command because the host doesn't have MDM turned on. Run the following command to see a list of hosts with MDM on: fleetctl get hosts --mdm") + } + + result, err := client.EnqueueCommand([]string{host.UUID}, payload) + if err != nil { + var sce kithttp.StatusCoder + if errors.As(err, &sce) { + if sce.StatusCode() == http.StatusForbidden { + return fmt.Errorf("Permission denied. You don't have permission to run an MDM command on this host: %w", err) + } + } + return err + } + + fmt.Fprintf(c.App.Writer, ` +The hosts will run the command the next time it checks into Fleet. + +Copy and run this command to see results: + +fleetctl get mdm-command-results --id=%v +`, result.CommandUUID) + + return nil + }, + } +} + +func checkMDMEnabled(client *service.Client) error { + appCfg, err := client.GetAppConfig() + if err != nil { + return err + } + if !appCfg.MDM.EnabledAndConfigured { + return errors.New("MDM features aren't turned on. Use `fleetctl generate mdm-apple` and then `fleet serve` with `mdm` configuration to turn on MDM features.") + } + return nil +} diff --git a/cmd/fleetctl/mdm_test.go b/cmd/fleetctl/mdm_test.go new file mode 100644 index 0000000000..a54dfd7aa0 --- /dev/null +++ b/cmd/fleetctl/mdm_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + mock "github.com/fleetdm/fleet/v4/server/mock/nanomdm" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/service" + "github.com/google/uuid" + "github.com/micromdm/nanomdm/mdm" + "github.com/micromdm/nanomdm/push" + "github.com/stretchr/testify/require" +) + +type mockPusher struct{} + +func (mockPusher) Push(ctx context.Context, ids []string) (map[string]*push.Response, error) { + m := make(map[string]*push.Response, len(ids)) + for _, id := range ids { + m[id] = &push.Response{Id: id} + } + return m, nil +} + +func TestMDMRunCommand(t *testing.T) { + enqueuer := new(mock.Storage) + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{MDMStorage: enqueuer, MDMPusher: mockPusher{}}) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + switch identifier { + case "no-such-host": + return nil, ¬FoundError{} + case "no-mdm-host": + return &fleet.Host{ID: 1, UUID: identifier}, nil + case "no-fleet-mdm-host": + return &fleet.Host{ID: 2, UUID: identifier, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMJamf, EnrollmentStatus: ptr.String("On (manual)")}}, nil + case "fleet-mdm-pending-host": + return &fleet.Host{ID: 3, UUID: identifier, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("Pending")}}, nil + default: + return &fleet.Host{ID: 4, UUID: identifier, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}}, nil + } + } + ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { + return nil + } + ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { + return nil, nil + } + ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) (packs []*fleet.Pack, err error) { + return nil, nil + } + ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { + return nil, nil + } + ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { + return nil, nil + } + ds.GetHostMDMProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) { + return nil, nil + } + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + if len(uuids) == 0 { + return nil, nil + } + return []*fleet.Host{ + {ID: 4, UUID: uuids[0], MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}}, + }, nil + } + enqueuer.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) { + return map[string]error{}, nil + } + + _, err := runAppNoChecks([]string{"mdm", "run-command"}) + require.Error(t, err) + require.ErrorContains(t, err, `Required flags "host, payload" not set`) + + _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "abc"}) + require.Error(t, err) + require.ErrorContains(t, err, `Required flag "payload" not set`) + + _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "abc", "--payload", "no-such-file"}) + require.Error(t, err) + require.ErrorContains(t, err, `open no-such-file: no such file or directory`) + + // pass a yaml file instead of xml + yamlFilePath := writeTmpYml(t, `invalid`) + _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "valid", "--payload", yamlFilePath}) + require.Error(t, err) + require.ErrorContains(t, err, `The payload isn't valid XML.`) + + // host not found + cmdFilePath := writeTmpMDMCmd(t, "FooBar") + _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "no-such-host", "--payload", cmdFilePath}) + require.Error(t, err) + require.ErrorContains(t, err, `The host doesn't exist.`) + + // host not in mdm + _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "no-mdm-host", "--payload", cmdFilePath}) + require.Error(t, err) + require.ErrorContains(t, err, `Can't run the MDM command because the host doesn't have MDM turned on.`) + + // host not in fleet mdm + _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "no-fleet-mdm-host", "--payload", cmdFilePath}) + require.Error(t, err) + require.ErrorContains(t, err, `Can't run the MDM command because the host doesn't have MDM turned on.`) + + // host in fleet mdm but pending + _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "fleet-mdm-pending-host", "--payload", cmdFilePath}) + require.Error(t, err) + require.ErrorContains(t, err, `Can't run the MDM command because the host doesn't have MDM turned on.`) + + // host enrolled in fleet mdm + buf, err := runAppNoChecks([]string{"mdm", "run-command", "--host", "valid-host", "--payload", cmdFilePath}) + require.NoError(t, err) + require.Contains(t, buf.String(), `The hosts will run the command the next time it checks into Fleet.`) + require.Contains(t, buf.String(), `fleetctl get mdm-command-results --id=`) + + // try to run a fleet premium command + cmdFilePath = writeTmpMDMCmd(t, "EraseDevice") + _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "valid-host", "--payload", cmdFilePath}) + require.Error(t, err) + require.ErrorContains(t, err, `missing or invalid license`) +} + +func writeTmpMDMCmd(t *testing.T, commandName string) string { + tmpFile, err := os.CreateTemp(t.TempDir(), "*.xml") + require.NoError(t, err) + _, err = tmpFile.WriteString(fmt.Sprintf(` + + + + CommandUUID + %s + Command + + RequestType + %s + + +`, uuid.New().String(), commandName)) + require.NoError(t, err) + return tmpFile.Name() +} diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 6a4743acd6..3ada65db93 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -540,6 +540,7 @@ The MDM endpoints exist to support the related command-line interface sub-comman - [Update Apple MDM settings](#update-apple-mdm-settings) - [Download an enrollment profile using IdP authentication](#download-an-enrollment-profile-using-idp-authentication) - [Get Apple disk encryption summary](#get-apple-disk-encryption-summary) +- [Enqueue MDM command](#enqueue-mdm-command) ### Get Apple MDM @@ -1015,6 +1016,36 @@ Get aggregate status counts of Apple disk encryption profiles applying to macOS } ``` +### Enqueue MDM command + +This endpoint enqueues an MDM command to be executed on a list of hosts identified by their UUID. + +`POST /api/v1/fleet/mdm/apple/enqueue` + +#### Parameters + +| Name | Type | In | Description | +| ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | +| command | string | json | A base64-encoded MDM command as described in [Apple's documentation](https://developer.apple.com/documentation/devicemanagement/commands_and_queries) | +| device_ids | array | json | An array of host UUIDs enrolled in Fleet's MDM on which the command should run. | + +Note that the `EraseDevice` and `DeviceLock` commands are _available in Fleet Premium_ only. + +#### Example + +`POST /api/v1/fleet/mdm/apple/enqueue` + +##### Default response + +`Status: 200` + +```json +{ + "command_uuid": "a2064cef-0000-1234-afb9-283e3c1d487e", + "request_type": "ProfileList" +} +``` + ## 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/). diff --git a/docs/Using-Fleet/Permissions.md b/docs/Using-Fleet/Permissions.md index 53d0d1199a..c231238d09 100644 --- a/docs/Using-Fleet/Permissions.md +++ b/docs/Using-Fleet/Permissions.md @@ -46,7 +46,8 @@ Users with the Admin role receive all permissions. | View Apple business manager (BM) information | | | ✅ | | Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | ✅ | | View disk encryption key for macOS hosts enrolled in Fleet's MDM | ✅ | ✅ | ✅ | -| Create edit and delete configuration profiles for macOS hosts enrolled in Fleet's MDM | | ✅ | ✅ | +| Create edit and delete configuration profiles for macOS hosts enrolled in Fleet's MDM | | ✅ | ✅ | +| Execute MDM commands on macOS hosts enrolled in Fleet's MDM, and read command results | | ✅ | ✅ | \*Applies only to Fleet Premium @@ -95,6 +96,7 @@ Users that are members of multiple teams can be assigned different roles for eac | Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | ✅ | ✅ | | View disk encryption key for macOS hosts enrolled in Fleet's MDM | ✅ | ✅ | ✅ | | Create edit and delete configuration profiles for macOS hosts enrolled in Fleet's MDM | | ✅ | ✅ | +| Execute MDM commands on macOS hosts enrolled in Fleet's MDM, and read command results | | ✅ | ✅ | \* Applies only to [Fleet REST API](https://fleetdm.com/docs/using-fleet/rest-api) diff --git a/server/authz/policy.rego b/server/authz/policy.rego index aab879dc0a..1e24633d18 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -563,10 +563,18 @@ allow { action == [read, write][_] } -# Global admins can read and write Apple commands. +# Global admins and maintainers can read and write (execute) MDM Apple commands. allow { object.type == "mdm_apple_command" - subject.global_role == admin + subject.global_role == [admin, maintainer][_] + action == [read, write][_] +} + +# Team admins and maintainers can read and write (execute) MDM Apple commands on hosts of their teams. +allow { + not is_null(object.team_id) + object.type == "mdm_apple_command" + team_role(subject, object.team_id) == [admin, maintainer][_] action == [read, write][_] } diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index c87d493892..ace656915e 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -1756,6 +1756,51 @@ func (ds *Datastore) HostIDsByName(ctx context.Context, filter fleet.TeamFilter, return hostIDs, nil } +func (ds *Datastore) ListHostsLiteByUUIDs(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + if len(uuids) == 0 { + return nil, nil + } + + stmt := fmt.Sprintf(` +SELECT + id, + created_at, + updated_at, + osquery_host_id, + node_key, + hostname, + uuid, + hardware_serial, + hardware_model, + computer_name, + platform, + team_id, + distributed_interval, + logger_tls_period, + config_tls_refresh, + detail_updated_at, + label_updated_at, + last_enrolled_at, + policy_updated_at, + refetch_requested +FROM hosts +WHERE uuid IN (?) AND %s + `, ds.whereFilterHostsByTeams(filter, "hosts"), + ) + + stmt, args, err := sqlx.In(stmt, uuids) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "building query to select hosts by uuid") + } + + var hosts []*fleet.Host + if err := sqlx.SelectContext(ctx, ds.reader, &hosts, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "select hosts by uuid") + } + + return hosts, nil +} + func (ds *Datastore) HostByIdentifier(ctx context.Context, identifier string) (*fleet.Host, error) { stmt := ` SELECT diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index f57995bc0e..7db295d436 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -145,6 +145,7 @@ func TestHosts(t *testing.T) { {"EnrollOrbit", testHostsEnrollOrbit}, {"EnrollUpdatesMissingInfo", testHostsEnrollUpdatesMissingInfo}, {"EncryptionKeyRawDecryption", testHostsEncryptionKeyRawDecryption}, + {"ListHostsLiteByUUIDs", testHostsListHostsLiteByUUIDs}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -6554,3 +6555,178 @@ func testHostsEncryptionKeyRawDecryption(t *testing.T, ds *Datastore) { require.NotNil(t, got.MDM.TestGetRawDecryptable()) require.Equal(t, 1, *got.MDM.TestGetRawDecryptable()) } + +func testHostsListHostsLiteByUUIDs(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create hosts, UUID is the `i` index + hosts := make([]*fleet.Host, 10) + for i := range hosts { + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String(fmt.Sprintf("host%d", i)), + NodeKey: ptr.String(fmt.Sprintf("%d", i)), + UUID: fmt.Sprintf("%d", i), + Hostname: fmt.Sprintf("foo.%d.local", i), + }) + require.NoError(t, err) + hosts[i] = h + } + + // move hosts 0, 1, 2 to team 1 + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + require.NoError(t, ds.AddHostsToTeam(ctx, &team1.ID, []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID})) + + // move hosts 3, 4, 5 to team 2 + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + require.NoError(t, ds.AddHostsToTeam(ctx, &team2.ID, []uint{hosts[3].ID, hosts[4].ID, hosts[5].ID})) + + // create a team 3 without any host + team3, err := ds.NewTeam(ctx, &fleet.Team{Name: "team3"}) + require.NoError(t, err) + + tm1Admin := &fleet.User{Teams: []fleet.UserTeam{{Team: *team1, Role: fleet.RoleAdmin}}} + tm1Maintainer := &fleet.User{Teams: []fleet.UserTeam{{Team: *team1, Role: fleet.RoleMaintainer}}} + tm1Observer := &fleet.User{Teams: []fleet.UserTeam{{Team: *team1, Role: fleet.RoleObserver}}} + tm2Admin := &fleet.User{Teams: []fleet.UserTeam{{Team: *team2, Role: fleet.RoleAdmin}}} + tm2Maintainer := &fleet.User{Teams: []fleet.UserTeam{{Team: *team2, Role: fleet.RoleMaintainer}}} + tm2Observer := &fleet.User{Teams: []fleet.UserTeam{{Team: *team2, Role: fleet.RoleObserver}}} + tm3Admin := &fleet.User{Teams: []fleet.UserTeam{{Team: *team3, Role: fleet.RoleAdmin}}} + tm1MaintainerTm2Observer := &fleet.User{Teams: []fleet.UserTeam{ + {Team: *team1, Role: fleet.RoleMaintainer}, + {Team: *team2, Role: fleet.RoleObserver}, + }} + + cases := []struct { + desc string + filter fleet.TeamFilter + uuids []string + wantIDs []uint + }{ + { + "no user sees nothing", + fleet.TeamFilter{}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + nil, + }, + { + "global admin no uuid provided", + fleet.TeamFilter{User: test.UserAdmin}, + []string{}, + nil, + }, + { + "global admin sees everything", + fleet.TeamFilter{User: test.UserAdmin}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID, hosts[5].ID, hosts[6].ID, hosts[7].ID, hosts[8].ID, hosts[9].ID}, + }, + { + "global maintainer sees everything", + fleet.TeamFilter{User: test.UserMaintainer}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID, hosts[5].ID, hosts[6].ID, hosts[7].ID, hosts[8].ID, hosts[9].ID}, + }, + { + "global observer sees nothing", + fleet.TeamFilter{User: test.UserObserver}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + nil, + }, + { + "global observer sees everything with observer allowed", + fleet.TeamFilter{User: test.UserObserver, IncludeObserver: true}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID, hosts[5].ID, hosts[6].ID, hosts[7].ID, hosts[8].ID, hosts[9].ID}, + }, + { + "team 1 admin sees team 1 hosts", + fleet.TeamFilter{User: tm1Admin}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID}, + }, + { + "team 1 maintainer sees team 1 hosts", + fleet.TeamFilter{User: tm1Maintainer}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID}, + }, + { + "team 1 observer sees nothing", + fleet.TeamFilter{User: tm1Observer}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + nil, + }, + { + "team 1 observer sees team 1 hosts with observer allowed", + fleet.TeamFilter{User: tm1Observer, IncludeObserver: true}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID}, + }, + { + "team 2 admin sees team 2 hosts", + fleet.TeamFilter{User: tm2Admin}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[3].ID, hosts[4].ID, hosts[5].ID}, + }, + { + "team 2 maintainer sees team 2 hosts", + fleet.TeamFilter{User: tm2Maintainer}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[3].ID, hosts[4].ID, hosts[5].ID}, + }, + { + "team 2 observer sees nothing", + fleet.TeamFilter{User: tm2Observer}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + nil, + }, + { + "team 2 observer sees team 2 hosts with observer allowed", + fleet.TeamFilter{User: tm2Observer, IncludeObserver: true}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[3].ID, hosts[4].ID, hosts[5].ID}, + }, + { + "team 3 admin sees nothing even with observer", + fleet.TeamFilter{User: tm3Admin, IncludeObserver: true}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + nil, + }, + { + "filtering on a specific team ID returns only those hosts", + fleet.TeamFilter{User: test.UserAdmin, TeamID: &team1.ID}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID}, + }, + { + "team 1 maintainer team 2 observer sees team 1", + fleet.TeamFilter{User: tm1MaintainerTm2Observer}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID}, + }, + { + "team 1 maintainer team 2 observer sees team 1 and 2 with observer", + fleet.TeamFilter{User: tm1MaintainerTm2Observer, IncludeObserver: true}, + []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}, + []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID, hosts[5].ID}, + }, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + hosts, err := ds.ListHostsLiteByUUIDs(ctx, c.filter, c.uuids) + require.NoError(t, err) + + gotIDs := make([]uint, len(hosts)) + for i, h := range hosts { + gotIDs[i] = h.ID + } + require.ElementsMatch(t, c.wantIDs, gotIDs) + }) + } +} diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 0b076a9438..269c61b85d 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -235,25 +235,18 @@ type EnrolledAPIResults map[string]*EnrolledAPIResult // CommandEnqueueResult is the result of a command execution on enrolled Apple devices. type CommandEnqueueResult struct { - // Status is the status of the command. - Status EnrolledAPIResults `json:"status,omitempty"` - // NoPush indicates whether the command was issued with no_push. - // If this is true, then Fleet won't send a push notification to devices. - NoPush bool `json:"no_push,omitempty"` - // PushError indicates the error when trying to send push notification - // to target devices. - PushError string `json:"push_error,omitempty"` - // CommandError holds the error when enqueueing the command. - CommandError string `json:"command_error,omitempty"` // CommandUUID is the unique identifier for the command. CommandUUID string `json:"command_uuid,omitempty"` // RequestType is the name of the command. RequestType string `json:"request_type,omitempty"` + // FailedUUIDs is the list of host UUIDs that failed to receive the command. + FailedUUIDs []string `json:"failed_uuids,omitempty"` } // MDMAppleCommand represents an Apple MDM command. type MDMAppleCommand struct { *mdm.Command + TeamID *uint `json:"team_id"` // required for authorization by team } // AuthzType implements authz.AuthzTyper. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index da5c9e74a0..26774bea00 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -195,6 +195,7 @@ type Datastore interface { DeleteHost(ctx context.Context, hid uint) error Host(ctx context.Context, id uint) (*Host, error) ListHosts(ctx context.Context, filter TeamFilter, opt HostListOptions) ([]*Host, error) + ListHostsLiteByUUIDs(ctx context.Context, filter TeamFilter, uuids []string) ([]*Host, error) MarkHostsSeen(ctx context.Context, hostIDs []uint, t time.Time) error SearchHosts(ctx context.Context, filter TeamFilter, query string, omit ...uint) ([]*Host, error) @@ -913,6 +914,11 @@ const ( UnknownMigrations ) +// TODO: we have a similar but different interface in the service package, +// service.NotFoundErr - at the very least, the IsNotFound method should be the +// same in both (the other is currently NotFound), and ideally we'd just have +// one of those interfaces. + // NotFoundError is returned when the datastore resource cannot be found. type NotFoundError interface { error diff --git a/server/fleet/service.go b/server/fleet/service.go index 908f56bb1a..e3383f17c9 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -633,8 +633,9 @@ type Service interface { // NewMDMAppleDEPKeyPair creates a public private key pair for use with the Apple MDM DEP token. NewMDMAppleDEPKeyPair(ctx context.Context) (*MDMAppleDEPKeyPair, error) - // EnqueueMDMAppleCommand enqueues a command for execution on the given devices. - EnqueueMDMAppleCommand(ctx context.Context, command *MDMAppleCommand, deviceIDs []string, noPush bool) (status int, result *CommandEnqueueResult, err error) + // EnqueueMDMAppleCommand enqueues a command for execution on the given + // devices. Note that a deviceID is the same as a host's UUID. + EnqueueMDMAppleCommand(ctx context.Context, rawBase64Cmd string, deviceIDs []string, noPush bool) (status int, result *CommandEnqueueResult, err error) // EnqueueMDMAppleCommandRemoveEnrollmentProfile enqueues a command to remove the // profile used for Fleet MDM enrollment from the specified device. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 10dc55e3da..225e965ade 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -154,6 +154,8 @@ type HostFunc func(ctx context.Context, id uint) (*fleet.Host, error) type ListHostsFunc func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) +type ListHostsLiteByUUIDsFunc func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) + type MarkHostsSeenFunc func(ctx context.Context, hostIDs []uint, t time.Time) error type SearchHostsFunc func(ctx context.Context, filter fleet.TeamFilter, query string, omit ...uint) ([]*fleet.Host, error) @@ -788,6 +790,9 @@ type DataStore struct { ListHostsFunc ListHostsFunc ListHostsFuncInvoked bool + ListHostsLiteByUUIDsFunc ListHostsLiteByUUIDsFunc + ListHostsLiteByUUIDsFuncInvoked bool + MarkHostsSeenFunc MarkHostsSeenFunc MarkHostsSeenFuncInvoked bool @@ -1913,6 +1918,13 @@ func (s *DataStore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt return s.ListHostsFunc(ctx, filter, opt) } +func (s *DataStore) ListHostsLiteByUUIDs(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + s.mu.Lock() + s.ListHostsLiteByUUIDsFuncInvoked = true + s.mu.Unlock() + return s.ListHostsLiteByUUIDsFunc(ctx, filter, uuids) +} + func (s *DataStore) MarkHostsSeen(ctx context.Context, hostIDs []uint, t time.Time) error { s.mu.Lock() s.MarkHostsSeenFuncInvoked = true diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 40ff722b1c..43f56dc7b2 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -17,23 +17,24 @@ import ( "sync" "time" + "github.com/VividCortex/mysqlerr" "github.com/docker/go-units" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/logging" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" kitlog "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" + "github.com/go-sql-driver/mysql" "github.com/google/uuid" "github.com/groob/plist" "github.com/micromdm/micromdm/mdm/appmanifest" "github.com/micromdm/nanodep/godep" "github.com/micromdm/nanomdm/mdm" - "github.com/micromdm/nanomdm/push" nanomdm_push "github.com/micromdm/nanomdm/push" - "github.com/micromdm/nanomdm/storage" nanomdm_storage "github.com/micromdm/nanomdm/storage" ) @@ -878,13 +879,12 @@ func (svc *Service) NewMDMAppleDEPKeyPair(ctx context.Context) (*fleet.MDMAppleD type enqueueMDMAppleCommandRequest struct { Command string `json:"command"` DeviceIDs []string `json:"device_ids"` - NoPush bool `json:"no_push"` } type enqueueMDMAppleCommandResponse struct { - status int `json:"-"` - Result fleet.CommandEnqueueResult `json:"result"` - Err error `json:"error,omitempty"` + *fleet.CommandEnqueueResult + status int `json:"-"` + Err error `json:"error,omitempty"` } func (r enqueueMDMAppleCommandResponse) error() error { return r.Err } @@ -892,146 +892,119 @@ func (r enqueueMDMAppleCommandResponse) Status() int { return r.status } func enqueueMDMAppleCommandEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*enqueueMDMAppleCommandRequest) - rawCommand, err := base64.RawStdEncoding.DecodeString(req.Command) - if err != nil { - return enqueueMDMAppleCommandResponse{Err: err}, nil - } - command, err := mdm.DecodeCommand(rawCommand) - if err != nil { - return enqueueMDMAppleCommandResponse{Err: err}, nil - } - status, result, err := svc.EnqueueMDMAppleCommand(ctx, &fleet.MDMAppleCommand{Command: command}, req.DeviceIDs, req.NoPush) + status, result, err := svc.EnqueueMDMAppleCommand(ctx, req.Command, req.DeviceIDs, false) if err != nil { return enqueueMDMAppleCommandResponse{Err: err}, nil } return enqueueMDMAppleCommandResponse{ - status: status, - Result: *result, + status: status, + CommandEnqueueResult: result, }, nil } func (svc *Service) EnqueueMDMAppleCommand( ctx context.Context, - command *fleet.MDMAppleCommand, + rawBase64Cmd string, deviceIDs []string, noPush bool, ) (status int, result *fleet.CommandEnqueueResult, err error) { - if err := svc.authz.Authorize(ctx, command, fleet.ActionWrite); err != nil { + var premiumCommands = map[string]bool{ + "EraseDevice": true, + "DeviceLock": true, + } + + // load hosts (lite) by uuids, check that the user has the rigts to run + // commands for every affected team. + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { return 0, nil, ctxerr.Wrap(ctx, err) } - return deprecatedRawCommandEnqueue(ctx, svc.mdmStorage, svc.mdmPushService, command.Command, deviceIDs, noPush, svc.logger) -} -// deprecatedRawCommandEnqueue enqueues a command to be executed on the given devices. -// -// This method was extracted from: -// https://github.com/fleetdm/nanomdm/blob/a261f081323c80fb7f6575a64ac1a912dffe44ba/http/api/api.go#L134-L261 -// NOTE(lucas): At the time, I found no way to reuse Fleet's gokit middlewares with a raw http.Handler -// like api.RawCommandEnqueueHandler. -func deprecatedRawCommandEnqueue( - ctx context.Context, - enqueuer storage.CommandEnqueuer, - pusher push.Pusher, - command *mdm.Command, - deviceIDs []string, - noPush bool, - logger kitlog.Logger, -) (status int, result *fleet.CommandEnqueueResult, err error) { - output := fleet.CommandEnqueueResult{ - Status: make(fleet.EnrolledAPIResults), - NoPush: noPush, - CommandUUID: command.CommandUUID, - RequestType: command.Command.RequestType, + vc, ok := viewer.FromContext(ctx) + if !ok { + return 0, nil, fleet.ErrNoContext } - - logger = kitlog.With( - logger, - "command_uuid", command.CommandUUID, - "request_type", command.Command.RequestType, - ) - logs := []interface{}{ - "msg", "enqueue", - } - idErrs, err := enqueuer.EnqueueCommand(ctx, deviceIDs, command) - ct := len(deviceIDs) - len(idErrs) + // for the team filter, we don't include observers as we require maintainer + // and up to run commands. + filter := fleet.TeamFilter{User: vc.User, IncludeObserver: false} + hosts, err := svc.ds.ListHostsLiteByUUIDs(ctx, filter, deviceIDs) if err != nil { - logs = append(logs, "err", err) - output.CommandError = err.Error() - if len(idErrs) == 0 { - // we assume if there were no ID-specific errors but - // there was a general error then all IDs failed - ct = 0 + return 0, nil, err + } + if len(hosts) == 0 { + return 0, nil, newNotFoundError() + } + + // collect the team IDs and verify that the user has access to run commands + // on all affected teams. + teamIDs := make(map[uint]bool) + for _, h := range hosts { + var id uint + if h.TeamID != nil { + id = *h.TeamID + } + teamIDs[id] = true + } + + var command fleet.MDMAppleCommand + for tmID := range teamIDs { + command.TeamID = &tmID + if tmID == 0 { + command.TeamID = nil + } + + if err := svc.authz.Authorize(ctx, command, fleet.ActionWrite); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err) } } - logs = append(logs, "count", ct) - if len(idErrs) > 0 { - logs = append(logs, "errs", len(idErrs)) + + rawXMLCmd, err := base64.RawStdEncoding.DecodeString(rawBase64Cmd) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "decode base64 command") } - if err != nil || len(idErrs) > 0 { - level.Info(logger).Log(logs...) - } else { - level.Debug(logger).Log(logs...) + cmd, err := mdm.DecodeCommand(rawXMLCmd) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "decode plist command") } - // loop through our command errors, if any, and add to output - for id, err := range idErrs { + + if premiumCommands[strings.TrimSpace(cmd.Command.RequestType)] { + lic, err := svc.License(ctx) if err != nil { - output.Status[id] = &fleet.EnrolledAPIResult{ - CommandError: err.Error(), + return 0, nil, ctxerr.Wrap(ctx, err, "get license") + } + if !lic.IsPremium() { + return 0, nil, fleet.ErrMissingLicense + } + } + + if err := svc.mdmAppleCommander.enqueue(ctx, deviceIDs, string(rawXMLCmd)); err != nil { + // if at least one UUID enqueued properly, return success, otherwise return 500 + var apnsErr *APNSDeliveryError + var mysqlErr *mysql.MySQLError + if errors.As(err, &apnsErr) { + if len(apnsErr.FailedUUIDs) < len(deviceIDs) { + // some hosts properly received the command, so return success, with the list + // of failed uuids. + return http.StatusOK, &fleet.CommandEnqueueResult{ + CommandUUID: cmd.CommandUUID, + RequestType: cmd.Command.RequestType, + FailedUUIDs: apnsErr.FailedUUIDs, + }, nil + } + } else if errors.As(err, &mysqlErr) { + // enqueue may fail with a foreign key constraint error 1452 when one of + // the hosts provided is not enrolled in nano_enrollments. Detect when + // that's the case and add information to the error. + if mysqlErr.Number == mysqlerr.ER_NO_REFERENCED_ROW_2 { + err := fleet.NewInvalidArgumentError("device_ids", fmt.Sprintf("at least one of the hosts is not enrolled in MDM: %v", err)) + return http.StatusInternalServerError, nil, ctxerr.Wrap(ctx, err, "enqueue command") } } + return http.StatusInternalServerError, nil, ctxerr.Wrap(ctx, err, "enqueue command") } - // optionally send pushes - pushResp := make(map[string]*push.Response) - var pushErr error - if !noPush { - pushResp, pushErr = pusher.Push(ctx, deviceIDs) - if err != nil { - level.Info(logger).Log("msg", "push", "err", err) - output.PushError = err.Error() - } - } else { - pushErr = nil - } - // loop through our push errors, if any, and add to output - var pushCt, pushErrCt int - for id, resp := range pushResp { - if _, ok := output.Status[id]; ok { - output.Status[id].PushResult = resp.Id - } else { - output.Status[id] = &fleet.EnrolledAPIResult{ - PushResult: resp.Id, - } - } - if resp.Err != nil { - output.Status[id].PushError = resp.Err.Error() - pushErrCt++ - } else { - pushCt++ - } - } - logs = []interface{}{ - "msg", "push", - "count", pushCt, - } - if pushErr != nil { - logs = append(logs, "err", pushErr) - } - if pushErrCt > 0 { - logs = append(logs, "errs", pushErrCt) - } - if pushErr != nil || pushErrCt > 0 { - level.Info(logger).Log(logs...) - } else { - level.Debug(logger).Log(logs...) - } - // generate response codes depending on if everything succeeded, failed, or parially succedded - header := http.StatusInternalServerError - if (len(idErrs) > 0 || err != nil || (!noPush && (pushErrCt > 0 || pushErr != nil))) && (ct > 0 || (!noPush && (pushCt > 0))) { - header = http.StatusMultiStatus - } else if (len(idErrs) == 0 && err == nil && (noPush || (pushErrCt == 0 && pushErr == nil))) && (ct >= 1 && (noPush || (pushCt >= 1))) { - header = http.StatusOK - } - return header, &output, nil + return http.StatusOK, &fleet.CommandEnqueueResult{ + CommandUUID: cmd.CommandUUID, + RequestType: cmd.Command.RequestType, + }, nil } type mdmAppleEnrollRequest struct { diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index d751b80684..4ef3de024d 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -162,7 +162,7 @@ func setupAppleMDMService(t *testing.T) (fleet.Service, context.Context, *mock.S } func TestAppleMDMAuthorization(t *testing.T) { - svc, ctx, _ := setupAppleMDMService(t) + svc, ctx, ds := setupAppleMDMService(t) checkAuthErr := func(t *testing.T, err error, shouldFailWithAuth bool) { t.Helper() @@ -195,8 +195,6 @@ func TestAppleMDMAuthorization(t *testing.T) { checkAuthErr(t, err, shouldFailWithAuth) _, err = svc.ListMDMAppleDEPDevices(ctx) checkAuthErr(t, err, shouldFailWithAuth) - _, _, err = svc.EnqueueMDMAppleCommand(ctx, &fleet.MDMAppleCommand{Command: &mdm.Command{}}, nil, false) - checkAuthErr(t, err, shouldFailWithAuth) } // Only global admins can access the endpoints. @@ -231,6 +229,83 @@ func TestAppleMDMAuthorization(t *testing.T) { ctx = test.HostContext(context.Background(), &fleet.Host{}) _, err = svc.GetDeviceMDMAppleEnrollmentProfile(ctx) require.NoError(t, err) + + hostUUIDsToTeamID := map[string]uint{ + "host1": 1, + "host2": 1, + "host3": 2, + "host4": 0, + } + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + hosts := make([]*fleet.Host, 0, len(uuids)) + for _, uuid := range uuids { + tmID := hostUUIDsToTeamID[uuid] + if tmID == 0 { + hosts = append(hosts, &fleet.Host{UUID: uuid, TeamID: nil}) + } else { + hosts = append(hosts, &fleet.Host{UUID: uuid, TeamID: &tmID}) + } + } + return hosts, nil + } + + rawB64FreeCmd := base64.RawStdEncoding.EncodeToString([]byte(` + + + + Command + + RequestType + FooBar + + CommandUUID + uuid + +`)) + + cases := []struct { + desc string + user *fleet.User + uuids []string + shoudFailWithAuth bool + }{ + {"no role", test.UserNoRoles, []string{"host1", "host2", "host3", "host4"}, true}, + {"maintainer can run", test.UserMaintainer, []string{"host1", "host2", "host3", "host4"}, false}, + {"admin can run", test.UserAdmin, []string{"host1", "host2", "host3", "host4"}, false}, + {"team 1 admin can run team 1", test.UserTeamAdminTeam1, []string{"host1", "host2"}, false}, + {"team 2 admin can run team 2", test.UserTeamAdminTeam2, []string{"host3"}, false}, + {"team 1 maintainer can run team 1", test.UserTeamMaintainerTeam1, []string{"host1", "host2"}, false}, + {"team 1 admin cannot run team 2", test.UserTeamAdminTeam1, []string{"host3"}, true}, + {"team 1 admin cannot run no team", test.UserTeamAdminTeam1, []string{"host4"}, true}, + {"team 1 admin cannot run mix of team 1 and 2", test.UserTeamAdminTeam1, []string{"host1", "host3"}, true}, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + ctx = test.UserContext(ctx, c.user) + _, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64FreeCmd, c.uuids, false) + checkAuthErr(t, err, c.shoudFailWithAuth) + }) + } + + // test with a command that requires a premium license + ctx = test.UserContext(ctx, test.UserAdmin) + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree}) + rawB64PremiumCmd := base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf(` + + + + Command + + RequestType + %s + + CommandUUID + uuid + +`, "DeviceLock"))) + _, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"}, false) + require.Error(t, err) + require.ErrorContains(t, err, fleet.ErrMissingLicense.Error()) } func TestMDMAppleEnrollURL(t *testing.T) { diff --git a/server/service/base_client.go b/server/service/base_client.go index c708829772..784b2df532 100644 --- a/server/service/base_client.go +++ b/server/service/base_client.go @@ -50,12 +50,11 @@ func (bc *baseClient) parseResponse(verb, path string, response *http.Response, break } - return fmt.Errorf( - "%s %s received status %d %s", - verb, path, - response.StatusCode, - extractServerErrorText(response.Body), - ) + e := &statusCodeErr{ + code: response.StatusCode, + body: extractServerErrorText(response.Body), + } + return fmt.Errorf("%s %s received status %w", verb, path, e) } bc.setServerCapabilities(response) diff --git a/server/service/base_client_errors.go b/server/service/base_client_errors.go index 24339f8d25..7bea24d99a 100644 --- a/server/service/base_client_errors.go +++ b/server/service/base_client_errors.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "io" "github.com/fleetdm/fleet/v4/server/fleet" @@ -44,6 +45,10 @@ func (e notSetupErr) NotSetup() bool { return true } +// TODO: we have a similar but different interface in the fleet package, +// fleet.NotFoundError - at the very least, the NotFound method should be the +// same in both (the other is currently IsNotFound), and ideally we'd just have +// one of those interfaces. type NotFoundErr interface { NotFound() bool Error() string @@ -111,3 +116,16 @@ func extractServerErrorText(body io.Reader) string { return errText } + +type statusCodeErr struct { + code int + body string +} + +func (e *statusCodeErr) Error() string { + return fmt.Sprintf("%d %s", e.code, e.body) +} + +func (e *statusCodeErr) StatusCode() int { + return e.code +} diff --git a/server/service/client_apple_mdm.go b/server/service/client_apple_mdm.go index ed6f486d23..6dd34eccf6 100644 --- a/server/service/client_apple_mdm.go +++ b/server/service/client_apple_mdm.go @@ -41,7 +41,7 @@ func (c *Client) ListEnrollments() ([]*fleet.MDMAppleEnrollmentProfile, error) { func (c *Client) EnqueueCommand(deviceIDs []string, rawPlist []byte) (*fleet.CommandEnqueueResult, error) { var commandPayload map[string]interface{} if _, err := plist.Unmarshal(rawPlist, &commandPayload); err != nil { - return nil, fmt.Errorf("unmarshal command plist: %w", err) + return nil, fmt.Errorf("The payload isn't valid XML. Please provide a file with valid XML: %w", err) } // generate a random command UUID @@ -55,13 +55,12 @@ func (c *Client) EnqueueCommand(deviceIDs []string, rawPlist []byte) (*fleet.Com request := enqueueMDMAppleCommandRequest{ Command: base64.RawStdEncoding.EncodeToString(b), DeviceIDs: deviceIDs, - NoPush: false, } var response enqueueMDMAppleCommandResponse if err := c.authenticatedRequest(request, "POST", "/api/latest/fleet/mdm/apple/enqueue", &response); err != nil { - return nil, fmt.Errorf("request: %w", err) + return nil, fmt.Errorf("run command request: %w", err) } - return &response.Result, nil + return response.CommandEnqueueResult, nil } func (c *Client) MDMAppleGetCommandResults(commandUUID string) (map[string]*fleet.MDMAppleCommandResult, error) { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index fb8e4ea356..2c22ad7a62 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -2398,6 +2398,62 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesStatus() { }) } +func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() { + t := s.T() + + unenrolledHost := createHostAndDeviceToken(t, s.ds, "unused") + enrolledHost := newMDMEnrolledDevice(s) + + newRawCmd := func() string { + return base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf(` + + + + Command + + ManagedOnly + + RequestType + ProfileList + + CommandUUID + %s + +`, uuid.New().String()))) + } + + // call with unknown host UUID + s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue", + enqueueMDMAppleCommandRequest{ + Command: newRawCmd(), + DeviceIDs: []string{"no-such-host"}, + }, http.StatusNotFound) + + // call with unenrolled host UUID + res := s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue", + enqueueMDMAppleCommandRequest{ + Command: newRawCmd(), + DeviceIDs: []string{unenrolledHost.UUID}, + }, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "at least one of the hosts is not enrolled in MDM") + + // call with enrolled host UUID + rawCmd := newRawCmd() + var resp enqueueMDMAppleCommandResponse + s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enqueue", + enqueueMDMAppleCommandRequest{ + Command: rawCmd, + DeviceIDs: []string{enrolledHost.uuid}, + }, http.StatusOK, &resp) + require.NotEmpty(t, resp.CommandUUID) + decodedCmd, err := base64.RawStdEncoding.DecodeString(rawCmd) + require.NoError(t, err) + require.Contains(t, string(decodedCmd), resp.CommandUUID) + require.Empty(t, resp.FailedUUIDs) + require.Equal(t, "ProfileList", resp.RequestType) +} + // only asserts the profile identifier, status and operation (per host) func (s *integrationMDMTestSuite) assertHostConfigProfiles(want map[*fleet.Host][]fleet.HostMDMAppleProfile) { t := s.T()