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 {