Add command get mdm-apple-bm to fleetctl (#8949)

This commit is contained in:
Martin Angers
2022-12-12 15:45:53 -05:00
committed by GitHub
parent 499e0a2890
commit f18a320bd5
17 changed files with 292 additions and 22 deletions
@@ -0,0 +1 @@
* Added the `fleetctl get mdm_apple_bm` command to retrieve the Apple Business Manager configuration information.
+5 -8
View File
@@ -51,7 +51,6 @@ import (
"github.com/go-kit/kit/log/level"
kitprometheus "github.com/go-kit/kit/metrics/prometheus"
"github.com/kolide/kit/version"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanomdm/cryptoutil"
"github.com/micromdm/nanomdm/push/buford"
nanomdm_pushsvc "github.com/micromdm/nanomdm/push/service"
@@ -394,7 +393,6 @@ the way that the Fleet server works.
appleSCEPKeyPEM []byte
appleAPNsCertPEM []byte
appleAPNsKeyPEM []byte
appleBMToken *nanodep_client.OAuth1Tokens
depStorage *mysql.NanoDEPStorage
mdmStorage *mysql.NanoMDMStorage
mdmPushService *nanomdm_pushsvc.PushService
@@ -448,7 +446,10 @@ the way that the Fleet server works.
if err != nil {
initFatal(err, "validate Apple BM token, certificate and key")
}
appleBMToken = tok
depStorage, err = mds.NewMDMAppleDEPStorage(*tok)
if err != nil {
initFatal(err, "initialize Apple BM DEP storage")
}
}
if config.MDMApple.Enable {
@@ -471,10 +472,6 @@ the way that the Fleet server works.
if err != nil {
initFatal(err, "initialize mdm apple MySQL storage")
}
depStorage, err = mds.NewMDMAppleDEPStorage(*appleBMToken)
if err != nil {
initFatal(err, "initialize mdm apple dep storage")
}
nanoMDMLogger := NewNanoMDMLogger(kitlog.With(logger, "component", "apple-mdm-push"))
pushProviderFactory := buford.NewPushProviderFactory()
mdmPushService = nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, nanoMDMLogger)
@@ -516,7 +513,7 @@ the way that the Fleet server works.
}
if license.IsPremium() {
svc, err = eeservice.NewService(svc, ds, logger, config, mailService, clock.C)
svc, err = eeservice.NewService(svc, ds, logger, config, mailService, clock.C, depStorage)
if err != nil {
initFatal(err, "initial Fleet Premium service")
}
+57 -1
View File
@@ -280,6 +280,7 @@ func getCommand() *cli.Command {
getTeamsCommand(),
getSoftwareCommand(),
getMDMAppleCommand(),
getMDMAppleBMCommand(),
},
}
}
@@ -1072,7 +1073,7 @@ func getMDMAppleCommand() *cli.Command {
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
return fmt.Errorf("could not get Apple MDM information: %w", err)
}
printKeyValueTable(c, [][]string{
@@ -1095,3 +1096,58 @@ func getMDMAppleCommand() *cli.Command {
},
}
}
func getMDMAppleBMCommand() *cli.Command {
return &cli.Command{
Name: "mdm_apple_bm",
Hidden: true, // TODO: temporary, until the MDM feature is officially released
Aliases: []string{"mdm-apple-bm"},
Usage: "Show information about Apple Business Manager for automatic enrollment",
Flags: []cli.Flag{
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
}
bm, err := client.GetAppleBM()
if err != nil {
var nfe service.NotFoundErr
if errors.As(err, &nfe) {
log(c, "Error: No Apple Business Manager server token found. Use `fleetctl generate mdm-apple-bm` and then `fleet serve` with `mdm` configuration to automatically enroll macOS hosts to Fleet.\n")
return nil
}
return fmt.Errorf("could not get Apple BM information: %w", err)
}
defaultTeam := bm.DefaultTeam
if defaultTeam == "" {
defaultTeam = "No team"
}
printKeyValueTable(c, [][]string{
{"Apple ID:", bm.AppleID},
{"Organization name:", bm.OrgName},
{"MDM server URL:", bm.MDMServerURL},
{"Renew date:", bm.RenewDate.Format("January 2, 2006")},
{"Default team:", defaultTeam},
})
warnDate := time.Now().Add(expirationWarning)
if bm.RenewDate.Before(time.Now()) {
// certificate is expired, print an error
color.New(color.FgRed).Fprintln(c.App.Writer, "\nERROR: Your Apple Business Manager (ABM) server token is expired. Laptops newly purchased via ABM will not automatically enroll in Fleet. To renew your ABM server token, follow these instructions: https://fleetdm.com/docs/using-fleet/faq#how-can-i-renew-my-apple-business-manager-server-token")
} else if bm.RenewDate.Before(warnDate) {
// certificate will soon expire, print a warning
color.New(color.FgYellow).Fprintln(c.App.Writer, "\nWARNING: Your Apple Business Manager (ABM) server token is less than 30 days from expiration. If it expires, laptops newly purchased via ABM will not automatically enroll in Fleet. To renew your ABM server token, follow these instructions: https://fleetdm.com/docs/using-fleet/faq#how-can-i-renew-my-apple-business-manager-server-token")
}
return nil
},
}
}
+18
View File
@@ -1461,6 +1461,24 @@ func TestGetAppleMDM(t *testing.T) {
assert.Contains(t, runAppForTest(t, []string{"get", "mdm_apple"}), expected)
}
func TestGetAppleBM(t *testing.T) {
t.Run("free license", func(t *testing.T) {
runServerWithMockedDS(t)
expected := `could not get Apple BM information: missing or invalid license`
_, err := runAppNoChecks([]string{"get", "mdm_apple_bm"})
require.Error(t, err)
assert.Contains(t, err.Error(), expected)
})
t.Run("premium license", func(t *testing.T) {
runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
expected := `No Apple Business Manager server token found`
assert.Contains(t, runAppForTest(t, []string{"get", "mdm_apple_bm"}), expected)
})
}
func TestGetCarves(t *testing.T) {
_, ds := runServerWithMockedDS(t)
+29
View File
@@ -525,6 +525,7 @@ Delete pack by name.
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 BM](#get-apple-bm)
### Get Apple MDM
@@ -551,6 +552,34 @@ None.
}
```
### Get Apple BM
_Available in Fleet Premium_
`GET /api/v1/fleet/mdm/apple_bm`
#### Parameters
None.
#### Example
`GET /api/v1/fleet/mdm/apple_bm`
##### Default response
`Status: 200`
```json
{
"apple_id": "example@fleetdm.com",
"org_name": "Fleet Device Management",
"mdm_server_url": "https://example.com/mdm/apple/mdm",
"renew_date": "2023-11-29T00:00:00Z",
"default_team": ""
}
```
## 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/).
+1
View File
@@ -42,6 +42,7 @@ Users with the Admin role receive all permissions.
| 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 | | | ✅ |
| View Apple business manager (BM) information | | | ✅ |
+13
View File
@@ -0,0 +1,13 @@
package service
type notFoundError struct{}
func (e notFoundError) Error() string {
return "not found"
}
// IsNotFound implements the service.IsNotFound interface (from the non-premium
// service package) so that the handler returns 404 for this error.
func (e notFoundError) IsNotFound() bool {
return true
}
+93
View File
@@ -0,0 +1,93 @@
package service
import (
"context"
"encoding/json"
"io"
"io/ioutil"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/storage"
)
func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppleBM{}, fleet.ActionRead); err != nil {
return nil, err
}
// if there is no apple bm config, fail with a 404
if !svc.config.MDM.IsAppleBMSet() {
return nil, notFoundError{}
}
appCfg, err := svc.AppConfig(ctx)
if err != nil {
return nil, err
}
tok, err := svc.config.MDM.AppleBM()
if err != nil {
return nil, err
}
appleBM, err := getAppleBMAccountDetail(ctx, svc.depStorage)
if err != nil {
return nil, err
}
// fill the rest of the AppleBM fields
appleBM.RenewDate = tok.AccessTokenExpiry
// TODO: default team will have to be set when https://github.com/fleetdm/fleet/issues/8733
// is implemented.
appleBM.DefaultTeam = ""
appleBM.MDMServerURL = appCfg.ServerSettings.ServerURL + apple_mdm.MDMPath
return appleBM, nil
}
func getAppleBMAccountDetail(ctx context.Context, depStorage storage.AllStorage) (*fleet.AppleBM, error) {
httpClient := fleethttp.NewClient()
depTransport := client.NewTransport(httpClient.Transport, httpClient, depStorage, nil)
depClient := client.NewClient(fleethttp.NewClient(), depTransport)
req, err := client.NewRequestWithContext(ctx, apple_mdm.DEPName, depStorage, "GET", "/account", nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create apple GET /account request")
}
res, err := depClient.Do(req)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "execute apple GET /account request")
}
defer res.Body.Close()
// TODO: if it fails in a way that indicates the token is invalid/expired
// (403 Forbidden), eventually we should surface that to the user.
if res.StatusCode >= 400 {
// read up to 512 bytes of the response body to get better error message if possible
body, _ := ioutil.ReadAll(io.LimitReader(res.Body, 512))
return nil, ctxerr.Wrapf(ctx, err, "apple GET /account request failed: status: %d; body: %s", res.StatusCode, string(body))
}
var account struct {
AdminID string `json:"admin_id"`
FacilitatorID string `json:"facilitator_id"`
OrgName string `json:"org_name"`
}
if err := json.NewDecoder(res.Body).Decode(&account); err != nil {
return nil, ctxerr.Wrap(ctx, err, "decode apple GET /account response")
}
if account.AdminID == "" {
// fallback to facilitator ID, as this is the same information but for
// older versions of the Apple API.
// https://github.com/fleetdm/fleet/issues/7515#issuecomment-1346579398
account.AdminID = account.FacilitatorID
}
return &fleet.AppleBM{
AppleID: account.AdminID,
OrgName: account.OrgName,
}, nil
}
+15 -11
View File
@@ -8,16 +8,18 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
kitlog "github.com/go-kit/kit/log"
"github.com/micromdm/nanodep/storage"
)
type Service struct {
fleet.Service
ds fleet.Datastore
logger kitlog.Logger
config config.FleetConfig
clock clock.Clock
authz *authz.Authorizer
ds fleet.Datastore
logger kitlog.Logger
config config.FleetConfig
clock clock.Clock
authz *authz.Authorizer
depStorage storage.AllStorage
}
func NewService(
@@ -27,6 +29,7 @@ func NewService(
config config.FleetConfig,
mailService fleet.MailService,
c clock.Clock,
depStorage storage.AllStorage,
) (*Service, error) {
authorizer, err := authz.NewAuthorizer()
@@ -35,12 +38,13 @@ func NewService(
}
eeservice := &Service{
Service: svc,
ds: ds,
logger: logger,
config: config,
clock: c,
authz: authorizer,
Service: svc,
ds: ds,
logger: logger,
config: config,
clock: c,
authz: authorizer,
depStorage: depStorage,
}
// Override methods that can't be easily overriden via
+12
View File
@@ -12,3 +12,15 @@ type AppleMDM struct {
func (a AppleMDM) AuthzType() string {
return "mdm_apple"
}
type AppleBM struct {
AppleID string `json:"apple_id"`
OrgName string `json:"org_name"`
MDMServerURL string `json:"mdm_server_url"`
RenewDate time.Time `json:"renew_date"`
DefaultTeam string `json:"default_team"`
}
func (a AppleBM) AuthzType() string {
return "mdm_apple"
}
+1
View File
@@ -529,6 +529,7 @@ type Service interface {
// Apple MDM
GetAppleMDM(ctx context.Context) (*AppleMDM, error)
GetAppleBM(ctx context.Context) (*AppleBM, error)
// NewMDMAppleEnrollmentProfile creates and returns new enrollment profile.
// Such enrollment profiles allow devices to enroll to Fleet MDM.
+8
View File
@@ -9,3 +9,11 @@ func (c *Client) GetAppleMDM() (*fleet.AppleMDM, error) {
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "")
return responseBody.AppleMDM, err
}
// GetAppleBM retrieves the Apple Business Manager information.
func (c *Client) GetAppleBM() (*fleet.AppleBM, error) {
verb, path := "GET", "/api/latest/fleet/mdm/apple_bm"
var responseBody getAppleBMResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "")
return responseBody.AppleBM, err
}
+1
View File
@@ -420,6 +420,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{})
}
ue.GET("/api/_version_/fleet/mdm/apple", getAppleMDMEndpoint, nil)
ue.GET("/api/_version_/fleet/mdm/apple_bm", getAppleBMEndpoint, nil)
errorLimiter := ratelimit.NewErrorMiddleware(limitStore)
+6 -1
View File
@@ -4265,7 +4265,7 @@ func (s *integrationTestSuite) TestPacksBadRequests() {
}
}
func (s *integrationTestSuite) TestTeamsEndpointsWithoutLicense() {
func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
t := s.T()
// list teams, none
@@ -4321,6 +4321,11 @@ func (s *integrationTestSuite) TestTeamsEndpointsWithoutLicense() {
// modify team enroll secrets
s.DoJSON("PATCH", "/api/latest/fleet/teams/123/secrets", modifyTeamEnrollSecretsRequest{Secrets: []fleet.EnrollSecret{{Secret: "DEF"}}}, http.StatusPaymentRequired, &secResp)
assert.Len(t, secResp.Secrets, 0)
// get apple BM configuration
var appleBMResp getAppleBMResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple_bm", nil, http.StatusPaymentRequired, &appleBMResp)
assert.Nil(t, appleBMResp.AppleBM)
}
// TestGlobalPoliciesBrowsing tests that team users can browse (read) global policies (see #3722).
@@ -1653,6 +1653,13 @@ func (s *integrationEnterpriseTestSuite) TestListHosts() {
require.Nil(t, summaryResp.LowDiskSpaceCount)
}
func (s *integrationEnterpriseTestSuite) TestAppleMDMNotConfigured() {
var mdmResp getAppleMDMResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple", nil, http.StatusNotFound, &mdmResp)
var bmResp getAppleBMResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple_bm", nil, http.StatusNotFound, &bmResp)
}
func (s *integrationEnterpriseTestSuite) TestGlobalPolicyCreateReadPatch() {
fields := []string{"Query", "Name", "Description", "Resolution", "Platform", "Critical"}
+24
View File
@@ -48,3 +48,27 @@ func (svc *Service) GetAppleMDM(ctx context.Context) (*fleet.AppleMDM, error) {
return appleMDM, nil
}
type getAppleBMResponse struct {
*fleet.AppleBM
Err error `json:"error,omitempty"`
}
func (r getAppleBMResponse) error() error { return r.Err }
func getAppleBMEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) {
appleBM, err := svc.GetAppleBM(ctx)
if err != nil {
return getAppleBMResponse{Err: err}, nil
}
return getAppleBMResponse{AppleBM: appleBM}, nil
}
func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
+1 -1
View File
@@ -142,7 +142,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
panic(err)
}
if lic.IsPremium() {
svc, err = eeservice.NewService(svc, ds, kitlog.NewNopLogger(), fleetConfig, mailer, c)
svc, err = eeservice.NewService(svc, ds, kitlog.NewNopLogger(), fleetConfig, mailer, c, depStorage)
if err != nil {
panic(err)
}