diff --git a/changes/issue-8596-fleetctl-get-mdm_apple b/changes/issue-8596-fleetctl-get-mdm_apple new file mode 100644 index 0000000000..f866acdb3d --- /dev/null +++ b/changes/issue-8596-fleetctl-get-mdm_apple @@ -0,0 +1 @@ +* Added the `fleetctl get mdm_apple` command to retrieve the Apple MDM configuration information. diff --git a/cmd/fleetctl/get.go b/cmd/fleetctl/get.go index 9b684327d6..0dcc728a63 100644 --- a/cmd/fleetctl/get.go +++ b/cmd/fleetctl/get.go @@ -8,7 +8,9 @@ import ( "net/url" "os" "strconv" + "time" + "github.com/fatih/color" "github.com/fleetdm/fleet/v4/pkg/secure" "gopkg.in/guregu/null.v3" @@ -40,6 +42,23 @@ func defaultTable(writer io.Writer) *tablewriter.Table { return table } +func borderlessTabularTable(writer io.Writer) *tablewriter.Table { + w := writerOrStdout(writer) + table := tablewriter.NewWriter(w) + table.SetRowLine(false) + table.SetAutoWrapText(false) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetTablePadding("\t") + table.SetNoWhiteSpace(true) + + return table +} + func writerOrStdout(writer io.Writer) io.Writer { var w io.Writer w = os.Stdout @@ -273,6 +292,7 @@ func getCommand() *cli.Command { getUserRolesCommand(), getTeamsCommand(), getSoftwareCommand(), + getMDMAppleCommand(), }, } } @@ -883,6 +903,12 @@ func printTable(c *cli.Context, columns []string, data [][]string) { table.Render() } +func printKeyValueTable(c *cli.Context, rows [][]string) { + table := borderlessTabularTable(c.App.Writer) + table.AppendBulk(rows) + table.Render() +} + func getTeamsCommand() *cli.Command { return &cli.Command{ Name: "teams", @@ -1022,3 +1048,53 @@ func getSoftwareCommand() *cli.Command { }, } } + +func getMDMAppleCommand() *cli.Command { + return &cli.Command{ + Name: "mdm_apple", + Hidden: true, // TODO: temporary, until the MDM feature is officially released + Aliases: []string{"mdm-apple"}, + Usage: "Show Apple Push Notification Service (APNs) information", + Flags: []cli.Flag{ + configFlag(), + contextFlag(), + debugFlag(), + }, + Action: func(c *cli.Context) error { + const expirationWarning = 30 * 24 * time.Hour // 30 days + + client, err := clientFromCLI(c) + if err != nil { + return err + } + + mdm, err := client.GetAppleMDM() + if err != nil { + var nfe service.NotFoundErr + if errors.As(err, &nfe) { + log(c, "Error: No Apple Push Notification service (APNs) certificate found. Use `fleetctl generate mdm-apple` and then `fleet serve` with `mdm` configuration to turn on MDM features.\n") + return nil + } + return err + } + + printKeyValueTable(c, [][]string{ + {"Common name (CN):", mdm.CommonName}, + {"Serial number:", mdm.SerialNumber}, + {"Issuer:", mdm.Issuer}, + {"Renew date:", mdm.RenewDate.Format("January 2, 2006")}, + }) + + warnDate := time.Now().Add(expirationWarning) + if mdm.RenewDate.Before(time.Now()) { + // certificate is expired, print an error + color.New(color.FgRed).Fprintln(writerOrStdout(c.App.Writer), "\nERROR: Your Apple Push Notification service (APNs) certificate is expired. MDM features are turned off. To renew your APNs certificate, follow these instructions: [TODO link to documentation]") + } else if mdm.RenewDate.Before(warnDate) { + // certificate will soon expire, print a warning + color.New(color.FgYellow).Fprintln(writerOrStdout(c.App.Writer), "\nWARNING: Your Apple Push Notification service (APNs) certificate is less than 30 days from expiration. If it expires, MDM features will be turned off. To renew your APNs certificate, follow these instructions: [TODO link to documentation]") + } + + return nil + }, + } +} diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 2bf175a333..27e6b56ac3 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -1451,3 +1451,12 @@ func TestEnrichedAppConfig(t *testing.T) { require.Equal(t, "filesystem", enriched.Logging.Status.Plugin) }) } + +func TestGetAppleMDM(t *testing.T) { + runServerWithMockedDS(t) + + // can only test when no MDM cert is provided, otherwise they would have to + // be valid Apple APNs and SCEP certs. + expected := `Error: No Apple Push Notification service (APNs) certificate found.` + assert.Contains(t, runAppForTest(t, []string{"get", "mdm_apple"}), expected) +} diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 452af67544..01c4baded5 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -1,8 +1,10 @@ # API for contributors - [Packs](#packs) +- [Mobile device management (MDM) - IN PROGRESS](#mobile-device-management-mdm-in-progress) - [Get or apply configuration files](#get-or-apply-configuration-files) - [Live query](#live-query) +- [Trigger cron schedule](#trigger-cron-schedule) - [Device-authenticated routes](#device-authenticated-routes) - [Downloadable installers](#downloadable-installers) - [Setup](#setup) @@ -516,6 +518,39 @@ Delete pack by name. --- +## Mobile device management (MDM) - IN PROGRESS + +> This feature is currently in development and is not ready for use. + +The MDM endpoints exist to support the related command-line interface sub-commands of `fleetctl`, such as `fleetctl generate mdm-apple` and `fleetctl get mdm-apple`, as well as the Web UI. + +- [Get Apple MDM](#get-apple-mdm) + +### Get Apple MDM + +`GET /api/v1/fleet/mdm/apple` + +#### Parameters + +None. + +#### Example + +`GET /api/v1/fleet/mdm/apple` + +##### Default response + +`Status: 200` + +```json +{ + "common_name": "APSP:04u52i98aewuh-xxxx-xxxx-xxxx-xxxx", + "serial_number": "1234567890987654321", + "issuer": "Apple Application Integration 2 Certification Authority", + "renew_date": "2023-09-30T00:00:00Z" +} +``` + ## 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/). @@ -1965,7 +2000,7 @@ currently pending. --- -### Device-authenticated routes +## Device-authenticated routes Device-authenticated routes are routes used by the Fleet Desktop application. Unlike most other routes, Fleet user's API token does not authenticate them. They use a device-specific token. @@ -2378,7 +2413,7 @@ If an installer with the provided parameters is found. If an installer with the provided parameters doesn't exist. -### Setup +## Setup Sets up a new Fleet instance with the given parameters. diff --git a/docs/Using-Fleet/Permissions.md b/docs/Using-Fleet/Permissions.md index 334f6ce1b8..842e91e37a 100644 --- a/docs/Using-Fleet/Permissions.md +++ b/docs/Using-Fleet/Permissions.md @@ -41,6 +41,7 @@ Users with the Admin role receive all permissions. | Edit [agent options for hosts assigned to teams](https://fleetdm.com/docs/using-fleet/configuration-files#team-agent-options)\* | | | ✅ | | Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | ✅ | ✅ | | Retrieve contents from file carving | | | ✅ | +| View Apple mobile device management (MDM) certificate information | | | ✅ | @@ -52,7 +53,7 @@ Users with the Admin role receive all permissions. `Applies only to Fleet Premium` -Users in Fleet either have team access or global access. +Users in Fleet either have team access or global access. Users with team access only have access to the [hosts](https://fleetdm.com/docs/using-fleet/rest-api#hosts), [software](https://fleetdm.com/docs/using-fleet/rest-api#software), [schedules](https://fleetdm.com/docs/using-fleet/fleet-ui#schedule-a-query) , and [policies](https://fleetdm.com/docs/using-fleet/rest-api#policies) assigned to their team. diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 257c7d121a..61734d82ff 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -515,6 +515,13 @@ allow { # Apple MDM ## +# Global admins can read and write MDM apple information. +allow { + object.type == "mdm_apple" + subject.global_role == admin + action == [read, write][_] +} + # Global admins can read and write Apple MDM enrollments. allow { object.type == "mdm_apple_enrollment_profile" diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go new file mode 100644 index 0000000000..7fdeb84519 --- /dev/null +++ b/server/fleet/mdm.go @@ -0,0 +1,14 @@ +package fleet + +import "time" + +type AppleMDM struct { + CommonName string `json:"common_name"` + SerialNumber string `json:"serial_number"` + Issuer string `json:"issuer"` + RenewDate time.Time `json:"renew_date"` +} + +func (a AppleMDM) AuthzType() string { + return "mdm_apple" +} diff --git a/server/fleet/service.go b/server/fleet/service.go index c50aed2934..022468886a 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -528,6 +528,8 @@ type Service interface { /////////////////////////////////////////////////////////////////////////////// // Apple MDM + GetAppleMDM(ctx context.Context) (*AppleMDM, error) + // NewMDMAppleEnrollmentProfile creates and returns new enrollment profile. // Such enrollment profiles allow devices to enroll to Fleet MDM. NewMDMAppleEnrollmentProfile(ctx context.Context, enrollmentPayload MDMAppleEnrollmentProfilePayload) (enrollmentProfile *MDMAppleEnrollmentProfile, err error) diff --git a/server/service/client_mdm.go b/server/service/client_mdm.go new file mode 100644 index 0000000000..0c39fbc238 --- /dev/null +++ b/server/service/client_mdm.go @@ -0,0 +1,11 @@ +package service + +import "github.com/fleetdm/fleet/v4/server/fleet" + +// GetAppleMDM retrieves the Apple MDM APNs information. +func (c *Client) GetAppleMDM() (*fleet.AppleMDM, error) { + verb, path := "GET", "/api/latest/fleet/mdm/apple" + var responseBody getAppleMDMResponse + err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "") + return responseBody.AppleMDM, err +} diff --git a/server/service/handler.go b/server/service/handler.go index e442f818f5..71e44a9674 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -419,6 +419,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/mdm/apple/devices", listMDMAppleDevicesEndpoint, listMDMAppleDevicesRequest{}) ue.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{}) } + ue.GET("/api/_version_/fleet/mdm/apple", getAppleMDMEndpoint, nil) errorLimiter := ratelimit.NewErrorMiddleware(limitStore) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 0133a02279..ecb4e4b357 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5868,6 +5868,11 @@ func (s *integrationTestSuite) TestPingEndpoints() { s.DoRawNoAuth("HEAD", "/api/fleet/device/ping", nil, http.StatusOK) } +func (s *integrationTestSuite) TestAppleMDMNotConfigured() { + var resp getAppleMDMResponse + s.DoJSON("GET", "/api/latest/fleet/mdm/apple", nil, http.StatusNotFound, &resp) +} + // this test can be deleted once the "v1" version is removed. func (s *integrationTestSuite) TestAPIVersion_v1_2022_04() { t := s.T() diff --git a/server/service/mdm.go b/server/service/mdm.go new file mode 100644 index 0000000000..632c3cd4e7 --- /dev/null +++ b/server/service/mdm.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + + "github.com/fleetdm/fleet/v4/server/fleet" +) + +type getAppleMDMResponse struct { + *fleet.AppleMDM + Err error `json:"error,omitempty"` +} + +func (r getAppleMDMResponse) error() error { return r.Err } + +func getAppleMDMEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) { + appleMDM, err := svc.GetAppleMDM(ctx) + if err != nil { + return getAppleMDMResponse{Err: err}, nil + } + + return getAppleMDMResponse{AppleMDM: appleMDM}, nil +} + +func (svc *Service) GetAppleMDM(ctx context.Context) (*fleet.AppleMDM, error) { + if err := svc.authz.Authorize(ctx, &fleet.AppleMDM{}, fleet.ActionRead); err != nil { + return nil, err + } + + // if there is no apple mdm config, fail with a 404 + if !svc.config.MDM.IsAppleAPNsSet() { + return nil, notFoundError{} + } + + apns, _, _, err := svc.config.MDM.AppleAPNs() + if err != nil { + return nil, err + } + + appleMDM := &fleet.AppleMDM{ + CommonName: apns.Leaf.Subject.CommonName, + Issuer: apns.Leaf.Issuer.CommonName, + RenewDate: apns.Leaf.NotAfter, + } + if apns.Leaf.SerialNumber != nil { + appleMDM.SerialNumber = apns.Leaf.SerialNumber.String() + } + + return appleMDM, nil +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go new file mode 100644 index 0000000000..eeb760d895 --- /dev/null +++ b/server/service/mdm_test.go @@ -0,0 +1,76 @@ +package service + +import ( + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/stretchr/testify/require" +) + +func TestGetMDMApple(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierFree} + cfg := config.TestConfig() + cfg.MDM.AppleAPNsCert = "testdata/server.pem" + cfg.MDM.AppleAPNsKey = "testdata/server.key" + cfg.MDM.AppleSCEPCert = "testdata/server.pem" + cfg.MDM.AppleSCEPKey = "testdata/server.key" + svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + _, _, _, err := cfg.MDM.AppleAPNs() + require.NoError(t, err) + + ctx = test.UserContext(ctx, test.UserAdmin) + got, err := svc.GetAppleMDM(ctx) + require.NoError(t, err) + + // NOTE: to inspect the test certificate, you can use: + // openssl x509 -in ./server/service/testdata/server.pem -text -noout + require.Equal(t, &fleet.AppleMDM{ + CommonName: "servq.groob.io", + SerialNumber: "1", + Issuer: "groob-ca", + RenewDate: time.Date(2017, 10, 24, 13, 11, 44, 0, time.UTC), + }, got) +} + +func TestMDMAppleAuthorization(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + // use a custom implementation of checkAuthErr as the service call will fail + // with a not found error (given that MDM is not really configured) in case + // of success, and the package-wide checkAuthErr requires no error. + checkAuthErr := func(t *testing.T, shouldFail bool, err error) { + if shouldFail { + require.Error(t, err) + require.Equal(t, (&authz.Forbidden{}).Error(), err.Error()) + } else if err != nil { + require.NotEqual(t, (&authz.Forbidden{}).Error(), err.Error()) + } + } + testAuthdMethods := func(t *testing.T, user *fleet.User, shouldFailWithAuth bool) { + ctx := test.UserContext(ctx, user) + _, err := svc.GetAppleMDM(ctx) + checkAuthErr(t, shouldFailWithAuth, err) + } + + // Only global admins can access the endpoints. + testAuthdMethods(t, test.UserAdmin, false) + + // All other users should not have access to the endpoints. + for _, user := range []*fleet.User{ + test.UserNoRoles, + test.UserMaintainer, + test.UserObserver, + test.UserTeamAdminTeam1, + } { + testAuthdMethods(t, user, true) + } +}