diff --git a/changes/issue-9643-fleetctl-get-mdm-command-results b/changes/issue-9643-fleetctl-get-mdm-command-results
new file mode 100644
index 0000000000..a42cdf1a6d
--- /dev/null
+++ b/changes/issue-9643-fleetctl-get-mdm-command-results
@@ -0,0 +1 @@
+* Added the `fleetctl get mdm-command-results` sub-command to get the results for a previously-executed MDM command.
diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go
index d86ed52563..6d6d180c7a 100644
--- a/cmd/fleet/cron.go
+++ b/cmd/fleet/cron.go
@@ -910,7 +910,7 @@ func newMDMAppleProfileManager(
ctx context.Context,
instanceID string,
ds fleet.Datastore,
- commander *service.MDMAppleCommander,
+ commander *apple_mdm.MDMAppleCommander,
logger kitlog.Logger,
loggingDebug bool,
) (*schedule.Schedule, error) {
diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go
index fa1e127908..b93819f4af 100644
--- a/cmd/fleet/serve.go
+++ b/cmd/fleet/serve.go
@@ -40,6 +40,7 @@ import (
"github.com/fleetdm/fleet/v4/server/live_query"
"github.com/fleetdm/fleet/v4/server/logging"
"github.com/fleetdm/fleet/v4/server/mail"
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/pubsub"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/async"
@@ -590,7 +591,7 @@ the way that the Fleet server works.
mailService,
clock.C,
depStorage,
- service.NewMDMAppleCommander(mdmStorage, mdmPushService),
+ apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
mdmPushCertTopic,
)
if err != nil {
@@ -680,7 +681,7 @@ the way that the Fleet server works.
ctx,
instanceID,
ds,
- service.NewMDMAppleCommander(mdmStorage, mdmPushService),
+ apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
logger,
config.Logging.Debug,
)
diff --git a/cmd/fleetctl/apple_mdm.go b/cmd/fleetctl/apple_mdm.go
index 04c1fce012..1a802cce3c 100644
--- a/cmd/fleetctl/apple_mdm.go
+++ b/cmd/fleetctl/apple_mdm.go
@@ -710,9 +710,9 @@ func appleMDMCommandResultsCommand() *cli.Command {
table.SetAutoWrapText(false)
table.SetRowLine(true)
- for deviceID, result := range results {
+ for _, result := range results {
xml := bytes.ReplaceAll(result.Result, []byte{'\t'}, []byte{' '})
- table.Append([]string{deviceID, result.Status, string(xml)})
+ table.Append([]string{result.DeviceID, result.Status, string(xml)})
}
table.Render()
diff --git a/cmd/fleetctl/get.go b/cmd/fleetctl/get.go
index a5daa686b0..841b773f97 100644
--- a/cmd/fleetctl/get.go
+++ b/cmd/fleetctl/get.go
@@ -289,6 +289,7 @@ func getCommand() *cli.Command {
getSoftwareCommand(),
getMDMAppleCommand(),
getMDMAppleBMCommand(),
+ getMDMCommandResultsCommand(),
},
}
}
@@ -1104,8 +1105,8 @@ func getSoftwareCommand() *cli.Command {
func getMDMAppleCommand() *cli.Command {
return &cli.Command{
- Name: "mdm_apple",
- Aliases: []string{"mdm-apple"},
+ Name: "mdm-apple",
+ Aliases: []string{"mdm_apple"},
Usage: "Show Apple Push Notification Service (APNs) information",
Flags: []cli.Flag{
configFlag(),
@@ -1153,8 +1154,8 @@ func getMDMAppleCommand() *cli.Command {
func getMDMAppleBMCommand() *cli.Command {
return &cli.Command{
- Name: "mdm_apple_bm",
- Aliases: []string{"mdm-apple-bm"},
+ Name: "mdm-apple-bm",
+ Aliases: []string{"mdm_apple_bm"},
Usage: "Show information about Apple Business Manager for automatic enrollment",
Flags: []cli.Flag{
configFlag(),
@@ -1204,3 +1205,58 @@ func getMDMAppleBMCommand() *cli.Command {
},
}
}
+
+func getMDMCommandResultsCommand() *cli.Command {
+ return &cli.Command{
+ Name: "mdm-command-results",
+ Aliases: []string{"mdm_command_results"},
+ Usage: "Retrieve results for a specific MDM command.",
+ Flags: []cli.Flag{
+ configFlag(),
+ contextFlag(),
+ debugFlag(),
+ &cli.StringFlag{
+ Name: "id",
+ Usage: "Filter MDM commands by ID.",
+ Required: true,
+ },
+ },
+ Action: func(c *cli.Context) error {
+ client, err := clientFromCLI(c)
+ if err != nil {
+ return err
+ }
+
+ // print an error if MDM is not configured
+ if err := checkMDMEnabled(client); err != nil {
+ return err
+ }
+
+ res, err := client.MDMAppleGetCommandResults(c.String("id"))
+ if err != nil {
+ var nfe service.NotFoundErr
+ if errors.As(err, &nfe) {
+ return errors.New("The command doesn't exist. Please provide a valid command ID. To see a list of commands that were run, run `fleetct get mdm-commands`.")
+ }
+ return err
+ }
+
+ // print the results as a table
+ data := [][]string{}
+ for _, r := range res {
+ data = append(data, []string{
+ r.CommandUUID,
+ r.UpdatedAt.Format(time.RFC3339),
+ r.RequestType,
+ r.Status,
+ r.Hostname,
+ string(r.Result),
+ })
+ }
+ columns := []string{"ID", "TIME", "TYPE", "STATUS", "HOSTNAME", "RESULTS"}
+ printTable(c, columns, data)
+
+ return nil
+ },
+ }
+}
diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go
index 6831944f0a..8fa0f8a36f 100644
--- a/cmd/fleetctl/get_test.go
+++ b/cmd/fleetctl/get_test.go
@@ -1440,3 +1440,123 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
require.Equal(t, "[+] applied 2 teams\n", runAppForTest(t, []string{"apply", "-f", yamlFilePath}))
}
+
+func TestGetMDMCommandResults(t *testing.T) {
+ _, ds := runServerWithMockedDS(t)
+
+ rawXml := `
+
+
+
+ Command
+
+ ManagedOnly
+
+ RequestType
+ ProfileList
+
+ CommandUUID
+ 0001_ProfileList
+
+`
+
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
+ }
+ ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) {
+ if len(uuids) == 0 {
+ return nil, nil
+ }
+ require.Len(t, uuids, 2)
+ return []*fleet.Host{
+ {ID: 1, UUID: uuids[0], Hostname: "host1"},
+ {ID: 2, UUID: uuids[1], Hostname: "host2"},
+ }, nil
+ }
+ ds.GetMDMAppleCommandRequestTypeFunc = func(ctx context.Context, commandUUID string) (string, error) {
+ if commandUUID == "no-such-cmd" {
+ return "", ¬FoundError{}
+ }
+ return "test", nil
+ }
+ ds.GetMDMAppleCommandResultsFunc = func(ctx context.Context, commandUUID string) ([]*fleet.MDMAppleCommandResult, error) {
+ switch commandUUID {
+ case "empty-cmd":
+ return nil, nil
+ case "fail-cmd":
+ return nil, io.EOF
+ default:
+ return []*fleet.MDMAppleCommandResult{
+ {
+ DeviceID: "device1",
+ CommandUUID: commandUUID,
+ Status: "Acknowledged",
+ UpdatedAt: time.Date(2023, 4, 4, 15, 29, 0, 0, time.UTC),
+ RequestType: "test",
+ Result: []byte(rawXml),
+ },
+ {
+ DeviceID: "device2",
+ CommandUUID: commandUUID,
+ Status: "Error",
+ UpdatedAt: time.Date(2023, 4, 4, 15, 29, 0, 0, time.UTC),
+ RequestType: "test",
+ Result: []byte(rawXml),
+ },
+ }, nil
+ }
+ }
+
+ _, err := runAppNoChecks([]string{"get", "mdm-command-results"})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `Required flag "id" not set`)
+
+ _, err = runAppNoChecks([]string{"get", "mdm-command-results", "--id", "no-such-cmd"})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `The command doesn't exist.`)
+
+ _, err = runAppNoChecks([]string{"get", "mdm-command-results", "--id", "fail-cmd"})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `EOF`)
+
+ buf, err := runAppNoChecks([]string{"get", "mdm-command-results", "--id", "empty-cmd"})
+ require.NoError(t, err)
+ require.Contains(t, buf.String(), strings.TrimSpace(`
++----+------+------+--------+----------+---------+
+| ID | TIME | TYPE | STATUS | HOSTNAME | RESULTS |
++----+------+------+--------+----------+---------+
+`))
+
+ buf, err = runAppNoChecks([]string{"get", "mdm-command-results", "--id", "valid-cmd"})
+ require.NoError(t, err)
+ fmt.Println(buf.String())
+ require.Contains(t, buf.String(), strings.TrimSpace(`
++-----------+----------------------+------+--------------+----------+---------------------------------------------------+
+| ID | TIME | TYPE | STATUS | HOSTNAME | RESULTS |
++-----------+----------------------+------+--------------+----------+---------------------------------------------------+
+| valid-cmd | 2023-04-04T15:29:00Z | test | Acknowledged | host1 | |
+| | | | | | |
+| | | | | | Command |
+| | | | | | ManagedOnly |
+| | | | | | RequestType |
+| | | | | | ProfileList |
+| | | | | | CommandUUID |
+| | | | | | 0001_ProfileList |
+| | | | | | |
++-----------+----------------------+------+--------------+----------+---------------------------------------------------+
+| valid-cmd | 2023-04-04T15:29:00Z | test | Error | host2 | |
+| | | | | | |
+| | | | | | Command |
+| | | | | | ManagedOnly |
+| | | | | | RequestType |
+| | | | | | ProfileList |
+| | | | | | CommandUUID |
+| | | | | | 0001_ProfileList |
+| | | | | | |
++-----------+----------------------+------+--------------+----------+---------------------------------------------------+
+`))
+}
diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md
index 3ada65db93..499500522e 100644
--- a/docs/Contributing/API-for-contributors.md
+++ b/docs/Contributing/API-for-contributors.md
@@ -541,6 +541,7 @@ The MDM endpoints exist to support the related command-line interface sub-comman
- [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 MDM command results](#get-mdm-command-results)
### Get Apple MDM
@@ -1046,6 +1047,40 @@ Note that the `EraseDevice` and `DeviceLock` commands are _available in Fleet Pr
}
```
+### Get MDM command results
+
+This endpoint returns the results for an MDM command.
+
+`GET /api/v1/fleet/mdm/apple/commandresults`
+
+#### Parameters
+
+| Name | Type | In | Description |
+| ------------------------- | ------ | ----- | ------------------------------------------------------------------------- |
+| command_uuid | string | query | The unique identifier of the command. |
+
+#### Example
+
+`GET /api/v1/fleet/mdm/apple/commandresults?command_uuid=a2064cef-0000-1234-afb9-283e3c1d487e`
+
+##### Default response
+
+`Status: 200`
+
+```json
+{
+ "results": [
+ "device_id": "145cafeb-87c7-4869-84d5-e4118a927746",
+ "command_uuid": "a2064cef-0000-1234-afb9-283e3c1d487e",
+ "status": "Acknowledged",
+ "updated_at": "2023-04-04:00:00Z",
+ "request_type": "ProfileList",
+ "hostname": "mycomputer",
+ "result": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHBsaXN0IFBVQkxJQyAiLS8vQXBwbGUvL0RURCBQTElTVCAxLjAvL0VOIiAiaHR0cDovL3d3dy5hcHBsZS5jb20vRFREcy9Qcm9wZXJ0eUxpc3QtMS4wLmR0ZCI-CjxwbGlzdCB2ZXJzaW9uPSIxLjAiPgo8ZGljdD4KICAgIDxrZXk-Q29tbWFuZDwva2V5PgogICAgPGRpY3Q-CiAgICAgICAgPGtleT5NYW5hZ2VkT25seTwva2V5PgogICAgICAgIDxmYWxzZS8-CiAgICAgICAgPGtleT5SZXF1ZXN0VHlwZTwva2V5PgogICAgICAgIDxzdHJpbmc-UHJvZmlsZUxpc3Q8L3N0cmluZz4KICAgIDwvZGljdD4KICAgIDxrZXk-Q29tbWFuZFVVSUQ8L2tleT4KICAgIDxzdHJpbmc-MDAwMV9Qcm9maWxlTGlzdDwvc3RyaW5nPgo8L2RpY3Q-CjwvcGxpc3Q-"
+ ]
+}
+```
+
## 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 c231238d09..bd067b7d16 100644
--- a/docs/Using-Fleet/Permissions.md
+++ b/docs/Using-Fleet/Permissions.md
@@ -47,7 +47,8 @@ Users with the Admin role receive all permissions.
| 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 | | ✅ | ✅ |
-| Execute MDM commands on macOS hosts enrolled in Fleet's MDM, and read command results | | ✅ | ✅ |
+| Execute MDM commands on macOS hosts enrolled in Fleet's MDM | | ✅ | ✅ |
+| View results of MDM commands executed on macOS hosts enrolled in Fleet's MDM | ✅ | ✅ | ✅ |
\*Applies only to Fleet Premium
@@ -96,7 +97,8 @@ 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 | | ✅ | ✅ |
+| Execute MDM commands on macOS hosts enrolled in Fleet's MDM | | ✅ | ✅ |
+| View results of MDM commands executed on macOS hosts enrolled in Fleet's MDM | ✅ | ✅ | ✅ |
\* 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 1e24633d18..3d24af5021 100644
--- a/server/authz/policy.rego
+++ b/server/authz/policy.rego
@@ -563,26 +563,34 @@ allow {
action == [read, write][_]
}
-# Global admins and maintainers can read and write (execute) MDM Apple commands.
+# Global admins and maintainers can write (execute) MDM Apple commands.
allow {
object.type == "mdm_apple_command"
subject.global_role == [admin, maintainer][_]
- action == [read, write][_]
+ action == write
}
-# Team admins and maintainers can read and write (execute) MDM Apple commands on hosts of their teams.
+# Team admins and maintainers can 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][_]
+ action == write
}
-# Global admins can read and write Apple MDM command results.
+# Admin, maintainer and observer can read MDM Apple commands.
allow {
- object.type == "mdm_apple_command_result"
- subject.global_role == admin
- action == [read, write][_]
+ object.type == "mdm_apple_command"
+ subject.global_role == [admin, maintainer, observer][_]
+ action == read
+}
+
+# Team admins, maintainers and observers can read 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, observer][_]
+ action == read
}
# Global admins can read and write Apple MDM installers.
diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go
index 2a4fed825a..d1f4e8fe76 100644
--- a/server/datastore/mysql/apple_mdm.go
+++ b/server/datastore/mysql/apple_mdm.go
@@ -273,20 +273,29 @@ WHERE
func (ds *Datastore) GetMDMAppleCommandRequestType(ctx context.Context, commandUUID string) (string, error) {
var rt string
err := sqlx.GetContext(ctx, ds.reader, &rt, `SELECT request_type FROM nano_commands WHERE command_uuid = ?`, commandUUID)
+ if err == sql.ErrNoRows {
+ return "", ctxerr.Wrap(ctx, notFound("MDMAppleCommand").WithName(commandUUID))
+ }
return rt, err
}
-func (ds *Datastore) GetMDMAppleCommandResults(ctx context.Context, commandUUID string) (map[string]*fleet.MDMAppleCommandResult, error) {
+func (ds *Datastore) GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*fleet.MDMAppleCommandResult, error) {
query := `
SELECT
- id,
- command_uuid,
- status,
- result
+ ncr.id as device_id,
+ ncr.command_uuid,
+ ncr.status,
+ ncr.result,
+ ncr.updated_at,
+ nc.request_type
FROM
- nano_command_results
+ nano_command_results ncr
+INNER JOIN
+ nano_commands nc
+ON
+ ncr.command_uuid = nc.command_uuid
WHERE
- command_uuid = ?
+ ncr.command_uuid = ?
`
var results []*fleet.MDMAppleCommandResult
@@ -300,13 +309,7 @@ WHERE
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get command results")
}
-
- resultsMap := make(map[string]*fleet.MDMAppleCommandResult, len(results))
- for _, result := range results {
- resultsMap[result.ID] = result
- }
-
- return resultsMap, nil
+ return results, nil
}
func (ds *Datastore) NewMDMAppleInstaller(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*fleet.MDMAppleInstaller, error) {
diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go
index c8654d29e1..61725a6fb2 100644
--- a/server/datastore/mysql/apple_mdm_test.go
+++ b/server/datastore/mysql/apple_mdm_test.go
@@ -8,13 +8,19 @@ import (
"testing"
"time"
+ "github.com/VividCortex/mysqlerr"
"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"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
+ "github.com/go-sql-driver/mysql"
+ "github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanodep/godep"
+ "github.com/micromdm/nanodep/tokenpki"
+ "github.com/micromdm/nanomdm/mdm"
+ "github.com/micromdm/nanomdm/push"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -40,6 +46,7 @@ func TestMDMAppleConfigProfile(t *testing.T) {
{"TestIgnoreMDMClientError", testIgnoreMDMClientError},
{"TestDeleteMDMAppleProfilesForHost", testDeleteMDMAppleProfilesForHost},
{"TestBulkSetPendingMDMAppleHostProfiles", testBulkSetPendingMDMAppleHostProfiles},
+ {"TestGetMDMAppleCommandResults", testGetMDMAppleCommandResults},
{"TestBulkUpsertMDMAppleConfigProfiles", testBulkUpsertMDMAppleConfigProfile},
}
@@ -2352,6 +2359,176 @@ func testBulkSetPendingMDMAppleHostProfiles(t *testing.T, ds *Datastore) {
})
}
+func testGetMDMAppleCommandResults(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+
+ createRawCmd := func(cmdUUID string) string {
+ return fmt.Sprintf(`
+
+
+
+ Command
+
+ ManagedOnly
+
+ RequestType
+ ProfileList
+
+ CommandUUID
+ %s
+
+`, cmdUUID)
+ }
+
+ // no enrolled host, unknown command
+ res, err := ds.GetMDMAppleCommandResults(ctx, uuid.New().String())
+ require.NoError(t, err)
+ require.Empty(t, res)
+
+ // create some hosts, all enrolled
+ enrolledHosts := make([]*fleet.Host, 3)
+ for i := 0; i < 3; i++ {
+ h, err := ds.NewHost(ctx, &fleet.Host{
+ Hostname: fmt.Sprintf("test-host%d-name", i),
+ OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
+ NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
+ UUID: fmt.Sprintf("test-uuid-%d", i),
+ Platform: "darwin",
+ })
+ require.NoError(t, err)
+ nanoEnroll(t, ds, h, false)
+ enrolledHosts[i] = h
+ t.Logf("enrolled host [%d]: %s", i, h.UUID)
+ }
+
+ // create a non-enrolled host
+ i := 3
+ unenrolledHost, err := ds.NewHost(ctx, &fleet.Host{
+ Hostname: fmt.Sprintf("test-host%d-name", i),
+ OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
+ NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
+ UUID: fmt.Sprintf("test-uuid-%d", i),
+ Platform: "darwin",
+ })
+ require.NoError(t, err)
+
+ commander, storage := createMDMAppleCommanderAndStorage(t, ds)
+
+ // enqueue a command for an unenrolled host fails with a foreign key error (no enrollment)
+ uuid1 := uuid.New().String()
+ err = commander.EnqueueCommand(ctx, []string{unenrolledHost.UUID}, createRawCmd(uuid1))
+ require.Error(t, err)
+ var mysqlErr *mysql.MySQLError
+ require.ErrorAs(t, err, &mysqlErr)
+ require.Equal(t, uint16(mysqlerr.ER_NO_REFERENCED_ROW_2), mysqlErr.Number)
+
+ // command has no results
+ res, err = ds.GetMDMAppleCommandResults(ctx, uuid1)
+ require.NoError(t, err)
+ require.Empty(t, res)
+
+ // enqueue a command for a couple of enrolled hosts
+ uuid2 := uuid.New().String()
+ rawCmd2 := createRawCmd(uuid2)
+ err = commander.EnqueueCommand(ctx, []string{enrolledHosts[0].UUID, enrolledHosts[1].UUID}, rawCmd2)
+ require.NoError(t, err)
+
+ // command has no results yet
+ res, err = ds.GetMDMAppleCommandResults(ctx, uuid2)
+ require.NoError(t, err)
+ require.Empty(t, res)
+
+ // simulate a result for enrolledHosts[0]
+ err = storage.StoreCommandReport(&mdm.Request{
+ EnrollID: &mdm.EnrollID{ID: enrolledHosts[0].UUID},
+ Context: ctx,
+ }, &mdm.CommandResults{
+ CommandUUID: uuid2,
+ Status: "Acknowledged",
+ RequestType: "ProfileList",
+ Raw: []byte(rawCmd2),
+ })
+ require.NoError(t, err)
+
+ // command has a result for [0]
+ res, err = ds.GetMDMAppleCommandResults(ctx, uuid2)
+ require.NoError(t, err)
+ require.Len(t, res, 1)
+ require.NotZero(t, res[0].UpdatedAt)
+ res[0].UpdatedAt = time.Time{}
+ require.Equal(t, res[0], &fleet.MDMAppleCommandResult{
+ DeviceID: enrolledHosts[0].UUID,
+ CommandUUID: uuid2,
+ Status: "Acknowledged",
+ RequestType: "ProfileList",
+ Result: []byte(rawCmd2),
+ })
+
+ // simulate a result for enrolledHosts[1]
+ err = storage.StoreCommandReport(&mdm.Request{
+ EnrollID: &mdm.EnrollID{ID: enrolledHosts[1].UUID},
+ Context: ctx,
+ }, &mdm.CommandResults{
+ CommandUUID: uuid2,
+ Status: "Error",
+ RequestType: "ProfileList",
+ Raw: []byte(rawCmd2),
+ })
+ require.NoError(t, err)
+
+ // command has both results
+ res, err = ds.GetMDMAppleCommandResults(ctx, uuid2)
+ require.NoError(t, err)
+ require.Len(t, res, 2)
+
+ require.NotZero(t, res[0].UpdatedAt)
+ res[0].UpdatedAt = time.Time{}
+ require.NotZero(t, res[1].UpdatedAt)
+ res[1].UpdatedAt = time.Time{}
+
+ require.ElementsMatch(t, res, []*fleet.MDMAppleCommandResult{
+ {
+ DeviceID: enrolledHosts[0].UUID,
+ CommandUUID: uuid2,
+ Status: "Acknowledged",
+ RequestType: "ProfileList",
+ Result: []byte(rawCmd2),
+ },
+ {
+ DeviceID: enrolledHosts[1].UUID,
+ CommandUUID: uuid2,
+ Status: "Error",
+ RequestType: "ProfileList",
+ Result: []byte(rawCmd2),
+ },
+ })
+}
+
+func createMDMAppleCommanderAndStorage(t *testing.T, ds *Datastore) (*apple_mdm.MDMAppleCommander, *NanoMDMStorage) {
+ testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
+ require.NoError(t, err)
+ testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
+ testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
+ mdmStorage, err := ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
+ require.NoError(t, err)
+
+ return apple_mdm.NewMDMAppleCommander(mdmStorage, pusherFunc(okPusherFunc)), mdmStorage
+}
+
+func okPusherFunc(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
+}
+
+type pusherFunc func(context.Context, []string) (map[string]*push.Response, error)
+
+func (f pusherFunc) Push(ctx context.Context, ids []string) (map[string]*push.Response, error) {
+ return f(ctx, ids)
+}
+
func testBulkUpsertMDMAppleConfigProfile(t *testing.T, ds *Datastore) {
ctx := context.Background()
mc := mobileconfig.Mobileconfig([]byte("TestConfigProfile"))
diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go
index 269c61b85d..d6ac99df0e 100644
--- a/server/fleet/apple_mdm.go
+++ b/server/fleet/apple_mdm.go
@@ -151,22 +151,26 @@ type MDMAppleDEPKeyPair struct {
PrivateKey []byte `json:"private_key"`
}
-// MDMAppleCommandResult holds the result of a command execution provided by the target device.
+// MDMAppleCommandResult holds the result of a command execution provided by
+// the target device.
type MDMAppleCommandResult struct {
- // ID is the enrollment ID. This should be the same as the device ID.
- ID string `json:"id" db:"id"`
+ // DeviceID is the MDM enrollment ID. This is the same as the host UUID.
+ DeviceID string `json:"device_id" db:"device_id"`
// CommandUUID is the unique identifier of the command.
CommandUUID string `json:"command_uuid" db:"command_uuid"`
// Status is the command status. One of Acknowledged, Error, or NotNow.
Status string `json:"status" db:"status"`
+ // UpdatedAt is the last update timestamp of the command result.
+ UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
+ // RequestType is the command's request type, which is basically the
+ // command name.
+ RequestType string `json:"request_type" db:"request_type"`
// Result is the original command result XML plist. If the status is Error, it will include the
// ErrorChain key with more information.
Result []byte `json:"result" db:"result"`
-}
-
-// AuthzType implements authz.AuthzTyper.
-func (m MDMAppleCommandResult) AuthzType() string {
- return "mdm_apple_command_result"
+ // Hostname is not filled by the query, it is filled in the service layer
+ // afterwards. To make that explicit, the db field tag is explicitly ignored.
+ Hostname string `json:"hostname" db:"-"`
}
// MDMAppleInstaller holds installer packages for Apple devices.
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index 85a559b0d9..2e10133929 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -770,8 +770,7 @@ type Datastore interface {
ListMDMAppleEnrollmentProfiles(ctx context.Context) ([]*MDMAppleEnrollmentProfile, error)
// GetMDMAppleCommandResults returns the execution results of a command identified by a CommandUUID.
- // The map returned has a result for each target device ID.
- GetMDMAppleCommandResults(ctx context.Context, commandUUID string) (map[string]*MDMAppleCommandResult, error)
+ GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*MDMAppleCommandResult, error)
// NewMDMAppleInstaller creates and stores an Apple installer to Fleet.
NewMDMAppleInstaller(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*MDMAppleInstaller, error)
diff --git a/server/fleet/service.go b/server/fleet/service.go
index e3383f17c9..ab14029ffc 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -601,8 +601,7 @@ type Service interface {
GetDeviceMDMAppleEnrollmentProfile(ctx context.Context) ([]byte, error)
// GetMDMAppleCommandResults returns the execution results of a command identified by a CommandUUID.
- // The map returned has a result for each target device ID.
- GetMDMAppleCommandResults(ctx context.Context, commandUUID string) (map[string]*MDMAppleCommandResult, error)
+ GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*MDMAppleCommandResult, error)
// UploadMDMAppleInstaller uploads an Apple installer to Fleet.
UploadMDMAppleInstaller(ctx context.Context, name string, size int64, installer io.Reader) (*MDMAppleInstaller, error)
diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go
new file mode 100644
index 0000000000..dfb1bdbd7b
--- /dev/null
+++ b/server/mdm/apple/commander.go
@@ -0,0 +1,170 @@
+package apple_mdm
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
+ "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
+ "github.com/micromdm/nanomdm/mdm"
+ nanomdm_push "github.com/micromdm/nanomdm/push"
+ nanomdm_storage "github.com/micromdm/nanomdm/storage"
+)
+
+// MDMAppleCommander contains methods to enqueue commands managed by Fleet and
+// send push notifications to hosts.
+//
+// It's intentionally decoupled from fleet.Service so it can be used internally
+// in crons and other services, leaving authentication/permission handling to
+// the caller.
+type MDMAppleCommander struct {
+ storage nanomdm_storage.AllStorage
+ pusher nanomdm_push.Pusher
+}
+
+// NewMDMAppleCommander creates a new commander instance.
+func NewMDMAppleCommander(mdmStorage nanomdm_storage.AllStorage, mdmPushService nanomdm_push.Pusher) *MDMAppleCommander {
+ return &MDMAppleCommander{
+ storage: mdmStorage,
+ pusher: mdmPushService,
+ }
+}
+
+// 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 {
+ base64Profile := base64.StdEncoding.EncodeToString(profile)
+ raw := fmt.Sprintf(`
+
+
+
+ CommandUUID
+ %s
+ Command
+
+ RequestType
+ InstallProfile
+ Payload
+ %s
+
+
+`, uuid, base64Profile)
+ err := svc.EnqueueCommand(ctx, hostUUIDs, raw)
+ return ctxerr.Wrap(ctx, err, "commander install profile")
+}
+
+// InstallProfile 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(`
+
+
+
+ CommandUUID
+ %s
+ Command
+
+ RequestType
+ RemoveProfile
+ Identifier
+ %s
+
+
+`, uuid, profileIdentifier)
+ err := svc.EnqueueCommand(ctx, hostUUIDs, raw)
+ return ctxerr.Wrap(ctx, err, "commander remove profile")
+}
+
+func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, hostUUIDs []string, uuid string) error {
+ pin := GenerateRandomPin(6)
+ raw := fmt.Sprintf(`
+
+
+
+ CommandUUID
+ %s
+ Command
+
+ RequestType
+ DeviceLock
+ PIN
+ %s
+
+
+`, uuid, pin)
+ return svc.EnqueueCommand(ctx, hostUUIDs, raw)
+}
+
+func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, hostUUIDs []string, uuid string) error {
+ pin := GenerateRandomPin(6)
+ raw := fmt.Sprintf(`
+
+
+
+ CommandUUID
+ %s
+ Command
+
+ RequestType
+ EraseDevice
+ PIN
+ %s
+
+
+`, uuid, pin)
+ return svc.EnqueueCommand(ctx, hostUUIDs, raw)
+}
+
+// EnqueueCommand takes care of enqueuing the commands and sending push
+// notifications to the devices.
+//
+// Always sending the push notification when a command is enqueued was decided
+// internally, leaving making pushes optional as an optimization to be tackled
+// later.
+func (svc *MDMAppleCommander) EnqueueCommand(ctx context.Context, hostUUIDs []string, rawCommand string) error {
+ cmd, err := mdm.DecodeCommand([]byte(rawCommand))
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "commander enqueue")
+ }
+
+ // MySQL implementation always returns nil for the first parameter
+ _, err = svc.storage.EnqueueCommand(ctx, hostUUIDs, cmd)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "commander enqueue")
+ }
+
+ apnsResponses, err := svc.pusher.Push(ctx, hostUUIDs)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "commander push")
+ }
+
+ // Even if we didn't get an error, some of the APNs
+ // responses might have failed, signal that to the caller.
+ var failed []string
+ for uuid, response := range apnsResponses {
+ if response.Err != nil {
+ failed = append(failed, uuid)
+ }
+ }
+ if len(failed) > 0 {
+ return &APNSDeliveryError{FailedUUIDs: failed, Err: err}
+ }
+
+ return nil
+}
+
+// APNSDeliveryError records an error and the associated host UUIDs in which it
+// occurred.
+type APNSDeliveryError struct {
+ FailedUUIDs []string
+ Err error
+}
+
+func (e *APNSDeliveryError) Error() string {
+ return fmt.Sprintf("APNS delivery failed with: %e, for UUIDs: %v", e.Err, e.FailedUUIDs)
+}
+
+func (e *APNSDeliveryError) Unwrap() error { return e.Err }
+
+func (e *APNSDeliveryError) StatusCode() int { return http.StatusBadGateway }
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index e6d7acf670..b4730f9565 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -536,7 +536,7 @@ type GetMDMAppleEnrollmentProfileByTokenFunc func(ctx context.Context, token str
type ListMDMAppleEnrollmentProfilesFunc func(ctx context.Context) ([]*fleet.MDMAppleEnrollmentProfile, error)
-type GetMDMAppleCommandResultsFunc func(ctx context.Context, commandUUID string) (map[string]*fleet.MDMAppleCommandResult, error)
+type GetMDMAppleCommandResultsFunc func(ctx context.Context, commandUUID string) ([]*fleet.MDMAppleCommandResult, error)
type NewMDMAppleInstallerFunc func(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*fleet.MDMAppleInstaller, error)
@@ -3265,7 +3265,7 @@ func (s *DataStore) ListMDMAppleEnrollmentProfiles(ctx context.Context) ([]*flee
return s.ListMDMAppleEnrollmentProfilesFunc(ctx)
}
-func (s *DataStore) GetMDMAppleCommandResults(ctx context.Context, commandUUID string) (map[string]*fleet.MDMAppleCommandResult, error) {
+func (s *DataStore) GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*fleet.MDMAppleCommandResult, error) {
s.mu.Lock()
s.GetMDMAppleCommandResultsFuncInvoked = true
s.mu.Unlock()
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index 9d29ad0cab..cb36a606ee 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -34,8 +34,6 @@ import (
"github.com/micromdm/micromdm/mdm/appmanifest"
"github.com/micromdm/nanodep/godep"
"github.com/micromdm/nanomdm/mdm"
- nanomdm_push "github.com/micromdm/nanomdm/push"
- nanomdm_storage "github.com/micromdm/nanomdm/storage"
)
type createMDMAppleEnrollmentProfileRequest struct {
@@ -195,8 +193,8 @@ type getMDMAppleCommandResultsRequest struct {
}
type getMDMAppleCommandResultsResponse struct {
- Results map[string]*fleet.MDMAppleCommandResult `json:"results,omitempty"`
- Err error `json:"error,omitempty"`
+ Results []*fleet.MDMAppleCommandResult `json:"results,omitempty"`
+ Err error `json:"error,omitempty"`
}
func (r getMDMAppleCommandResultsResponse) error() error { return r.Err }
@@ -215,16 +213,80 @@ func getMDMAppleCommandResultsEndpoint(ctx context.Context, request interface{},
}, nil
}
-func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID string) (map[string]*fleet.MDMAppleCommandResult, error) {
- if err := svc.authz.Authorize(ctx, &fleet.MDMAppleCommandResult{}, fleet.ActionRead); err != nil {
+func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*fleet.MDMAppleCommandResult, error) {
+ // first, authorize that the user has the right to list hosts
+ if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
+ vc, ok := viewer.FromContext(ctx)
+ if !ok {
+ return nil, fleet.ErrNoContext
+ }
+
+ // check that command exists first, to return 404 on invalid commands
+ // (the command may exist but have no results yet).
+ if _, err := svc.ds.GetMDMAppleCommandRequestType(ctx, commandUUID); err != nil {
+ return nil, err
+ }
+
+ // next, we need to read the command results before we know what hosts (and
+ // therefore what teams) we're dealing with.
results, err := svc.ds.GetMDMAppleCommandResults(ctx, commandUUID)
if err != nil {
return nil, err
}
+ // now we can load the hosts (lite) corresponding to those command results,
+ // and do the final authorization check with the proper team(s). Include observers,
+ // as they are able to view command results for their teams' hosts.
+ filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
+ hostUUIDs := make([]string, len(results))
+ for i, res := range results {
+ hostUUIDs[i] = res.DeviceID
+ }
+ hosts, err := svc.ds.ListHostsLiteByUUIDs(ctx, filter, hostUUIDs)
+ if err != nil {
+ return nil, err
+ }
+ if len(hosts) == 0 {
+ // do not return 404 here, as it's possible for a command to not have
+ // results yet
+ return nil, nil
+ }
+
+ // collect the team IDs and verify that the user has access to run commands
+ // on all affected teams. Index the hosts by uuid for easly lookup as
+ // afterwards we'll want to store the hostname on the returned results.
+ hostsByUUID := make(map[string]*fleet.Host, len(hosts))
+ teamIDs := make(map[uint]bool)
+ for _, h := range hosts {
+ var id uint
+ if h.TeamID != nil {
+ id = *h.TeamID
+ }
+ teamIDs[id] = true
+ hostsByUUID[h.UUID] = h
+ }
+
+ 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.ActionRead); err != nil {
+ return nil, ctxerr.Wrap(ctx, err)
+ }
+ }
+
+ // add the hostnames to the results
+ for _, res := range results {
+ if h := hostsByUUID[res.DeviceID]; h != nil {
+ res.Hostname = hostsByUUID[res.DeviceID].Hostname
+ }
+ }
return results, nil
}
@@ -976,9 +1038,10 @@ func (svc *Service) EnqueueMDMAppleCommand(
}
}
- 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
+ if err := svc.mdmAppleCommander.EnqueueCommand(ctx, deviceIDs, string(rawXMLCmd)); err != nil {
+ // if at least one UUID enqueued properly, return success, otherwise return
+ // 500
+ var apnsErr *apple_mdm.APNSDeliveryError
var mysqlErr *mysql.MySQLError
if errors.As(err, &apnsErr) {
if len(apnsErr.FailedUUIDs) < len(deviceIDs) {
@@ -1796,162 +1859,6 @@ func (svc *MDMAppleCheckinAndCommandService) fmtErrorChain(chain []mdm.ErrorChai
return sb.String()
}
-// MDMAppleCommander contains methods to enqueue commands managed by Fleet and
-// send push notifications to hosts.
-//
-// It's intentionally decoupled from fleet.Service so it can be used internally
-// in crons and other services, leaving authentication/permission handling to
-// the caller.
-type MDMAppleCommander struct {
- storage nanomdm_storage.AllStorage
- pusher nanomdm_push.Pusher
-}
-
-// NewMDMAppleCommander creates a new commander instance.
-func NewMDMAppleCommander(mdmStorage nanomdm_storage.AllStorage, mdmPushService nanomdm_push.Pusher) *MDMAppleCommander {
- return &MDMAppleCommander{
- storage: mdmStorage,
- pusher: mdmPushService,
- }
-}
-
-// 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 {
- base64Profile := base64.StdEncoding.EncodeToString(profile)
- raw := fmt.Sprintf(`
-
-
-
- CommandUUID
- %s
- Command
-
- RequestType
- InstallProfile
- Payload
- %s
-
-
-`, uuid, base64Profile)
- err := svc.enqueue(ctx, hostUUIDs, raw)
- return ctxerr.Wrap(ctx, err, "commander install profile")
-}
-
-// InstallProfile 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(`
-
-
-
- CommandUUID
- %s
- Command
-
- RequestType
- RemoveProfile
- Identifier
- %s
-
-
-`, uuid, profileIdentifier)
- err := svc.enqueue(ctx, hostUUIDs, raw)
- return ctxerr.Wrap(ctx, err, "commander remove profile")
-}
-
-func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, hostUUIDs []string, uuid string) error {
- pin := apple_mdm.GenerateRandomPin(6)
- raw := fmt.Sprintf(`
-
-
-
- CommandUUID
- %s
- Command
-
- RequestType
- DeviceLock
- PIN
- %s
-
-
-`, uuid, pin)
- return svc.enqueue(ctx, hostUUIDs, raw)
-}
-
-func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, hostUUIDs []string, uuid string) error {
- pin := apple_mdm.GenerateRandomPin(6)
- raw := fmt.Sprintf(`
-
-
-
- CommandUUID
- %s
- Command
-
- RequestType
- EraseDevice
- PIN
- %s
-
-
-`, uuid, pin)
- return svc.enqueue(ctx, hostUUIDs, raw)
-}
-
-// enqueue takes care of enqueuing the commands and sending push notifications
-// to the devices.
-//
-// Always sending the push notification when a command is enqueued was decided
-// internally, leaving making pushes optional as an optimization to be tackled
-// later.
-func (svc *MDMAppleCommander) enqueue(ctx context.Context, hostUUIDs []string, rawCommand string) error {
- cmd, err := mdm.DecodeCommand([]byte(rawCommand))
- if err != nil {
- return ctxerr.Wrap(ctx, err, "commander enqueue")
- }
-
- // MySQL implementation always returns nil for the first parameter
- _, err = svc.storage.EnqueueCommand(ctx, hostUUIDs, cmd)
- if err != nil {
- return ctxerr.Wrap(ctx, err, "commander enqueue")
- }
-
- apnsResponses, err := svc.pusher.Push(ctx, hostUUIDs)
- if err != nil {
- return ctxerr.Wrap(ctx, err, "commander push")
- }
-
- // Even if we didn't get an error, some of the APNs
- // responses might have failed, signal that to the caller.
- var failed []string
- for uuid, response := range apnsResponses {
- if response.Err != nil {
- failed = append(failed, uuid)
- }
- }
- if len(failed) > 0 {
- return &APNSDeliveryError{FailedUUIDs: failed, Err: err}
- }
-
- return nil
-}
-
-// APNSDeliveryError records an error and the associated host UUIDs in which it
-// occurred.
-type APNSDeliveryError struct {
- FailedUUIDs []string
- Err error
-}
-
-func (e *APNSDeliveryError) Error() string {
- return fmt.Sprintf("APNS delivery failed with: %e, for UUIDs: %v", e.Err, e.FailedUUIDs)
-}
-
-func (e *APNSDeliveryError) Unwrap() error { return e.Err }
-
-func (e *APNSDeliveryError) StatusCode() int { return http.StatusBadGateway }
-
// ensureFleetdConfig ensures there's a fleetd configuration profile in
// mdm_apple_configuration_profiles for each team and for "no team"
//
@@ -2022,7 +1929,7 @@ func ensureFleetdConfig(ctx context.Context, ds fleet.Datastore, logger kitlog.L
func ReconcileProfiles(
ctx context.Context,
ds fleet.Datastore,
- commander *MDMAppleCommander,
+ commander *apple_mdm.MDMAppleCommander,
logger kitlog.Logger,
) error {
if err := ensureFleetdConfig(ctx, ds, logger); err != nil {
@@ -2147,7 +2054,7 @@ func ReconcileProfiles(
err = commander.RemoveProfile(ctx, target.hostUUIDs, target.profIdent, target.cmdUUID)
}
- var e *APNSDeliveryError
+ var e *apple_mdm.APNSDeliveryError
switch {
case errors.As(err, &e):
level.Debug(logger).Log("err", "sending push notifications, profiles still enqueued", "details", err)
diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go
index b2c8120ac0..018259365e 100644
--- a/server/service/apple_mdm_test.go
+++ b/server/service/apple_mdm_test.go
@@ -130,9 +130,6 @@ func setupAppleMDMService(t *testing.T) (fleet.Service, context.Context, *mock.S
ds.ListMDMAppleEnrollmentProfilesFunc = func(ctx context.Context) ([]*fleet.MDMAppleEnrollmentProfile, error) {
return nil, nil
}
- ds.GetMDMAppleCommandResultsFunc = func(ctx context.Context, commandUUID string) (map[string]*fleet.MDMAppleCommandResult, error) {
- return nil, nil
- }
ds.NewMDMAppleInstallerFunc = func(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*fleet.MDMAppleInstaller, error) {
return nil, nil
}
@@ -157,6 +154,9 @@ func setupAppleMDMService(t *testing.T) (fleet.Service, context.Context, *mock.S
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoEnrollment, error) {
return &fleet.NanoEnrollment{Enabled: false}, nil
}
+ ds.GetMDMAppleCommandRequestTypeFunc = func(ctx context.Context, commandUUID string) (string, error) {
+ return "", nil
+ }
return svc, ctx, ds
}
@@ -181,8 +181,6 @@ func TestAppleMDMAuthorization(t *testing.T) {
checkAuthErr(t, err, shouldFailWithAuth)
_, err = svc.ListMDMAppleEnrollmentProfiles(ctx)
checkAuthErr(t, err, shouldFailWithAuth)
- _, err = svc.GetMDMAppleCommandResults(ctx, "foo")
- checkAuthErr(t, err, shouldFailWithAuth)
_, err = svc.UploadMDMAppleInstaller(ctx, "foo", 3, bytes.NewReader([]byte("foo")))
checkAuthErr(t, err, shouldFailWithAuth)
_, err = svc.GetMDMAppleInstallerByID(ctx, 42)
@@ -263,7 +261,7 @@ func TestAppleMDMAuthorization(t *testing.T) {
`))
- cases := []struct {
+ enqueueCmdCases := []struct {
desc string
user *fleet.User
uuids []string
@@ -272,14 +270,16 @@ func TestAppleMDMAuthorization(t *testing.T) {
{"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},
+ {"observer cannot run", test.UserObserver, []string{"host1", "host2", "host3", "host4"}, true},
{"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 observer cannot run team 1", test.UserTeamObserverTeam1, []string{"host1", "host2"}, true},
{"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 {
+ for _, c := range enqueueCmdCases {
t.Run(c.desc, func(t *testing.T) {
ctx = test.UserContext(ctx, c.user)
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64FreeCmd, c.uuids, false)
@@ -306,6 +306,63 @@ func TestAppleMDMAuthorization(t *testing.T) {
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"}, false)
require.Error(t, err)
require.ErrorContains(t, err, fleet.ErrMissingLicense.Error())
+
+ cmdUUIDToHostUUIDs := map[string][]string{
+ "uuidTm1": {"host1", "host2"},
+ "uuidTm2": {"host3"},
+ "uuidNoTm": {"host4"},
+ "uuidMixTm1Tm2": {"host1", "host3"},
+ }
+ ds.GetMDMAppleCommandResultsFunc = func(ctx context.Context, commandUUID string) ([]*fleet.MDMAppleCommandResult, error) {
+ hosts := cmdUUIDToHostUUIDs[commandUUID]
+ res := make([]*fleet.MDMAppleCommandResult, 0, len(hosts))
+ for _, h := range hosts {
+ res = append(res, &fleet.MDMAppleCommandResult{
+ DeviceID: h,
+ })
+ }
+ return res, nil
+ }
+
+ cmdResultsCases := []struct {
+ desc string
+ user *fleet.User
+ cmdUUID string
+ shoudFailWithAuth bool
+ }{
+ {"no role", test.UserNoRoles, "uuidTm1", true},
+ {"maintainer can view", test.UserMaintainer, "uuidTm1", false},
+ {"maintainer can view", test.UserMaintainer, "uuidTm2", false},
+ {"maintainer can view", test.UserMaintainer, "uuidNoTm", false},
+ {"maintainer can view", test.UserMaintainer, "uuidMixTm1Tm2", false},
+ {"observer can view", test.UserObserver, "uuidTm1", false},
+ {"observer can view", test.UserObserver, "uuidTm2", false},
+ {"observer can view", test.UserObserver, "uuidNoTm", false},
+ {"observer can view", test.UserObserver, "uuidMixTm1Tm2", false},
+ {"admin can view", test.UserAdmin, "uuidTm1", false},
+ {"admin can view", test.UserAdmin, "uuidTm2", false},
+ {"admin can view", test.UserAdmin, "uuidNoTm", false},
+ {"admin can view", test.UserAdmin, "uuidMixTm1Tm2", false},
+ {"tm1 maintainer can view tm1", test.UserTeamMaintainerTeam1, "uuidTm1", false},
+ {"tm1 maintainer cannot view tm2", test.UserTeamMaintainerTeam1, "uuidTm2", true},
+ {"tm1 maintainer cannot view no team", test.UserTeamMaintainerTeam1, "uuidNoTm", true},
+ {"tm1 maintainer cannot view mix", test.UserTeamMaintainerTeam1, "uuidMixTm1Tm2", true},
+ {"tm1 observer can view tm1", test.UserTeamObserverTeam1, "uuidTm1", false},
+ {"tm1 observer cannot view tm2", test.UserTeamObserverTeam1, "uuidTm2", true},
+ {"tm1 observer cannot view no team", test.UserTeamObserverTeam1, "uuidNoTm", true},
+ {"tm1 observer cannot view mix", test.UserTeamObserverTeam1, "uuidMixTm1Tm2", true},
+ {"tm1 admin can view tm1", test.UserTeamAdminTeam1, "uuidTm1", false},
+ {"tm1 admin cannot view tm2", test.UserTeamAdminTeam1, "uuidTm2", true},
+ {"tm1 admin cannot view no team", test.UserTeamAdminTeam1, "uuidNoTm", true},
+ {"tm1 admin cannot view mix", test.UserTeamAdminTeam1, "uuidMixTm1Tm2", true},
+ }
+ for _, c := range cmdResultsCases {
+ t.Run(c.desc, func(t *testing.T) {
+ ctx = test.UserContext(ctx, c.user)
+ _, err = svc.GetMDMAppleCommandResults(ctx, c.cmdUUID)
+ checkAuthErr(t, err, c.shoudFailWithAuth)
+ })
+ }
}
func TestMDMAppleEnrollURL(t *testing.T) {
@@ -1498,7 +1555,7 @@ func TestMDMAppleCommander(t *testing.T) {
pushFactory,
NewNanoMDMLogger(kitlog.NewJSONLogger(os.Stdout)),
)
- cmdr := NewMDMAppleCommander(mdmStorage, pusher)
+ cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
// TODO(roberto): there's a data race in the mock when more
// than one host ID is provided because the pusher uses one
@@ -1573,7 +1630,7 @@ func TestMDMAppleReconcileProfiles(t *testing.T) {
pushFactory,
NewNanoMDMLogger(kitlog.NewNopLogger()),
)
- cmdr := NewMDMAppleCommander(mdmStorage, pusher)
+ cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
hostUUID, hostUUID2 := "ABC-DEF", "GHI-JKL"
contents1 := []byte("test-content-1")
contents1Base64 := base64.StdEncoding.EncodeToString(contents1)
diff --git a/server/service/client_apple_mdm.go b/server/service/client_apple_mdm.go
index 6dd34eccf6..665b23c797 100644
--- a/server/service/client_apple_mdm.go
+++ b/server/service/client_apple_mdm.go
@@ -63,7 +63,7 @@ func (c *Client) EnqueueCommand(deviceIDs []string, rawPlist []byte) (*fleet.Com
return response.CommandEnqueueResult, nil
}
-func (c *Client) MDMAppleGetCommandResults(commandUUID string) (map[string]*fleet.MDMAppleCommandResult, error) {
+func (c *Client) MDMAppleGetCommandResults(commandUUID string) ([]*fleet.MDMAppleCommandResult, error) {
verb, path := http.MethodGet, "/api/latest/fleet/mdm/apple/commandresults"
query := url.Values{}
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index 716137a142..979717343b 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -76,6 +76,7 @@ type integrationMDMTestSuite struct {
profileSchedule *schedule.Schedule
onScheduleDone func() // function called when profileSchedule.Trigger() job completed
oktaMock *externalsvc.MockOktaServer
+ mdmStorage *mysql.NanoMDMStorage
}
func (s *integrationMDMTestSuite) SetupSuite() {
@@ -153,7 +154,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
if s.onScheduleDone != nil {
defer s.onScheduleDone()
}
- return ReconcileProfiles(ctx, ds, NewMDMAppleCommander(mdmStorage, mdmPushService), logger)
+ return ReconcileProfiles(ctx, ds, apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService), logger)
}),
)
return profileSchedule, nil
@@ -172,6 +173,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
s.depSchedule = depSchedule
s.profileSchedule = profileSchedule
s.oktaMock = oktaMock
+ s.mdmStorage = mdmStorage
fleetdmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status := s.fleetDMNextCSRStatus.Swap(http.StatusOK)
@@ -2507,13 +2509,18 @@ func (s *integrationMDMTestSuite) TestFleetdConfiguration() {
}
func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
+ ctx := context.Background()
t := s.T()
unenrolledHost := createHostAndDeviceToken(t, s.ds, "unused")
enrolledHost := newMDMEnrolledDevice(s)
- newRawCmd := func() string {
- return base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf(`
+ base64Cmd := func(rawCmd string) string {
+ return base64.RawStdEncoding.EncodeToString([]byte(rawCmd))
+ }
+
+ newRawCmd := func(cmdUUID string) string {
+ return fmt.Sprintf(`
@@ -2527,39 +2534,78 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
CommandUUID
%s
-`, uuid.New().String())))
+`, cmdUUID)
}
// call with unknown host UUID
+ uuid1 := uuid.New().String()
s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue",
enqueueMDMAppleCommandRequest{
- Command: newRawCmd(),
+ Command: base64Cmd(newRawCmd(uuid1)),
DeviceIDs: []string{"no-such-host"},
}, http.StatusNotFound)
+ // get command results returns 404, that command does not exist
+ var cmdResResp getMDMAppleCommandResultsResponse
+ s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commandresults", nil, http.StatusNotFound, &cmdResResp, "command_uuid", uuid1)
+
// call with unenrolled host UUID
res := s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue",
enqueueMDMAppleCommandRequest{
- Command: newRawCmd(),
+ Command: base64Cmd(newRawCmd(uuid.New().String())),
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()
+ uuid2 := uuid.New().String()
+ rawCmd := newRawCmd(uuid2)
var resp enqueueMDMAppleCommandResponse
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enqueue",
enqueueMDMAppleCommandRequest{
- Command: rawCmd,
+ Command: base64Cmd(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.Contains(t, rawCmd, resp.CommandUUID)
require.Empty(t, resp.FailedUUIDs)
require.Equal(t, "ProfileList", resp.RequestType)
+
+ // the command exists but no results yet
+ s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commandresults", nil, http.StatusOK, &cmdResResp, "command_uuid", uuid2)
+ require.Len(t, cmdResResp.Results, 0)
+
+ // simulate a result and call again
+ err := s.mdmStorage.StoreCommandReport(&mdm.Request{
+ EnrollID: &mdm.EnrollID{ID: enrolledHost.uuid},
+ Context: ctx,
+ }, &mdm.CommandResults{
+ CommandUUID: uuid2,
+ Status: "Acknowledged",
+ RequestType: "ProfileList",
+ Raw: []byte(rawCmd),
+ })
+ require.NoError(t, err)
+
+ h, err := s.ds.HostByIdentifier(ctx, enrolledHost.uuid)
+ require.NoError(t, err)
+ h.Hostname = "test-host"
+ err = s.ds.UpdateHost(ctx, h)
+ require.NoError(t, err)
+
+ s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commandresults", nil, http.StatusOK, &cmdResResp, "command_uuid", uuid2)
+ require.Len(t, cmdResResp.Results, 1)
+ require.NotZero(t, cmdResResp.Results[0].UpdatedAt)
+ cmdResResp.Results[0].UpdatedAt = time.Time{}
+ require.Equal(t, &fleet.MDMAppleCommandResult{
+ DeviceID: enrolledHost.uuid,
+ CommandUUID: uuid2,
+ Status: "Acknowledged",
+ RequestType: "ProfileList",
+ Result: []byte(rawCmd),
+ Hostname: "test-host",
+ }, cmdResResp.Results[0])
}
// only asserts the profile identifier, status and operation (per host)
diff --git a/server/service/service.go b/server/service/service.go
index bb1946527b..ad67599b14 100644
--- a/server/service/service.go
+++ b/server/service/service.go
@@ -13,6 +13,7 @@ import (
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/service/async"
"github.com/fleetdm/fleet/v4/server/sso"
kitlog "github.com/go-kit/kit/log"
@@ -56,7 +57,7 @@ type Service struct {
mdmStorage nanomdm_storage.AllStorage
mdmPushService nanomdm_push.Pusher
mdmPushCertTopic string
- mdmAppleCommander *MDMAppleCommander
+ mdmAppleCommander *apple_mdm.MDMAppleCommander
cronSchedulesService fleet.CronSchedulesService
}
@@ -136,7 +137,7 @@ func NewService(
mdmStorage: mdmStorage,
mdmPushService: mdmPushService,
mdmPushCertTopic: mdmPushCertTopic,
- mdmAppleCommander: NewMDMAppleCommander(mdmStorage, mdmPushService),
+ mdmAppleCommander: apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
cronSchedulesService: cronSchedulesService,
}
return validationMiddleware{svc, ds, sso}, nil
diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go
index 2867a5d328..e71e7f40c7 100644
--- a/server/service/testing_utils.go
+++ b/server/service/testing_utils.go
@@ -155,7 +155,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
mailer,
c,
depStorage,
- NewMDMAppleCommander(mdmStorage, mdmPusher),
+ apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher),
"",
)
if err != nil {