Add fleetctl get mdm-commands command and supporting API endpoint (#11163)

This commit is contained in:
Martin Angers
2023-04-17 11:45:16 -04:00
committed by GitHub
parent 5aa5f8aae3
commit c1d3f67e6f
18 changed files with 806 additions and 1065 deletions
@@ -0,0 +1 @@
* Added the `fleetctl get mdm-commands` command to get a list of MDM commands that were executed. This also adds the `GET /api/latest/fleet/mdm/apple/commands` API endpoint.
-825
View File
@@ -1,825 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/groob/plist"
"github.com/micromdm/micromdm/mdm/appmanifest"
"github.com/micromdm/micromdm/mdm/mdm"
"github.com/olekukonko/tablewriter"
"github.com/urfave/cli/v2"
)
const (
apnsKeyPath = "fleet-mdm-apple-apns.key"
scepCACertPath = "fleet-mdm-apple-scep.crt"
scepCAKeyPath = "fleet-mdm-apple-scep.key"
bmPublicKeyCertPath = "fleet-apple-mdm-bm-public-key.crt"
bmPrivateKeyPath = "fleet-apple-mdm-bm-private.key"
)
func appleMDMCommand() *cli.Command {
return &cli.Command{
Name: "apple-mdm",
Usage: "Apple MDM functionality",
// TODO: Remove when Apple MDM is production ready.
Hidden: true,
Flags: []cli.Flag{
configFlag(),
contextFlag(),
debugFlag(),
},
Subcommands: []*cli.Command{
appleMDMEnrollmentProfilesCommand(),
appleMDMEnqueueCommandCommand(),
appleMDMDEPCommand(),
appleMDMDevicesCommand(),
appleMDMCommandResultsCommand(),
appleMDMInstallersCommand(),
},
}
}
func appleMDMEnrollmentProfilesCommand() *cli.Command {
return &cli.Command{
Name: "enrollment-profiles",
Usage: "Commands to manage enrollment profiles",
Subcommands: []*cli.Command{
appleMDMEnrollmentProfilesCreateAutomaticCommand(),
appleMDMEnrollmentProfilesCreateManualCommand(),
appleMDMEnrollmentProfilesListCommand(),
},
}
}
func appleMDMEnrollmentProfilesCreateAutomaticCommand() *cli.Command {
var depProfilePath string
return &cli.Command{
Name: "create-automatic",
Usage: "Create an automatic enrollment profile",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "dep-profile",
Usage: "JSON file with fields defined in https://developer.apple.com/documentation/devicemanagement/profile",
Destination: &depProfilePath,
Required: true,
},
},
Action: func(c *cli.Context) error {
profile, err := os.ReadFile(depProfilePath)
if err != nil {
return fmt.Errorf("read dep profile: %w", err)
}
client, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
depProfile := json.RawMessage(profile)
enrollmentProfile, err := client.CreateEnrollmentProfile(fleet.MDMAppleEnrollmentTypeAutomatic, &depProfile)
if err != nil {
return fmt.Errorf("create enrollment profile: %w", err)
}
fmt.Printf("Automatic enrollment profile created, ID: %d\n", enrollmentProfile.ID)
return nil
},
}
}
func appleMDMEnrollmentProfilesCreateManualCommand() *cli.Command {
return &cli.Command{
Name: "create-manual",
Usage: "Create a manual enrollment profile",
Flags: []cli.Flag{},
Action: func(c *cli.Context) error {
client, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
enrollmentProfile, err := client.CreateEnrollmentProfile(fleet.MDMAppleEnrollmentTypeManual, nil)
if err != nil {
return fmt.Errorf("create enrollment profile: %w", err)
}
fmt.Printf("Manual enrollment profile created, URL: %s.\n", enrollmentProfile.EnrollmentURL)
return nil
},
}
}
func appleMDMEnrollmentProfilesListCommand() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "List all enrollments",
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
enrollments, err := fleet.ListEnrollments()
if err != nil {
return fmt.Errorf("create enrollment: %w", err)
}
// format output as a table
table := tablewriter.NewWriter(os.Stdout)
table.SetRowLine(true)
table.SetHeader([]string{"ID", "Type", "DEP Profile", "Enrollment URL"})
table.SetAutoWrapText(false)
table.SetRowLine(true)
for _, enrollment := range enrollments {
var depProfile string
if enrollment.DEPProfile != nil {
depProfile = string(*enrollment.DEPProfile)
}
table.Append([]string{
strconv.FormatUint(uint64(enrollment.ID), 10),
string(enrollment.Type),
depProfile,
enrollment.EnrollmentURL,
})
}
table.Render()
return nil
},
}
}
func appleMDMEnqueueCommandCommand() *cli.Command {
return &cli.Command{
Name: "enqueue-command",
Usage: "Enqueue an MDM command. See the results using the command-results command and passing the command UUID that is returned from this command.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "device-ids",
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
},
&cli.StringFlag{
Name: "command-payload",
Usage: "A plist file containing the raw MDM command payload. Note that a new CommandUUID will be generated automatically. See https://developer.apple.com/documentation/devicemanagement/commands_and_queries for available commands.",
},
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
deviceIDs := strings.Split(c.String("device-ids"), ",")
if len(deviceIDs) == 0 {
return errors.New("must provide at least one device ID")
}
payloadFilename := c.String("command-payload")
if payloadFilename == "" {
return errors.New("must provide a command payload file")
}
payloadBytes, err := os.ReadFile(payloadFilename)
if err != nil {
return fmt.Errorf("read payload: %w", err)
}
result, err := fleet.EnqueueCommand(deviceIDs, payloadBytes)
if err != nil {
return err
}
commandUUID := result.CommandUUID
fmt.Printf("Command UUID: %s\n", commandUUID)
return nil
},
Subcommands: []*cli.Command{
appleMDMEnqueueCommandInstallProfileCommand(),
appleMDMEnqueueCommandSimpleCommand("ProfileList"),
appleMDMEnqueueCommandRemoveProfileCommand(),
appleMDMEnqueueCommandInstallEnterpriseApplicationCommand(),
appleMDMEnqueueCommandSimpleCommand("ProvisioningProfileList"),
appleMDMEnqueueCommandSimpleCommand("CertificateList"),
appleMDMEnqueueCommandSimpleCommand("SecurityInfo"),
appleMDMEnqueueCommandSimpleCommand("RestartDevice"),
appleMDMEnqueueCommandSimpleCommand("ShutdownDevice"),
appleMDMEnqueueCommandSimpleCommand("StopMirroring"),
appleMDMEnqueueCommandSimpleCommand("ClearRestrictionsPassword"),
appleMDMEnqueueCommandSimpleCommand("UserList"),
appleMDMEnqueueCommandSimpleCommand("LogOutUser"),
appleMDMEnqueueCommandSimpleCommand("PlayLostModeSound"),
appleMDMEnqueueCommandSimpleCommand("DisableLostMode"),
appleMDMEnqueueCommandSimpleCommand("DeviceLocation"),
appleMDMEnqueueCommandSimpleCommand("ManagedMediaList"),
appleMDMEnqueueCommandSimpleCommand("DeviceConfigured"),
appleMDMEnqueueCommandSimpleCommand("AvailableOSUpdates"),
appleMDMEnqueueCommandSimpleCommand("NSExtensionMappings"),
appleMDMEnqueueCommandSimpleCommand("OSUpdateStatus"),
appleMDMEnqueueCommandSimpleCommand("EnableRemoteDesktop"),
appleMDMEnqueueCommandSimpleCommand("DisableRemoteDesktop"),
appleMDMEnqueueCommandSimpleCommand("ActivationLockBypassCode"),
appleMDMEnqueueCommandSimpleCommand("ScheduleOSUpdateScan"),
appleMDMEnqueueCommandEraseDeviceCommand(),
appleMDMEnqueueCommandDeviceLockCommand(),
appleMDMEnqueueCommandDeviceInformationCommand(),
},
}
}
func appleMDMEnqueueCommandInstallProfileCommand() *cli.Command {
return &cli.Command{
Name: "InstallProfile",
Usage: "Enqueue the InstallProfile MDM command.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "device-ids",
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
},
&cli.StringFlag{
Name: "mobileconfig",
Usage: "The mobileconfig file containing the profile to install.",
},
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
deviceIDs := strings.Split(c.String("device-ids"), ",")
if len(deviceIDs) == 0 {
return errors.New("must provide at least one device ID")
}
profilePayloadFilename := c.String("mobileconfig")
if profilePayloadFilename == "" {
return errors.New("must provide a mobileprofile payload")
}
profilePayloadBytes, err := os.ReadFile(profilePayloadFilename)
if err != nil {
return fmt.Errorf("read payload: %w", err)
}
payload := &mdm.CommandPayload{
Command: &mdm.Command{
RequestType: "InstallProfile",
InstallProfile: &mdm.InstallProfile{
Payload: profilePayloadBytes,
},
},
}
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
},
}
}
func appleMDMEnqueueCommandRemoveProfileCommand() *cli.Command {
return &cli.Command{
Name: "RemoveProfile",
Usage: "Enqueue the RemoveProfile MDM command.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "device-ids",
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
},
&cli.StringFlag{
Name: "identifier",
Usage: "The PayloadIdentifier value for the profile to remove eg cis.macOSBenchmark.section2.SecureKeyboard.",
},
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
deviceIDs := strings.Split(c.String("device-ids"), ",")
if len(deviceIDs) == 0 {
return errors.New("must provide at least one device ID")
}
identifier := c.String("identifier")
if identifier == "" {
return errors.New("must provide the identifier of the profile")
}
payload := &mdm.CommandPayload{
Command: &mdm.Command{
RequestType: "RemoveProfile",
RemoveProfile: &mdm.RemoveProfile{
Identifier: identifier,
},
},
}
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
},
}
}
func appleMDMEnqueueCommandSimpleCommand(name string) *cli.Command {
return &cli.Command{
Name: name,
Usage: fmt.Sprintf("Enqueue the %s MDM command.", name),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "device-ids",
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
},
},
Action: func(c *cli.Context) error {
return runSimpleCommand(c, name)
},
}
}
// runSimpleCommand runs commands that do not have any extra arguments, like RestartDevice.
func runSimpleCommand(c *cli.Context, name string) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
deviceIDs := strings.Split(c.String("device-ids"), ",")
if len(deviceIDs) == 0 {
return errors.New("must provide at least one device ID")
}
payload := &mdm.CommandPayload{
Command: &mdm.Command{
RequestType: name,
},
}
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
}
func enqueueCommandAndPrintHelp(fleet *service.Client, deviceIDs []string, payload *mdm.CommandPayload) error {
// convert to xml using tabs for indentation
payloadBytes, err := plist.MarshalIndent(payload, " ")
if err != nil {
return fmt.Errorf("marshal command payload plist: %w", err)
}
result, err := fleet.EnqueueCommand(deviceIDs, payloadBytes)
if err != nil {
return fmt.Errorf("enqueue command: %w", err)
}
commandUUID := result.CommandUUID
fmt.Printf("Command UUID: %s\n", commandUUID)
fmt.Printf("Use `fleetctl apple-mdm command-results --command-uuid %s` to get results.\n", commandUUID)
return nil
}
func appleMDMEnqueueCommandEraseDeviceCommand() *cli.Command {
return &cli.Command{
Name: "EraseDevice",
Usage: "Enqueue the EraseDevice MDM command.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "device-ids",
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
},
&cli.StringFlag{
Name: "pin",
Usage: "The six-character PIN for Find My.",
},
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
deviceIDs := strings.Split(c.String("device-ids"), ",")
if len(deviceIDs) == 0 {
return errors.New("must provide at least one device ID")
}
pin := c.String("pin")
if len(pin) != 6 {
return errors.New("must provide a six-character PIN for Find My")
}
payload := &mdm.CommandPayload{
Command: &mdm.Command{
RequestType: "EraseDevice",
EraseDevice: &mdm.EraseDevice{
PIN: pin,
},
},
}
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
},
}
}
func appleMDMEnqueueCommandDeviceLockCommand() *cli.Command {
return &cli.Command{
Name: "DeviceLock",
Usage: "Enqueue the DeviceLock MDM command.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "device-ids",
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
},
&cli.StringFlag{
Name: "pin",
Usage: "The six-character PIN for Find My.",
},
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
deviceIDs := strings.Split(c.String("device-ids"), ",")
if len(deviceIDs) == 0 {
return errors.New("must provide at least one device ID")
}
pin := c.String("pin")
if len(pin) != 6 {
return errors.New("must provide a six-character PIN for Find My")
}
payload := &mdm.CommandPayload{
Command: &mdm.Command{
RequestType: "DeviceLock",
DeviceLock: &mdm.DeviceLock{
PIN: pin,
},
},
}
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
},
}
}
func appleMDMEnqueueCommandDeviceInformationCommand() *cli.Command {
return &cli.Command{
Name: "DeviceInformation",
Usage: "Enqueue the DeviceInformation MDM command.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "device-ids",
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
},
&cli.StringFlag{
Name: "queries",
Usage: "An array of query dictionaries to get information about a device. See https://developer.apple.com/documentation/devicemanagement/deviceinformationcommand/command/queries.",
},
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
deviceIDs := strings.Split(c.String("device-ids"), ",")
if len(deviceIDs) == 0 {
return errors.New("must provide at least one device ID")
}
queries := strings.Split(c.String("queries"), ",")
if len(queries) == 0 {
return errors.New("must provide queries for the device")
}
payload := &mdm.CommandPayload{
Command: &mdm.Command{
RequestType: "DeviceInformation",
DeviceInformation: &mdm.DeviceInformation{
Queries: queries,
},
},
}
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
},
}
}
func appleMDMEnqueueCommandInstallEnterpriseApplicationCommand() *cli.Command {
return &cli.Command{
Name: "InstallEnterpriseApplication",
Usage: "Enqueue the InstallEnterpriseApplication MDM command.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "device-ids",
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
},
&cli.UintFlag{
Name: "installer-id",
Usage: "ID of the installer to install on the target devices.",
},
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
deviceIDs := strings.Split(c.String("device-ids"), ",")
if len(deviceIDs) == 0 {
return errors.New("must provide at least one device ID")
}
installerID := c.Uint("installer-id")
if installerID == 0 {
return errors.New("must provide an installer ID")
}
installer, err := fleet.MDMAppleGetInstallerDetails(installerID)
if err != nil {
return fmt.Errorf("get installer: %w", err)
}
var m appmanifest.Manifest
if err := plist.NewDecoder(bytes.NewReader([]byte(installer.Manifest))).Decode(&m); err != nil {
return fmt.Errorf("decode manifest: %w", err)
}
payload := &mdm.CommandPayload{
Command: &mdm.Command{
RequestType: "InstallEnterpriseApplication",
InstallEnterpriseApplication: &mdm.InstallEnterpriseApplication{
Manifest: &m,
},
},
}
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
},
}
}
func appleMDMDevicesCommand() *cli.Command {
return &cli.Command{
Name: "devices",
Usage: "Inspect enrolled devices",
Subcommands: []*cli.Command{
appleMDMDevicesListCommand(),
},
}
}
func appleMDMDevicesListCommand() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "List all devices",
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
devices, err := fleet.MDMAppleListDevices()
if err != nil {
return err
}
// format output as a table
table := tablewriter.NewWriter(os.Stdout)
table.SetRowLine(true)
table.SetHeader([]string{"Device ID", "Serial Number", "Enrolled"})
table.SetAutoWrapText(false)
table.SetRowLine(true)
for _, device := range devices {
table.Append([]string{
device.ID,
device.SerialNumber,
strconv.FormatBool(device.Enabled),
})
}
table.Render()
return nil
},
}
}
func appleMDMDEPCommand() *cli.Command {
return &cli.Command{
Name: "dep",
Usage: "Device Enrollment Program commands",
Subcommands: []*cli.Command{
appleMDMDEPListCommand(),
},
}
}
func appleMDMDEPListCommand() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "List all DEP devices from the linked MDM server in Apple Business Manager",
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
devices, err := fleet.DEPListDevices()
if err != nil {
return err
}
// format output as a table
table := tablewriter.NewWriter(os.Stdout)
table.SetRowLine(true)
table.SetHeader([]string{
"Serial Number",
"OS",
"Family",
"Model",
"Description",
"Color",
"Profile Status",
"Profile UUID",
"Profile Assign Time",
"Profile Push Time",
"Device Assigned Date",
"Assigned By",
})
table.SetAutoWrapText(false)
table.SetRowLine(true)
const timeFmt = "2006-01-02T15:04:05Z"
for _, device := range devices {
table.Append([]string{
device.SerialNumber,
device.OS,
device.DeviceFamily,
device.Model,
device.Description,
device.Color,
device.ProfileStatus,
device.ProfileUUID,
device.ProfileAssignTime.Format(timeFmt),
device.ProfilePushTime.Format(timeFmt),
device.DeviceAssignedDate.Format(timeFmt),
device.DeviceAssignedBy,
})
}
table.Render()
return nil
},
}
}
func appleMDMCommandResultsCommand() *cli.Command {
return &cli.Command{
Name: "command-results",
Usage: "Get MDM command results",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "command-uuid",
Usage: "The command uuid.",
Required: true,
},
},
Action: func(c *cli.Context) error {
commandUUID := c.String("command-uuid")
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
results, err := fleet.MDMAppleGetCommandResults(commandUUID)
if err != nil {
return err
}
// format output as a table
table := tablewriter.NewWriter(os.Stdout)
table.SetRowLine(true)
table.SetHeader([]string{"Device ID", "Status", "Result"})
table.SetAutoWrapText(false)
table.SetRowLine(true)
for _, result := range results {
xml := bytes.ReplaceAll(result.Result, []byte{'\t'}, []byte{' '})
table.Append([]string{result.DeviceID, result.Status, string(xml)})
}
table.Render()
return nil
},
}
}
func appleMDMInstallersCommand() *cli.Command {
return &cli.Command{
Name: "installers",
Usage: "Commands to manage macOS installers",
Subcommands: []*cli.Command{
appleMDMInstallersUploadCommand(),
appleMDMInstallersListCommand(),
appleMDMInstallersDeleteCommand(),
},
}
}
func appleMDMInstallersUploadCommand() *cli.Command {
var path string
return &cli.Command{
Name: "upload",
Usage: "Upload an Apple installer to Fleet",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "path",
Usage: "Path to the installer",
Destination: &path,
Required: true,
},
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
fp, err := os.Open(path)
if err != nil {
return fmt.Errorf("open path %q: %w", path, err)
}
defer fp.Close()
installerID, err := fleet.UploadMDMAppleInstaller(c.Context, filepath.Base(path), fp)
if err != nil {
return fmt.Errorf("upload installer: %w", err)
}
fmt.Printf("Installer uploaded successfully, id=%d", installerID)
return nil
},
}
}
func appleMDMInstallersListCommand() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "List all Apple installers",
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
installers, err := fleet.ListMDMAppleInstallers()
if err != nil {
return fmt.Errorf("list installers: %w", err)
}
// format output as a table
table := tablewriter.NewWriter(os.Stdout)
table.SetRowLine(true)
table.SetHeader([]string{"ID", "Name", "Manifest", "URL"})
table.SetAutoWrapText(false)
table.SetRowLine(true)
for _, installer := range installers {
manifest := strings.ReplaceAll(installer.Manifest, "\t", " ")
table.Append([]string{strconv.FormatUint(uint64(installer.ID), 10), installer.Name, manifest, installer.URL})
}
table.Render()
return nil
},
}
}
func appleMDMInstallersDeleteCommand() *cli.Command {
var installerID uint
return &cli.Command{
Name: "delete",
Usage: "Delete an Apple installer",
Flags: []cli.Flag{
&cli.UintFlag{
Name: "id",
Usage: "Identifier of the installer",
Destination: &installerID,
Required: true,
},
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("create client: %w", err)
}
if err := fleet.MDMDeleteAppleInstaller(installerID); err != nil {
return fmt.Errorf("delete installer: %w", err)
}
return nil
},
}
}
-1
View File
@@ -89,7 +89,6 @@ func createApp(reader io.Reader, writer io.Writer, exitErrHandler cli.ExitErrHan
hostsCommand(),
vulnerabilityDataStreamCommand(),
packageCommand(),
appleMDMCommand(),
generateCommand(),
{
// It's become common for folks to unintentionally install fleetctl when they actually
+8
View File
@@ -8,6 +8,14 @@ import (
"github.com/urfave/cli/v2"
)
const (
apnsKeyPath = "fleet-mdm-apple-apns.key"
scepCACertPath = "fleet-mdm-apple-scep.crt"
scepCAKeyPath = "fleet-mdm-apple-scep.key"
bmPublicKeyCertPath = "fleet-apple-mdm-bm-public-key.crt"
bmPrivateKeyPath = "fleet-apple-mdm-bm-private.key"
)
func generateCommand() *cli.Command {
return &cli.Command{
Name: "generate",
+50
View File
@@ -292,6 +292,7 @@ func getCommand() *cli.Command {
getMDMAppleCommand(),
getMDMAppleBMCommand(),
getMDMCommandResultsCommand(),
getMDMCommandsCommand(),
},
}
}
@@ -1271,3 +1272,52 @@ func getMDMCommandResultsCommand() *cli.Command {
},
}
}
func getMDMCommandsCommand() *cli.Command {
return &cli.Command{
Name: "mdm-commands",
Aliases: []string{"mdm_commands"},
Usage: "List information about MDM commands that were run.",
Flags: []cli.Flag{
configFlag(),
contextFlag(),
debugFlag(),
},
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 := client.CheckMDMEnabled(); err != nil {
return err
}
results, err := client.MDMAppleListCommands()
if err != nil {
return err
}
if len(results) == 0 {
log(c, "You haven't run any MDM commands. Run MDM commands with the `fleetctl mdm run-command` command.\n")
return nil
}
// print the results as a table
data := [][]string{}
for _, r := range results {
data = append(data, []string{
r.CommandUUID,
r.UpdatedAt.Format(time.RFC3339),
r.RequestType,
r.Status,
r.Hostname,
})
}
columns := []string{"ID", "TIME", "TYPE", "STATUS", "HOSTNAME"}
printTable(c, columns, data)
return nil
},
}
}
+57 -1
View File
@@ -1529,7 +1529,6 @@ func TestGetMDMCommandResults(t *testing.T) {
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 |
@@ -1560,3 +1559,60 @@ func TestGetMDMCommandResults(t *testing.T) {
+-----------+----------------------+------+--------------+----------+---------------------------------------------------+
`))
}
func TestGetMDMCommands(t *testing.T) {
_, ds := runServerWithMockedDS(t)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
var empty bool
var listErr error
ds.ListMDMAppleCommandsFunc = func(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, error) {
if empty || listErr != nil {
return nil, listErr
}
return []*fleet.MDMAppleCommand{
{
DeviceID: "h1",
CommandUUID: "u1",
UpdatedAt: time.Date(2023, 4, 12, 9, 5, 0, 0, time.UTC),
RequestType: "ProfileList",
Status: "Acknowledged",
Hostname: "host1",
},
{
DeviceID: "h2",
CommandUUID: "u2",
UpdatedAt: time.Date(2023, 4, 11, 9, 5, 0, 0, time.UTC),
RequestType: "ListApps",
Status: "Acknowledged",
Hostname: "host2",
},
}, nil
}
listErr = io.ErrUnexpectedEOF
_, err := runAppNoChecks([]string{"get", "mdm-commands"})
require.Error(t, err)
require.ErrorContains(t, err, io.ErrUnexpectedEOF.Error())
listErr = nil
empty = true
buf, err := runAppNoChecks([]string{"get", "mdm-commands"})
require.NoError(t, err)
require.Contains(t, buf.String(), "You haven't run any MDM commands. Run MDM commands with the `fleetctl mdm run-command` command.")
empty = false
buf, err = runAppNoChecks([]string{"get", "mdm-commands"})
require.NoError(t, err)
require.Contains(t, buf.String(), strings.TrimSpace(`
+----+----------------------+-------------+--------------+----------+
| ID | TIME | TYPE | STATUS | HOSTNAME |
+----+----------------------+-------------+--------------+----------+
| u1 | 2023-04-12T09:05:00Z | ProfileList | Acknowledged | host1 |
+----+----------------------+-------------+--------------+----------+
| u2 | 2023-04-11T09:05:00Z | ListApps | Acknowledged | host2 |
+----+----------------------+-------------+--------------+----------+
`))
}
+52 -11
View File
@@ -3519,6 +3519,7 @@ These API endpoints are used to automate MDM features in Fleet. Read more about
- [Get macOS settings statistics](#get-macos-settings-statistics)
- [Run custom MDM command](#run-custom-mdm-command)
- [Get custom MDM command results](#get-custom-mdm-command-results)
- [List custom MDM commands](#list-custom-mdm-commands)
- [Get Apple Push Notification service (APNs)](#get-apple-push-notification-service-apns)
- [Get Apple Business Manager (ABM)](#get-apple-business-manager-abm)
- [Turn off MDM for a host](#turn-off-mdm-for-a-host)
@@ -3601,7 +3602,7 @@ of duplicate payload display name or duplicate payload identifier.
### List custom macOS settings (configuration profiles)
Get a list of the configuration profiles in Fleet.
Get a list of the configuration profiles in Fleet.
For Fleet Premium, the list can
optionally be filtered by team ID. If no team ID is specified, team profiles are excluded from the
@@ -3735,7 +3736,7 @@ _Available in Fleet Premium_
_Available in Fleet Premium_
Get aggregate status counts of disk encryption enforced on hosts.
Get aggregate status counts of disk encryption enforced on hosts.
The summary can optionally be filtered by team id.
@@ -3769,7 +3770,7 @@ Get aggregate status counts of Apple disk encryption profiles applying to macOS
### Get macOS settings statistics
Get aggregate status counts of all macOS settings (configuraiton profiles and disk encryption) enforced on hosts.
Get aggregate status counts of all macOS settings (configuraiton profiles and disk encryption) enforced on hosts.
For Fleet Premium uses, the statistics can
optionally be filtered by team id. If no team id is specified, team profiles are excluded from the
@@ -3854,13 +3855,53 @@ This endpoint returns the results for a specific custom MDM command.
```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-"
{
"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-"
}
]
}
```
### List custom MDM commands
This endpoint returns the list of custom MDM commands that have been executed.
`GET /api/v1/fleet/mdm/apple/commands`
#### Parameters
| Name | Type | In | Description |
| ------------------------- | ------ | ----- | ------------------------------------------------------------------------- |
| page | integer | query | Page number of the results to fetch. |
| per_page | integer | query | Results per page. |
| order_key | string | query | What to order results by. Can be any field listed in the `results` array example below. |
| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. |
#### Example
`GET /api/v1/fleet/mdm/apple/commands?per_page=5
##### 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"
}
]
}
```
@@ -3936,7 +3977,7 @@ None.
`Status: 200`
###
###
---
+44 -12
View File
@@ -306,7 +306,7 @@ WHERE
var results []*fleet.MDMAppleCommandResult
err := sqlx.SelectContext(
ctx,
ds.writer,
ds.reader,
&results,
query,
commandUUID,
@@ -317,6 +317,38 @@ WHERE
return results, nil
}
func (ds *Datastore) ListMDMAppleCommands(
ctx context.Context,
tmFilter fleet.TeamFilter,
listOpts *fleet.MDMAppleCommandListOptions,
) ([]*fleet.MDMAppleCommand, error) {
stmt := fmt.Sprintf(`
SELECT
nvq.id as device_id,
nvq.command_uuid,
COALESCE(nvq.status, '') as status,
COALESCE(nvq.result_updated_at, nvq.created_at) as updated_at,
nvq.request_type,
h.hostname,
h.team_id
FROM
nano_view_queue nvq
INNER JOIN
hosts h
ON
nvq.id = h.uuid
WHERE
%s
`, ds.whereFilterHostsByTeams(tmFilter, "h"))
stmt, params := appendListOptionsWithCursorToSQL(stmt, nil, &listOpts.ListOptions)
var results []*fleet.MDMAppleCommand
if err := sqlx.SelectContext(ctx, ds.reader, &results, stmt, params...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "list commands")
}
return results, nil
}
func (ds *Datastore) NewMDMAppleInstaller(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*fleet.MDMAppleInstaller, error) {
res, err := ds.writer.ExecContext(
ctx,
@@ -1681,17 +1713,17 @@ func getMDMAppleConfigProfileByTeamAndIdentifierDB(ctx context.Context, tx sqlx.
}
stmt := `
SELECT
profile_id,
team_id,
name,
identifier,
mobileconfig,
created_at,
updated_at
FROM
mdm_apple_configuration_profiles
WHERE
SELECT
profile_id,
team_id,
name,
identifier,
mobileconfig,
created_at,
updated_at
FROM
mdm_apple_configuration_profiles
WHERE
team_id=? AND identifier=?`
var profile fleet.MDMAppleConfigProfile
+257
View File
@@ -51,6 +51,7 @@ func TestMDMAppleConfigProfile(t *testing.T) {
{"TestGetMDMAppleCommandResults", testGetMDMAppleCommandResults},
{"TestBulkUpsertMDMAppleConfigProfiles", testBulkUpsertMDMAppleConfigProfile},
{"TestMDMAppleBootstrapPackageCRUD", testMDMAppleBootstrapPackageCRUD},
{"TestListMDMAppleCommands", testListMDMAppleCommands},
}
for _, c := range cases {
@@ -2726,3 +2727,259 @@ func testMDMAppleBootstrapPackageCRUD(t *testing.T, ds *Datastore) {
require.ErrorAs(t, err, &nfe)
require.Nil(t, meta)
}
func testListMDMAppleCommands(t *testing.T, ds *Datastore) {
ctx := context.Background()
createRawCmd := func(reqType, cmdUUID string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Command</key>
<dict>
<key>ManagedOnly</key>
<false/>
<key>RequestType</key>
<string>%s</string>
</dict>
<key>CommandUUID</key>
<string>%s</string>
</dict>
</plist>`, reqType, cmdUUID)
}
// create some enrolled hosts
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 team
tm1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
// assign enrolledHosts[2] to tm1
err = ds.AddHostsToTeam(ctx, &tm1.ID, []uint{enrolledHosts[2].ID})
require.NoError(t, err)
commander, storage := createMDMAppleCommanderAndStorage(t, ds)
// no commands yet
res, err := ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
require.NoError(t, err)
require.Empty(t, res)
// enqueue a command for enrolled hosts [0] and [1]
uuid1 := uuid.New().String()
rawCmd1 := createRawCmd("ListApps", uuid1)
err = commander.EnqueueCommand(ctx, []string{enrolledHosts[0].UUID, enrolledHosts[1].UUID}, rawCmd1)
require.NoError(t, err)
// command has no results yet, so the status is empty
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
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.MDMAppleCommand{
{
DeviceID: enrolledHosts[0].UUID,
CommandUUID: uuid1,
Status: "",
RequestType: "ListApps",
Hostname: enrolledHosts[0].Hostname,
TeamID: nil,
},
{
DeviceID: enrolledHosts[1].UUID,
CommandUUID: uuid1,
Status: "",
RequestType: "ListApps",
Hostname: enrolledHosts[1].Hostname,
TeamID: nil,
},
})
// simulate a result for enrolledHosts[0]
err = storage.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: enrolledHosts[0].UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: uuid1,
Status: "Acknowledged",
RequestType: "ListApps",
Raw: []byte(rawCmd1),
})
require.NoError(t, err)
// command is now listed with a status for this result
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
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.MDMAppleCommand{
{
DeviceID: enrolledHosts[0].UUID,
CommandUUID: uuid1,
Status: "Acknowledged",
RequestType: "ListApps",
Hostname: enrolledHosts[0].Hostname,
TeamID: nil,
},
{
DeviceID: enrolledHosts[1].UUID,
CommandUUID: uuid1,
Status: "",
RequestType: "ListApps",
Hostname: enrolledHosts[1].Hostname,
TeamID: nil,
},
})
// simulate a result for enrolledHosts[1]
err = storage.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: enrolledHosts[1].UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: uuid1,
Status: "Error",
RequestType: "ListApps",
Raw: []byte(rawCmd1),
})
require.NoError(t, err)
// both results are now listed
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
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.MDMAppleCommand{
{
DeviceID: enrolledHosts[0].UUID,
CommandUUID: uuid1,
Status: "Acknowledged",
RequestType: "ListApps",
Hostname: enrolledHosts[0].Hostname,
TeamID: nil,
},
{
DeviceID: enrolledHosts[1].UUID,
CommandUUID: uuid1,
Status: "Error",
RequestType: "ListApps",
Hostname: enrolledHosts[1].Hostname,
TeamID: nil,
},
})
// enqueue another command for enrolled hosts [1] and [2]
uuid2 := uuid.New().String()
rawCmd2 := createRawCmd("InstallApp", uuid2)
err = commander.EnqueueCommand(ctx, []string{enrolledHosts[1].UUID, enrolledHosts[2].UUID}, rawCmd2)
require.NoError(t, err)
// simulate a result for enrolledHosts[1] and [2]
err = storage.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: enrolledHosts[1].UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: uuid2,
Status: "Acknowledged",
RequestType: "InstallApp",
Raw: []byte(rawCmd2),
})
require.NoError(t, err)
err = storage.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: enrolledHosts[2].UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: uuid2,
Status: "Acknowledged",
RequestType: "InstallApp",
Raw: []byte(rawCmd2),
})
require.NoError(t, err)
// results are listed
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
require.NoError(t, err)
require.Len(t, res, 4)
// page-by-page: first page
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{
ListOptions: fleet.ListOptions{Page: 0, PerPage: 3, OrderKey: "device_id", OrderDirection: fleet.OrderDescending},
})
require.NoError(t, err)
require.Len(t, res, 3)
// page-by-page: second page
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{
ListOptions: fleet.ListOptions{Page: 1, PerPage: 3, OrderKey: "device_id", OrderDirection: fleet.OrderDescending},
})
require.NoError(t, err)
require.Len(t, res, 1)
// filter by a user from team tm1, can only see that team's host
u1, err := ds.NewUser(ctx, &fleet.User{
Password: []byte("garbage"),
Salt: "garbage",
Name: "user1",
Email: "user1@example.com",
GlobalRole: nil,
Teams: []fleet.UserTeam{
{Team: *tm1, Role: fleet.RoleObserver},
},
})
require.NoError(t, err)
u1, err = ds.UserByID(ctx, u1.ID)
require.NoError(t, err)
// u1 is an observer, so if IncludeObserver is not set, returns nothing
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: u1}, &fleet.MDMAppleCommandListOptions{
ListOptions: fleet.ListOptions{PerPage: 3},
})
require.NoError(t, err)
require.Len(t, res, 0)
// now with IncludeObserver set to true
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: u1, IncludeObserver: true}, &fleet.MDMAppleCommandListOptions{
ListOptions: fleet.ListOptions{PerPage: 3, OrderKey: "updated_at", OrderDirection: fleet.OrderDescending},
})
require.NoError(t, err)
require.Len(t, res, 1)
require.NotZero(t, res[0].UpdatedAt)
res[0].UpdatedAt = time.Time{}
require.ElementsMatch(t, res, []*fleet.MDMAppleCommand{
{
DeviceID: enrolledHosts[2].UUID,
CommandUUID: uuid2,
Status: "Acknowledged",
RequestType: "InstallApp",
Hostname: enrolledHosts[2].Hostname,
TeamID: &tm1.ID,
},
})
}
+43 -5
View File
@@ -10,7 +10,6 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/micromdm/nanodep/godep"
"github.com/micromdm/nanomdm/mdm"
)
type MDMAppleCommandIssuer interface {
@@ -248,14 +247,14 @@ type CommandEnqueueResult struct {
FailedUUIDs []string `json:"failed_uuids,omitempty"`
}
// MDMAppleCommand represents an Apple MDM command.
type MDMAppleCommand struct {
*mdm.Command
// MDMAppleCommandAuthz is used to check user authorization to read/write an
// Apple MDM command.
type MDMAppleCommandAuthz struct {
TeamID *uint `json:"team_id"` // required for authorization by team
}
// AuthzType implements authz.AuthzTyper.
func (m MDMAppleCommand) AuthzType() string {
func (m MDMAppleCommandAuthz) AuthzType() string {
return "mdm_apple_command"
}
@@ -428,3 +427,42 @@ type NanoEnrollment struct {
Enabled bool `json:"-" db:"enabled"`
TokenUpdateTally int `json:"-" db:"token_update_tally"`
}
// MDMAppleCommandListOptions defines the options to control the list of MDM
// Apple Commands to return. Although it only supports the standard list
// options for now, in the future we expect to add filtering options.
//
// https://github.com/fleetdm/fleet/issues/11008#issuecomment-1503466119
type MDMAppleCommandListOptions struct {
ListOptions
}
// MDMAppleCommand represents an MDM Apple command that has been enqueued for
// execution. It is similar to MDMAppleCommandResult, but a separate struct is
// used as there are plans to evolve the `fleetctl get mdm-commands` command
// output in the future to list one row per command instead of one per
// command-host combination, and this fleetctl command is the only use of this
// struct at the moment. Also, it is filled a bit differently than what we do
// in MDMAppleCommandResult, since it needs to join with the hosts in the
// query to make authorization (retrieving the team id) manageable.
//
// https://github.com/fleetdm/fleet/issues/11008#issuecomment-1503466119
type MDMAppleCommand struct {
// 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"`
// 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"`
// Status is the command status. One of Acknowledged, Error, or NotNow.
Status string `json:"status" db:"status"`
// Hostname is the hostname of the host that executed the command.
Hostname string `json:"hostname" db:"hostname"`
// TeamID is the host's team, null if the host is in no team. This is used
// to authorize the user to see the command, it is not returned as part of
// the response payload.
TeamID *uint `json:"-" db:"team_id"`
}
+4
View File
@@ -774,6 +774,10 @@ type Datastore interface {
// GetMDMAppleCommandResults returns the execution results of a command identified by a CommandUUID.
GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*MDMAppleCommandResult, error)
// ListMDMAppleCommands returns a list of MDM Apple commands that have been
// executed, based on the provided options.
ListMDMAppleCommands(ctx context.Context, tmFilter TeamFilter, listOpts *MDMAppleCommandListOptions) ([]*MDMAppleCommand, 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)
+4
View File
@@ -606,6 +606,10 @@ type Service interface {
// GetMDMAppleCommandResults returns the execution results of a command identified by a CommandUUID.
GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*MDMAppleCommandResult, error)
// ListMDMAppleCommands returns a list of MDM Apple commands corresponding to
// the specified options.
ListMDMAppleCommands(ctx context.Context, opts *MDMAppleCommandListOptions) ([]*MDMAppleCommand, error)
// UploadMDMAppleInstaller uploads an Apple installer to Fleet.
UploadMDMAppleInstaller(ctx context.Context, name string, size int64, installer io.Reader) (*MDMAppleInstaller, error)
+12
View File
@@ -540,6 +540,8 @@ type ListMDMAppleEnrollmentProfilesFunc func(ctx context.Context) ([]*fleet.MDMA
type GetMDMAppleCommandResultsFunc func(ctx context.Context, commandUUID string) ([]*fleet.MDMAppleCommandResult, error)
type ListMDMAppleCommandsFunc func(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, error)
type NewMDMAppleInstallerFunc func(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*fleet.MDMAppleInstaller, error)
type MDMAppleInstallerFunc func(ctx context.Context, token string) (*fleet.MDMAppleInstaller, error)
@@ -1381,6 +1383,9 @@ type DataStore struct {
GetMDMAppleCommandResultsFunc GetMDMAppleCommandResultsFunc
GetMDMAppleCommandResultsFuncInvoked bool
ListMDMAppleCommandsFunc ListMDMAppleCommandsFunc
ListMDMAppleCommandsFuncInvoked bool
NewMDMAppleInstallerFunc NewMDMAppleInstallerFunc
NewMDMAppleInstallerFuncInvoked bool
@@ -3299,6 +3304,13 @@ func (s *DataStore) GetMDMAppleCommandResults(ctx context.Context, commandUUID s
return s.GetMDMAppleCommandResultsFunc(ctx, commandUUID)
}
func (s *DataStore) ListMDMAppleCommands(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, error) {
s.mu.Lock()
s.ListMDMAppleCommandsFuncInvoked = true
s.mu.Unlock()
return s.ListMDMAppleCommandsFunc(ctx, tmFilter, listOpts)
}
func (s *DataStore) NewMDMAppleInstaller(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*fleet.MDMAppleInstaller, error) {
s.mu.Lock()
s.NewMDMAppleInstallerFuncInvoked = true
+112 -9
View File
@@ -256,7 +256,7 @@ func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID s
return nil, nil
}
// collect the team IDs and verify that the user has access to run commands
// collect the team IDs and verify that the user has access to view 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))
@@ -270,14 +270,14 @@ func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID s
hostsByUUID[h.UUID] = h
}
var command fleet.MDMAppleCommand
var commandAuthz fleet.MDMAppleCommandAuthz
for tmID := range teamIDs {
command.TeamID = &tmID
commandAuthz.TeamID = &tmID
if tmID == 0 {
command.TeamID = nil
commandAuthz.TeamID = nil
}
if err := svc.authz.Authorize(ctx, command, fleet.ActionRead); err != nil {
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionRead); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
}
@@ -291,6 +291,109 @@ func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID s
return results, nil
}
type listMDMAppleCommandsRequest struct {
ListOptions fleet.ListOptions `url:"list_options"`
}
type listMDMAppleCommandsResponse struct {
Results []*fleet.MDMAppleCommand `json:"results"`
Err error `json:"error,omitempty"`
}
func (r listMDMAppleCommandsResponse) error() error { return r.Err }
func listMDMAppleCommandsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*listMDMAppleCommandsRequest)
results, err := svc.ListMDMAppleCommands(ctx, &fleet.MDMAppleCommandListOptions{
ListOptions: req.ListOptions,
})
if err != nil {
return listMDMAppleCommandsResponse{
Err: err,
}, nil
}
return listMDMAppleCommandsResponse{
Results: results,
}, nil
}
func (svc *Service) ListMDMAppleCommands(ctx context.Context, opts *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, 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
}
// get the list of commands so we know what hosts (and therefore what teams)
// we're dealing with. Including the observers as they are allowed to view
// MDM Apple commands.
results, err := svc.ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{
User: vc.User,
IncludeObserver: true,
}, opts)
if err != nil {
return nil, err
}
// collect the different team IDs and verify that the user has access to view
// commands on all affected teams, do not assume that ListMDMAppleCommands
// only returned hosts that the user is authorized to view the command
// results of (that is, always verify with our rego authz policy).
teamIDs := make(map[uint]bool)
for _, res := range results {
var id uint
if res.TeamID != nil {
id = *res.TeamID
}
teamIDs[id] = true
}
// instead of returning an authz error if the user is not authorized for a
// team, we remove those commands from the results (as we want to return
// whatever the user is allowed to see). Since this can only be done after
// retrieving the list of commands, this may result in returning less results
// than requested, but it's ok - it's expected that the results retrieved
// from the datastore will all be authorized for the user.
var commandAuthz fleet.MDMAppleCommandAuthz
var authzErr error
for tmID := range teamIDs {
commandAuthz.TeamID = &tmID
if tmID == 0 {
commandAuthz.TeamID = nil
}
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionRead); err != nil {
if authzErr == nil {
authzErr = err
}
teamIDs[tmID] = false
}
}
if authzErr != nil {
level.Error(svc.logger).Log("err", "unauthorized to view some team commands", "details", authzErr)
// filter-out the teams that the user is not allowed to view
allowedResults := make([]*fleet.MDMAppleCommand, 0, len(results))
for _, res := range results {
var id uint
if res.TeamID != nil {
id = *res.TeamID
}
if teamIDs[id] {
allowedResults = append(allowedResults, res)
}
}
results = allowedResults
}
return results, nil
}
type newMDMAppleConfigProfileRequest struct {
TeamID uint
Profile *multipart.FileHeader
@@ -1008,14 +1111,14 @@ func (svc *Service) EnqueueMDMAppleCommand(
teamIDs[id] = true
}
var command fleet.MDMAppleCommand
var commandAuthz fleet.MDMAppleCommandAuthz
for tmID := range teamIDs {
command.TeamID = &tmID
commandAuthz.TeamID = &tmID
if tmID == 0 {
command.TeamID = nil
commandAuthz.TeamID = nil
}
if err := svc.authz.Authorize(ctx, command, fleet.ActionWrite); err != nil {
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionWrite); err != nil {
return 0, nil, ctxerr.Wrap(ctx, err)
}
}
+122 -79
View File
@@ -262,36 +262,37 @@ func TestAppleMDMAuthorization(t *testing.T) {
</dict>
</plist>`))
enqueueCmdCases := []struct {
desc string
user *fleet.User
uuids []string
shoudFailWithAuth bool
}{
{"no role", test.UserNoRoles, []string{"host1", "host2", "host3", "host4"}, true},
{"maintainer can run", test.UserMaintainer, []string{"host1", "host2", "host3", "host4"}, false},
{"admin can run", test.UserAdmin, []string{"host1", "host2", "host3", "host4"}, false},
{"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 enqueueCmdCases {
t.Run(c.desc, func(t *testing.T) {
ctx = test.UserContext(ctx, c.user)
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64FreeCmd, c.uuids, false)
checkAuthErr(t, err, c.shoudFailWithAuth)
})
}
t.Run("EnqueueMDMAppleCommand", func(t *testing.T) {
enqueueCmdCases := []struct {
desc string
user *fleet.User
uuids []string
shoudFailWithAuth bool
}{
{"no role", test.UserNoRoles, []string{"host1", "host2", "host3", "host4"}, true},
{"maintainer can run", test.UserMaintainer, []string{"host1", "host2", "host3", "host4"}, false},
{"admin can run", test.UserAdmin, []string{"host1", "host2", "host3", "host4"}, false},
{"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 enqueueCmdCases {
t.Run(c.desc, func(t *testing.T) {
ctx = test.UserContext(ctx, c.user)
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64FreeCmd, c.uuids, false)
checkAuthErr(t, err, c.shoudFailWithAuth)
})
}
// test with a command that requires a premium license
ctx = test.UserContext(ctx, test.UserAdmin)
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree})
rawB64PremiumCmd := base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
// test with a command that requires a premium license
ctx = test.UserContext(ctx, test.UserAdmin)
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree})
rawB64PremiumCmd := base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
@@ -304,9 +305,10 @@ func TestAppleMDMAuthorization(t *testing.T) {
<string>uuid</string>
</dict>
</plist>`, "DeviceLock")))
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"}, false)
require.Error(t, err)
require.ErrorContains(t, err, fleet.ErrMissingLicense.Error())
_, _, 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"},
@@ -325,53 +327,94 @@ func TestAppleMDMAuthorization(t *testing.T) {
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},
{"observer+ can view", test.UserObserverPlus, "uuidTm1", false},
{"observer+ can view", test.UserObserverPlus, "uuidTm2", false},
{"observer+ can view", test.UserObserverPlus, "uuidNoTm", false},
{"observer+ can view", test.UserObserverPlus, "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 observer+ can view tm1", test.UserTeamObserverPlusTeam1, "uuidTm1", false},
{"tm1 observer+ cannot view tm2", test.UserTeamObserverPlusTeam1, "uuidTm2", true},
{"tm1 observer+ cannot view no team", test.UserTeamObserverPlusTeam1, "uuidNoTm", true},
{"tm1 observer+ cannot view mix", test.UserTeamObserverPlusTeam1, "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)
})
}
t.Run("GetMDMAppleCommandResults", func(t *testing.T) {
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},
{"observer+ can view", test.UserObserverPlus, "uuidTm1", false},
{"observer+ can view", test.UserObserverPlus, "uuidTm2", false},
{"observer+ can view", test.UserObserverPlus, "uuidNoTm", false},
{"observer+ can view", test.UserObserverPlus, "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 observer+ can view tm1", test.UserTeamObserverPlusTeam1, "uuidTm1", false},
{"tm1 observer+ cannot view tm2", test.UserTeamObserverPlusTeam1, "uuidTm2", true},
{"tm1 observer+ cannot view no team", test.UserTeamObserverPlusTeam1, "uuidNoTm", true},
{"tm1 observer+ cannot view mix", test.UserTeamObserverPlusTeam1, "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)
})
}
})
t.Run("ListMDMAppleCommands", func(t *testing.T) {
ds.ListMDMAppleCommandsFunc = func(ctx context.Context, tmFilter fleet.TeamFilter, opt *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, error) {
return []*fleet.MDMAppleCommand{
{DeviceID: "no team", TeamID: nil},
{DeviceID: "tm1", TeamID: ptr.Uint(1)},
{DeviceID: "tm2", TeamID: ptr.Uint(2)},
}, nil
}
listCmdsCases := []struct {
desc string
user *fleet.User
want []string // the expected device ids in the results
}{
{"no role", test.UserNoRoles, []string{}},
{"maintainer can view", test.UserMaintainer, []string{"no team", "tm1", "tm2"}},
{"observer can view", test.UserObserver, []string{"no team", "tm1", "tm2"}},
{"observer+ can view", test.UserObserverPlus, []string{"no team", "tm1", "tm2"}},
{"admin can view", test.UserAdmin, []string{"no team", "tm1", "tm2"}},
{"tm1 maintainer can view tm1", test.UserTeamMaintainerTeam1, []string{"tm1"}},
{"tm1 observer can view tm1", test.UserTeamObserverTeam1, []string{"tm1"}},
{"tm1 observer+ can view tm1", test.UserTeamObserverPlusTeam1, []string{"tm1"}},
{"tm1 admin can view tm1", test.UserTeamAdminTeam1, []string{"tm1"}},
}
for _, c := range listCmdsCases {
t.Run(c.desc, func(t *testing.T) {
ctx = test.UserContext(ctx, c.user)
res, err := svc.ListMDMAppleCommands(ctx, &fleet.MDMAppleCommandListOptions{})
require.NoError(t, err) // never fails with authz error, it just filters out unauthorized results
got := make([]string, len(res))
for i, r := range res {
got[i] = r.DeviceID
}
require.Equal(t, c.want, got)
})
}
})
}
func TestMDMAppleEnrollURL(t *testing.T) {
+10 -114
View File
@@ -1,14 +1,8 @@
package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
@@ -17,27 +11,6 @@ import (
"howett.net/plist"
)
func (c *Client) CreateEnrollmentProfile(enrollmentProfileType fleet.MDMAppleEnrollmentType, depProfile *json.RawMessage) (*fleet.MDMAppleEnrollmentProfile, error) {
request := createMDMAppleEnrollmentProfileRequest{
Type: enrollmentProfileType,
DEPProfile: depProfile,
}
var response createMDMAppleEnrollmentProfileResponse
if err := c.authenticatedRequest(request, "POST", "/api/latest/fleet/mdm/apple/enrollmentprofiles", &response); err != nil {
return nil, fmt.Errorf("request: %w", err)
}
return response.EnrollmentProfile, nil
}
func (c *Client) ListEnrollments() ([]*fleet.MDMAppleEnrollmentProfile, error) {
request := listMDMAppleEnrollmentProfilesRequest{}
var response listMDMAppleEnrollmentProfilesResponse
if err := c.authenticatedRequest(request, "GET", "/api/latest/fleet/mdm/apple/enrollmentprofiles", &response); err != nil {
return nil, fmt.Errorf("request: %w", err)
}
return response.EnrollmentProfiles, nil
}
func (c *Client) EnqueueCommand(deviceIDs []string, rawPlist []byte) (*fleet.CommandEnqueueResult, error) {
var commandPayload map[string]interface{}
if _, err := plist.Unmarshal(rawPlist, &commandPayload); err != nil {
@@ -78,98 +51,21 @@ func (c *Client) MDMAppleGetCommandResults(commandUUID string) ([]*fleet.MDMAppl
return responseBody.Results, nil
}
func (c *Client) UploadMDMAppleInstaller(ctx context.Context, name string, installer io.Reader) (uint, error) {
if c.token == "" {
return 0, errors.New("authentication token is empty")
}
func (c *Client) MDMAppleListCommands() ([]*fleet.MDMAppleCommand, error) {
const defaultCommandsPerPage = 1000
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
fw, err := writer.CreateFormFile("installer", name)
if err != nil {
return 0, fmt.Errorf("create form file: %w", err)
}
_, err = io.Copy(fw, installer)
if err != nil {
return 0, fmt.Errorf("write form file: %w", err)
}
writer.Close()
verb, path := http.MethodGet, "/api/latest/fleet/mdm/apple/commands"
var (
verb = "POST"
path = "/api/latest/fleet/mdm/apple/installers"
)
response, err := c.doContextWithBodyAndHeaders(ctx, verb, path, "",
body.Bytes(),
map[string]string{
"Content-Type": writer.FormDataContentType(),
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", c.token),
},
)
if err != nil {
return 0, fmt.Errorf("do multipart request: %w", err)
}
query := url.Values{}
query.Set("per_page", fmt.Sprint(defaultCommandsPerPage))
query.Set("order_key", "updated_at")
query.Set("order_direction", "desc")
var installerResponse uploadAppleInstallerResponse
if err := c.parseResponse(verb, path, response, &installerResponse); err != nil {
return 0, fmt.Errorf("parse response: %w", err)
}
return installerResponse.ID, nil
}
func (c *Client) MDMAppleGetInstallerDetails(id uint) (*fleet.MDMAppleInstaller, error) {
verb, path := http.MethodGet, fmt.Sprintf("/api/latest/fleet/mdm/apple/installers/%d", id)
var responseBody getAppleInstallerDetailsResponse
err := c.authenticatedRequest(nil, verb, path, &responseBody)
var responseBody listMDMAppleCommandsResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query.Encode())
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
return responseBody.Installer, nil
}
func (c *Client) MDMAppleListDevices() ([]fleet.MDMAppleDevice, error) {
verb, path := http.MethodGet, "/api/latest/fleet/mdm/apple/devices"
var responseBody listMDMAppleDevicesResponse
err := c.authenticatedRequest(nil, verb, path, &responseBody)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
return responseBody.Devices, nil
}
func (c *Client) DEPListDevices() ([]fleet.MDMAppleDEPDevice, error) {
verb, path := http.MethodGet, "/api/latest/fleet/mdm/apple/dep/devices"
var responseBody listMDMAppleDEPDevicesResponse
err := c.authenticatedRequest(nil, verb, path, &responseBody)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
return responseBody.Devices, nil
}
func (c *Client) ListMDMAppleInstallers() ([]fleet.MDMAppleInstaller, error) {
request := listMDMAppleInstallersRequest{}
var response listMDMAppleInstallersResponse
if err := c.authenticatedRequest(request, "GET", "/api/latest/fleet/mdm/apple/installers", &response); err != nil {
return nil, fmt.Errorf("request: %w", err)
}
return response.Installers, nil
}
func (c *Client) MDMDeleteAppleInstaller(id uint) error {
verb, path := http.MethodDelete, fmt.Sprintf("/api/latest/fleet/mdm/apple/installers/%d", id)
var responseBody deleteAppleInstallerDetailsResponse
err := c.authenticatedRequest(nil, verb, path, &responseBody)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
return nil
return responseBody.Results, nil
}
+12 -8
View File
@@ -436,16 +436,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// when you add an endpoint that's behind the mdmConfiguredMiddleware.
mdmConfiguredMiddleware := mdmconfigured.NewAppleMiddleware(svc)
mdm := ue.WithCustomMiddleware(mdmConfiguredMiddleware.Verify())
mdm.POST("/api/_version_/fleet/mdm/apple/enrollmentprofiles", createMDMAppleEnrollmentProfilesEndpoint, createMDMAppleEnrollmentProfileRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/enrollmentprofiles", listMDMAppleEnrollmentsEndpoint, listMDMAppleEnrollmentProfilesRequest{})
mdm.POST("/api/_version_/fleet/mdm/apple/enqueue", enqueueMDMAppleCommandEndpoint, enqueueMDMAppleCommandRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/commandresults", getMDMAppleCommandResultsEndpoint, getMDMAppleCommandResultsRequest{})
mdm.POST("/api/_version_/fleet/mdm/apple/installers", uploadAppleInstallerEndpoint, uploadAppleInstallerRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", getAppleInstallerEndpoint, getAppleInstallerDetailsRequest{})
mdm.DELETE("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", deleteAppleInstallerEndpoint, deleteAppleInstallerDetailsRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/installers", listMDMAppleInstallersEndpoint, listMDMAppleInstallersRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/devices", listMDMAppleDevicesEndpoint, listMDMAppleDevicesRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/commands", listMDMAppleCommandsEndpoint, listMDMAppleCommandsRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/filevault/summary", getMdmAppleFileVaultSummaryEndpoint, getMDMAppleFileVaultSummaryRequest{})
mdm.POST("/api/_version_/fleet/mdm/apple/profiles", newMDMAppleConfigProfileEndpoint, newMDMAppleConfigProfileRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesEndpoint, listMDMAppleConfigProfilesRequest{})
@@ -453,6 +446,17 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
mdm.DELETE("/api/_version_/fleet/mdm/apple/profiles/{profile_id:[0-9]+}", deleteMDMAppleConfigProfileEndpoint, deleteMDMAppleConfigProfileRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/profiles/summary", getMDMAppleProfilesSummaryEndpoint, getMDMAppleProfilesSummaryRequest{})
// TODO: are those undocumented endpoints still needed? I think they were only used
// by 'fleetctl apple-mdm' sub-commands.
mdm.POST("/api/_version_/fleet/mdm/apple/enrollmentprofiles", createMDMAppleEnrollmentProfilesEndpoint, createMDMAppleEnrollmentProfileRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/enrollmentprofiles", listMDMAppleEnrollmentsEndpoint, listMDMAppleEnrollmentProfilesRequest{})
mdm.POST("/api/_version_/fleet/mdm/apple/installers", uploadAppleInstallerEndpoint, uploadAppleInstallerRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", getAppleInstallerEndpoint, getAppleInstallerDetailsRequest{})
mdm.DELETE("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", deleteAppleInstallerEndpoint, deleteAppleInstallerDetailsRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/installers", listMDMAppleInstallersEndpoint, listMDMAppleInstallersRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/devices", listMDMAppleDevicesEndpoint, listMDMAppleDevicesRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{})
// bootstrap-package routes
mdm.POST("/api/_version_/fleet/mdm/apple/bootstrap", uploadBootstrapPackageEndpoint, uploadBootstrapPackageRequest{})
mdm.GET("/api/_version_/fleet/mdm/apple/bootstrap/{team_id:[0-9]+}/metadata", bootstrapPackageMetadataEndpoint, bootstrapPackageMetadataRequest{})
+18
View File
@@ -2563,6 +2563,11 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
var cmdResResp getMDMAppleCommandResultsResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commandresults", nil, http.StatusNotFound, &cmdResResp, "command_uuid", uuid1)
// list commands returns empty set
var listCmdResp listMDMAppleCommandsResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
require.Empty(t, listCmdResp.Results)
// call with unenrolled host UUID
res := s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue",
enqueueMDMAppleCommandRequest{
@@ -2620,6 +2625,19 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
Result: []byte(rawCmd),
Hostname: "test-host",
}, cmdResResp.Results[0])
// list commands returns that command
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
require.Len(t, listCmdResp.Results, 1)
require.NotZero(t, listCmdResp.Results[0].UpdatedAt)
listCmdResp.Results[0].UpdatedAt = time.Time{}
require.Equal(t, &fleet.MDMAppleCommand{
DeviceID: enrolledHost.uuid,
CommandUUID: uuid2,
Status: "Acknowledged",
RequestType: "ProfileList",
Hostname: "test-host",
}, listCmdResp.Results[0])
}
func (s *integrationMDMTestSuite) TestBootstrapPackage() {