Feat save certs (#19390)

for #10383

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Jahziel Villasana-Espinoza 2024-05-31 16:58:34 -04:00 committed by GitHub
commit a343eda885
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
153 changed files with 5230 additions and 1607 deletions

View file

@ -38,6 +38,5 @@
"prettier.requireConfig": true,
"yaml.schemas": {
"https://json.schemastore.org/codecov.json": ".github/workflows/codecov.yml"
},
"favorites.sortOrder": "ASC"
}
}

View file

@ -0,0 +1 @@
- Updated UI to support new workflows for macOS MDM setup and credentials.

View file

@ -0,0 +1,2 @@
- Adds a `GET /fleet/mdm/apple/request_csr` endpoint, which returns the signed APNS CSR needed to
activate Apple MDM.

1
changes/19179-bm Normal file
View file

@ -0,0 +1 @@
* Added new endpoints to configure ABM keypairs and tokens

2
changes/jve-pk-docs Normal file
View file

@ -0,0 +1,2 @@
- Updates the private key requirements to allow keys longer than 32 bytes
- Adds documentation around the new `FLEET_SERVER_PRIVATE_KEY` var

2
changes/post-apns-cert Normal file
View file

@ -0,0 +1,2 @@
- Adds 2 new endpoints: `POST` and `DELETE /fleet/mdm/apple/apns_certificate`. These endpoints let
users manage APNS certificates in Fleet.

View file

@ -0,0 +1,2 @@
- Adds a new Fleet server config variable, `FLEET_SERVER_PRIVATE_KEY`. This variable contains the
private key used to encrypt the MDM certificates and keys stored in Fleet.

View file

@ -20,6 +20,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/policies"
"github.com/fleetdm/fleet/v4/server/ptr"
@ -807,7 +808,7 @@ func newCleanupsAndAggregationSchedule(
schedule.WithJob(
"verify_disk_encryption_keys",
func(ctx context.Context) error {
return verifyDiskEncryptionKeys(ctx, logger, ds, config)
return verifyDiskEncryptionKeys(ctx, logger, ds)
},
),
schedule.WithJob(
@ -904,9 +905,15 @@ func verifyDiskEncryptionKeys(
ctx context.Context,
logger kitlog.Logger,
ds fleet.Datastore,
config *config.FleetConfig,
) error {
if !config.MDM.IsAppleSCEPSet() {
appCfg, err := ds.AppConfig(ctx)
if err != nil {
logger.Log("err", "unable to get app config", "details", err)
return ctxerr.Wrap(ctx, err, "fetching app config")
}
if !appCfg.MDM.EnabledAndConfigured {
logger.Log("inf", "skipping verification of macOS encryption keys as MDM is not fully configured")
return nil
}
@ -917,10 +924,10 @@ func verifyDiskEncryptionKeys(
return err
}
cert, _, _, err := config.MDM.AppleSCEP()
cert, err := assets.CAKeyPair(ctx, ds)
if err != nil {
logger.Log("err", "unable to get SCEP keypair to decrypt keys", "details", err)
return err
logger.Log("err", "unable to get CA keypair", "details", err)
return ctxerr.Wrap(ctx, err, "parsing SCEP keypair")
}
decryptable := []uint{}
@ -1013,11 +1020,24 @@ func newAppleMDMDEPProfileAssigner(
) (*schedule.Schedule, error) {
const name = string(fleet.CronAppleMDMDEPProfileAssigner)
logger = kitlog.With(logger, "cron", name, "component", "nanodep-syncer")
fleetSyncer := apple_mdm.NewDEPService(ds, depStorage, logger)
var fleetSyncer *apple_mdm.DEPService
s := schedule.New(
ctx, name, instanceID, periodicity, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob("dep_syncer", func(ctx context.Context) error {
appCfg, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "retrieving app config")
}
if !appCfg.MDM.AppleBMEnabledAndConfigured {
return nil
}
if fleetSyncer == nil {
fleetSyncer = apple_mdm.NewDEPService(ds, depStorage, logger)
}
return fleetSyncer.RunAssigner(ctx)
}),
)
@ -1031,8 +1051,6 @@ func newMDMProfileManager(
ds fleet.Datastore,
commander *apple_mdm.MDMAppleCommander,
logger kitlog.Logger,
loggingDebug bool,
cfg config.MDMConfig,
) (*schedule.Schedule, error) {
const (
name = string(fleet.CronMDMAppleProfileManager)
@ -1047,7 +1065,7 @@ func newMDMProfileManager(
ctx, name, instanceID, defaultInterval, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob("manage_apple_profiles", func(ctx context.Context) error {
return service.ReconcileAppleProfiles(ctx, ds, commander, logger, cfg)
return service.ReconcileAppleProfiles(ctx, ds, commander, logger)
}),
schedule.WithJob("manage_apple_declarations", func(ctx context.Context) error {
return service.ReconcileAppleDeclarations(ctx, ds, commander, logger)
@ -1196,6 +1214,16 @@ func newIPhoneIPadRefetcher(
ctx, name, instanceID, periodicity, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob("cron_iphone_ipad_refetcher", func(ctx context.Context) error {
appCfg, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "fetching app config")
}
if !appCfg.MDM.EnabledAndConfigured {
level.Debug(logger).Log("msg", "apple mdm is not configured, skipping run")
return nil
}
start := time.Now()
uuids, err := ds.ListIOSAndIPadOSToRefetch(ctx, 1*time.Hour)
if err != nil {

View file

@ -6,21 +6,20 @@ import (
"github.com/stretchr/testify/require"
"github.com/fleetdm/fleet/v4/server/config"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mock"
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
kitlog "github.com/go-kit/log"
)
func TestNewMDMProfileManagerWithoutConfig(t *testing.T) {
ctx := context.Background()
mdmStorage := &mock.MDMAppleStore{}
mdmStorage := &mdmmock.MDMAppleStore{}
ds := new(mock.Store)
mdmConfig := config.MDMConfig{}
cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, nil, mdmConfig)
cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, nil)
logger := kitlog.NewNopLogger()
sch, err := newMDMProfileManager(ctx, "foo", ds, cmdr, logger, false, mdmConfig)
sch, err := newMDMProfileManager(ctx, "foo", ds, cmdr, logger)
require.NotNil(t, sch)
require.NoError(t, err)
}

View file

@ -44,11 +44,9 @@ import (
"github.com/fleetdm/fleet/v4/server/mail"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/buford"
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
scep_depot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
"github.com/fleetdm/fleet/v4/server/pubsub"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/async"
@ -163,6 +161,14 @@ the way that the Fleet server works.
}
}
if len([]byte(config.Server.PrivateKey)) < 32 {
initFatal(errors.New("private key must be at least 32 bytes long"), "validate private key")
}
// We truncate to 32 bytes because AES-256 requires a 32 byte (256 bit) PK, but some
// infra setups generate keys that are longer than 32 bytes.
config.Server.PrivateKey = config.Server.PrivateKey[:32]
var ds fleet.Datastore
var carveStore fleet.CarveStore
var installerStore fleet.InstallerStore
@ -457,19 +463,29 @@ the way that the Fleet server works.
}
}
var (
scepStorage scep_depot.Depot
appleSCEPCertPEM []byte
appleSCEPKeyPEM []byte
appleAPNsCertPEM []byte
appleAPNsKeyPEM []byte
depStorage *mysql.NanoDEPStorage
mdmStorage *mysql.NanoMDMStorage
mdmPushService push.Pusher
mdmCheckinAndCommandService *service.MDMAppleCheckinAndCommandService
ddmService *service.MDMAppleDDMService
mdmPushCertTopic string
)
mdmStorage, err := mds.NewMDMAppleMDMStorage()
if err != nil {
initFatal(err, "initialize mdm apple MySQL storage")
}
depStorage, err := mds.NewMDMAppleDEPStorage()
if err != nil {
initFatal(err, "initialize Apple BM DEP storage")
}
scepStorage, err := mds.NewSCEPDepot()
if err != nil {
initFatal(err, "initialize mdm apple scep storage")
}
var mdmPushService push.Pusher
nanoMDMLogger := service.NewNanoMDMLogger(kitlog.With(logger, "component", "apple-mdm-push"))
pushProviderFactory := buford.NewPushProviderFactory()
if os.Getenv("FLEET_DEV_MDM_APPLE_DISABLE_PUSH") == "1" {
mdmPushService = nopPusher{}
} else {
mdmPushService = nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, nanoMDMLogger)
}
// validate Apple APNs/SCEP config
if config.MDM.IsAppleAPNsSet() || config.MDM.IsAppleSCEPSet() {
@ -483,14 +499,8 @@ the way that the Fleet server works.
if err != nil {
initFatal(err, "validate Apple APNs certificate and key")
}
appleAPNsCertPEM, appleAPNsKeyPEM = apnsCertPEM, apnsKeyPEM
mdmPushCertTopic, err = cryptoutil.TopicFromCert(apnsCert.Leaf)
if err != nil {
initFatal(err, "validate Apple APNs certificate: failed to get topic from certificate")
}
_, appleSCEPCertPEM, appleSCEPKeyPEM, err = config.MDM.AppleSCEP()
_, appleSCEPCertPEM, appleSCEPKeyPEM, err := config.MDM.AppleSCEP()
if err != nil {
initFatal(err, "validate Apple SCEP certificate and key")
}
@ -506,16 +516,25 @@ the way that the Fleet server works.
initFatal(err, "validate authentication with Apple APNs certificate")
}
cancel()
}
appCfg, err := ds.AppConfig(context.Background())
if err != nil {
initFatal(err, "loading app config")
err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
{Name: fleet.MDMAssetAPNSCert, Value: apnsCertPEM},
{Name: fleet.MDMAssetAPNSKey, Value: apnsKeyPEM},
{Name: fleet.MDMAssetCACert, Value: appleSCEPCertPEM},
{Name: fleet.MDMAssetCAKey, Value: appleSCEPKeyPEM},
})
if err != nil {
// duplicate key errors mean that we already
// have a value for those keys in the
// database, fail to initalize on other
// cases.
if !mysql.IsDuplicate(err) {
initFatal(err, "inserting MDM APNs and SCEP assets")
}
level.Warn(logger).Log("msg", "Your server already has stored SCEP and APNs certificates. Fleet will ignore any certificates provided via environment variables when this happens.")
}
}
// assume MDM is disabled until we verify that
// everything is properly configured below
appCfg.MDM.EnabledAndConfigured = false
appCfg.MDM.AppleBMEnabledAndConfigured = false
// validate Apple BM config
if config.MDM.IsAppleBMSet() {
@ -523,37 +542,62 @@ the way that the Fleet server works.
initFatal(errors.New("Apple Business Manager configuration is only available in Fleet Premium"), "validate Apple BM")
}
tok, err := config.MDM.AppleBM()
appleBM, err := config.MDM.AppleBM()
if err != nil {
initFatal(err, "validate Apple BM token, certificate and key")
}
depStorage, err = mds.NewMDMAppleDEPStorage(*tok)
err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
{Name: fleet.MDMAssetABMKey, Value: appleBM.KeyPEM},
{Name: fleet.MDMAssetABMCert, Value: appleBM.CertPEM},
{Name: fleet.MDMAssetABMToken, Value: appleBM.EncryptedToken},
})
if err != nil {
initFatal(err, "initialize Apple BM DEP storage")
// duplicate key errors mean that we already
// have a value for those keys in the
// database, fail to initalize on other
// cases.
if !mysql.IsDuplicate(err) {
initFatal(err, "inserting MDM ABM assets")
}
level.Warn(logger).Log("msg", "Your server already has stored ABM certificates and token. Fleet will ignore any certificates provided via environment variables when this happens.")
}
appCfg.MDM.AppleBMEnabledAndConfigured = true
}
if config.MDM.IsAppleAPNsSet() && config.MDM.IsAppleSCEPSet() {
scepStorage, err = mds.NewSCEPDepot(appleSCEPCertPEM, appleSCEPKeyPEM)
appCfg, err := ds.AppConfig(context.Background())
if err != nil {
initFatal(err, "loading app config")
}
checkMDMAssets := func(names []fleet.MDMAssetName) (bool, error) {
_, err = ds.GetAllMDMConfigAssetsByName(context.Background(), names)
if err != nil {
initFatal(err, "initialize mdm apple scep storage")
if fleet.IsNotFound(err) || errors.Is(err, mysql.ErrPartialResult) {
return false, nil
}
return false, err
}
mdmStorage, err = mds.NewMDMAppleMDMStorage(appleAPNsCertPEM, appleAPNsKeyPEM)
if err != nil {
initFatal(err, "initialize mdm apple MySQL storage")
}
nanoMDMLogger := service.NewNanoMDMLogger(kitlog.With(logger, "component", "apple-mdm-push"))
pushProviderFactory := buford.NewPushProviderFactory()
if os.Getenv("FLEET_DEV_MDM_APPLE_DISABLE_PUSH") == "1" {
mdmPushService = nopPusher{}
} else {
mdmPushService = nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, nanoMDMLogger)
}
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
mdmCheckinAndCommandService = service.NewMDMAppleCheckinAndCommandService(ds, commander, logger)
ddmService = service.NewMDMAppleDDMService(ds, logger)
appCfg.MDM.EnabledAndConfigured = true
return true, nil
}
appCfg.MDM.EnabledAndConfigured, err = checkMDMAssets([]fleet.MDMAssetName{
fleet.MDMAssetCACert,
fleet.MDMAssetCAKey,
fleet.MDMAssetAPNSKey,
fleet.MDMAssetAPNSCert,
})
if err != nil {
initFatal(err, "validating MDM assets from database")
}
appCfg.MDM.AppleBMEnabledAndConfigured, err = checkMDMAssets([]fleet.MDMAssetName{
fleet.MDMAssetABMCert,
fleet.MDMAssetABMKey,
fleet.MDMAssetABMToken,
})
if err != nil {
initFatal(err, "validating MDM ABM assets from database")
}
// register the Microsoft MDM services
@ -622,7 +666,6 @@ the way that the Fleet server works.
depStorage,
mdmStorage,
mdmPushService,
mdmPushCertTopic,
cronSchedules,
wstepCertManager,
)
@ -632,10 +675,7 @@ the way that the Fleet server works.
var softwareInstallStore fleet.SoftwareInstallerStore
if license.IsPremium() {
var profileMatcher fleet.ProfileMatcher
if appCfg.MDM.EnabledAndConfigured {
profileMatcher = apple_mdm.NewProfileMatcher(redisPool)
}
profileMatcher := apple_mdm.NewProfileMatcher(redisPool)
if config.S3.Bucket != "" {
store, err := s3.NewSoftwareInstallerStore(config.S3)
if err != nil {
@ -666,8 +706,7 @@ the way that the Fleet server works.
mailService,
clock.C,
depStorage,
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM),
mdmPushCertTopic,
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
ssoSessionStore,
profileMatcher,
softwareInstallStore,
@ -722,10 +761,7 @@ the way that the Fleet server works.
if err := cronSchedules.StartCronSchedule(
func() (fleet.CronSchedule, error) {
var commander *apple_mdm.MDMAppleCommander
if appCfg.MDM.EnabledAndConfigured {
commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
}
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
return newCleanupsAndAggregationSchedule(
ctx, instanceID, ds, logger, redisWrapperDS, &config, commander, softwareInstallStore,
)
@ -765,42 +801,33 @@ the way that the Fleet server works.
}
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
var commander *apple_mdm.MDMAppleCommander
if appCfg.MDM.EnabledAndConfigured {
commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
}
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander)
}); err != nil {
initFatal(err, "failed to register worker integrations schedule")
}
if license.IsPremium() && appCfg.MDM.EnabledAndConfigured && config.MDM.IsAppleBMSet() {
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
return newAppleMDMDEPProfileAssigner(ctx, instanceID, config.MDM.AppleDEPSyncPeriodicity, ds, depStorage, logger)
}); err != nil {
initFatal(err, "failed to register apple_mdm_dep_profile_assigner schedule")
}
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
return newAppleMDMDEPProfileAssigner(ctx, instanceID, config.MDM.AppleDEPSyncPeriodicity, ds, depStorage, logger)
}); err != nil {
initFatal(err, "failed to register apple_mdm_dep_profile_assigner schedule")
}
if appCfg.MDM.EnabledAndConfigured || appCfg.MDM.WindowsEnabledAndConfigured {
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
return newMDMProfileManager(
ctx,
instanceID,
ds,
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM),
logger,
config.Logging.Debug,
config.MDM,
)
}); err != nil {
initFatal(err, "failed to register mdm_apple_profile_manager schedule")
}
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
return newMDMProfileManager(
ctx,
instanceID,
ds,
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
logger,
)
}); err != nil {
initFatal(err, "failed to register mdm_apple_profile_manager schedule")
}
if license.IsPremium() && appCfg.MDM.EnabledAndConfigured {
if license.IsPremium() {
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
return newIPhoneIPadRefetcher(ctx, instanceID, 10*time.Minute, ds, commander, logger)
}); err != nil {
initFatal(err, "failed to register apple_mdm_iphone_ipad_refetcher schedule")
@ -919,18 +946,19 @@ the way that the Fleet server works.
rootMux.Handle("/version", service.PrometheusMetricsHandler("version", version.Handler()))
rootMux.Handle("/assets/", service.PrometheusMetricsHandler("static_assets", service.ServeStaticAssets("/assets/")))
if appCfg.MDM.EnabledAndConfigured {
if err := service.RegisterAppleMDMProtocolServices(
rootMux,
config.MDM,
mdmStorage,
scepStorage,
logger,
mdmCheckinAndCommandService,
ddmService,
); err != nil {
initFatal(err, "setup mdm apple services")
}
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
ddmService := service.NewMDMAppleDDMService(ds, logger)
mdmCheckinAndCommandService := service.NewMDMAppleCheckinAndCommandService(ds, commander, logger)
if err := service.RegisterAppleMDMProtocolServices(
rootMux,
config.MDM,
mdmStorage,
scepStorage,
logger,
mdmCheckinAndCommandService,
ddmService,
); err != nil {
initFatal(err, "setup mdm apple services")
}
if config.Prometheus.BasicAuth.Username != "" && config.Prometheus.BasicAuth.Password != "" {

View file

@ -21,7 +21,6 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
@ -1040,13 +1039,6 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) {
ctx := context.Background()
logger := log.NewNopLogger()
testBMToken := &nanodep_client.OAuth1Tokens{
ConsumerKey: "test_consumer",
ConsumerSecret: "test_secret",
AccessToken: "test_access_token",
AccessSecret: "test_access_secret",
AccessTokenExpiry: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC),
}
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
@ -1057,8 +1049,18 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) {
require.NoError(t, err)
base64EncryptedKey := base64.StdEncoding.EncodeToString(encryptedKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, testBMToken, "../../server/service/testdata")
assets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetCACert: {Value: testCertPEM},
fleet.MDMAssetCAKey: {Value: testKeyPEM},
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return assets, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
appCfg := fleet.AppConfig{}
appCfg.MDM.EnabledAndConfigured = true
return &appCfg, nil
}
now := time.Now()
@ -1087,7 +1089,7 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) {
return nil
}
err = verifyDiskEncryptionKeys(ctx, logger, ds, &fleetCfg)
err = verifyDiskEncryptionKeys(ctx, logger, ds)
require.NoError(t, err)
require.True(t, ds.GetUnverifiedDiskEncryptionKeysFuncInvoked)
require.True(t, ds.SetHostsDiskEncryptionKeyStatusFuncInvoked)
@ -1110,7 +1112,7 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) {
return nil
}
err = verifyDiskEncryptionKeys(ctx, logger, ds, &fleetCfg)
err = verifyDiskEncryptionKeys(ctx, logger, ds)
require.NoError(t, err)
require.True(t, ds.GetUnverifiedDiskEncryptionKeysFuncInvoked)
require.True(t, ds.SetHostsDiskEncryptionKeyStatusFuncInvoked)

View file

@ -21,6 +21,9 @@ import (
"github.com/urfave/cli/v2"
)
var ErrGeneric = errors.New(`Something's gone wrong. Please try again. If this keeps happening please file an issue:
https://github.com/fleetdm/fleet/issues/new/choose`)
func unauthenticatedClientFromCLI(c *cli.Context) (*service.Client, error) {
cc, err := clientConfigFromCLI(c)
if err != nil {

View file

@ -25,6 +25,7 @@ import (
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mock"
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/google/uuid"
@ -1109,7 +1110,7 @@ func mobileconfigForTest(name, identifier string) []byte {
}
func TestApplyAsGitOps(t *testing.T) {
enqueuer := new(mock.MDMAppleStore)
enqueuer := new(mdmmock.MDMAppleStore)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
// mdm test configuration must be set so that activating windows MDM works.
@ -1118,7 +1119,7 @@ func TestApplyAsGitOps(t *testing.T) {
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
_, ds := runServerWithMockedDS(t, &service.TestServerOpts{
License: license,

View file

@ -4,22 +4,18 @@ import (
"fmt"
"os"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/urfave/cli/v2"
)
const (
apnsKeyPath = "fleet-mdm-apple-apns.key"
scepCACertPath = "fleet-mdm-apple-scep.crt"
scepCAKeyPath = "fleet-mdm-apple-scep.key"
apnsCSRPath = "fleet-mdm-csr.csr"
bmPublicKeyCertPath = "fleet-apple-mdm-bm-public-key.crt"
bmPrivateKeyPath = "fleet-apple-mdm-bm-private.key"
)
func generateCommand() *cli.Command {
return &cli.Command{
Name: "generate",
Usage: "Generate certificates and keys required for MDM",
Usage: "Generate certificates and keys required for MDM.",
Flags: []cli.Flag{
configFlag(),
contextFlag(),
@ -36,91 +32,54 @@ func generateMDMAppleCommand() *cli.Command {
return &cli.Command{
Name: "mdm-apple",
Aliases: []string{"mdm_apple"},
Usage: "Generates certificate signing request (CSR) and key for Apple Push Notification Service (APNs) and certificate and key for Simple Certificate Enrollment Protocol (SCEP) to turn on MDM features.",
Usage: "Generates certificate signing request (CSR) to turn on MDM features.",
Flags: []cli.Flag{
contextFlag(),
debugFlag(),
&cli.StringFlag{
Name: "email",
Usage: "The email address to send the signed APNS csr to.",
Required: true,
},
&cli.StringFlag{
Name: "org",
Usage: "The organization requesting the signed APNS csr.",
Required: true,
},
&cli.StringFlag{
Name: "apns-key",
Usage: "The output path for the APNs private key.",
Value: apnsKeyPath,
},
&cli.StringFlag{
Name: "scep-cert",
Usage: "The output path for the SCEP CA certificate.",
Value: scepCACertPath,
},
&cli.StringFlag{
Name: "scep-key",
Usage: "The output path for the SCEP CA private key.",
Value: scepCAKeyPath,
Name: "csr",
Usage: "The output path for the APNs CSR.",
Value: apnsCSRPath,
},
},
Action: func(c *cli.Context) error {
email := c.String("email")
org := c.String("org")
apnsKeyPath := c.String("apns-key")
scepCACertPath := c.String("scep-cert")
scepCAKeyPath := c.String("scep-key")
csrPath := c.String("csr")
// get the fleet API client first, so that any login requirement are met
// before printing the CSR output message.
client, err := clientFromCLI(c)
if err != nil {
return err
fmt.Fprintf(c.App.ErrWriter, "client from CLI: %s", err)
return ErrGeneric
}
fmt.Fprintf(
c.App.Writer,
`Sending certificate signing request (CSR) for Apple Push Notification service (APNs) to %s...
Generating APNs key, Simple Certificate Enrollment Protocol (SCEP) certificate, and SCEP key...
`,
email,
)
csr, err := client.RequestAppleCSR(email, org)
csr, err := client.RequestAppleCSR()
if err != nil {
return err
fmt.Fprintf(c.App.ErrWriter, "requesting APNs CSR: %s", err)
return ErrGeneric
}
if err := os.WriteFile(apnsKeyPath, csr.APNsKey, defaultFileMode); err != nil {
return fmt.Errorf("failed to write APNs private key: %w", err)
if err := os.WriteFile(csrPath, csr, defaultFileMode); err != nil {
fmt.Fprintf(c.App.ErrWriter, "write CSR: %s", err)
return ErrGeneric
}
if err := os.WriteFile(scepCACertPath, csr.SCEPCert, defaultFileMode); err != nil {
return fmt.Errorf("failed to write SCEP CA certificate: %w", err)
}
if err := os.WriteFile(scepCAKeyPath, csr.SCEPKey, defaultFileMode); err != nil {
return fmt.Errorf("failed to write SCEP CA private key: %w", err)
appCfg, err := client.GetAppConfig()
if err != nil {
fmt.Fprintf(c.App.ErrWriter, "fetching app config: %s", err)
return ErrGeneric
}
fmt.Fprintf(
c.App.Writer,
`Success!
Generated your APNs key at %s
Generated your certificate signing request (CSR) at %s
Generated your SCEP certificate at %s
Generated your SCEP key at %s
Go to your email to download a CSR from Fleet. Then, visit https://identity.apple.com/pushcert to upload the CSR. You should receive an APNs certificate in return from Apple.
Next, use the generated certificates to deploy Fleet with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm
Go to %s/settings/integrations/mdm/apple and follow the steps.
`,
apnsKeyPath,
scepCACertPath,
scepCAKeyPath,
csrPath,
appCfg.ServerSettings.ServerURL,
)
return nil
@ -132,7 +91,7 @@ func generateMDMAppleBMCommand() *cli.Command {
return &cli.Command{
Name: "mdm-apple-bm",
Aliases: []string{"mdm_apple_bm"},
Usage: "Generate Apple Business Manager public and private keys to enable automatic enrollment for macOS hosts.",
Usage: "Generate Apple Business Manager public key to enable automatic enrollment for macOS hosts.",
Flags: []cli.Flag{
contextFlag(),
debugFlag(),
@ -141,27 +100,33 @@ func generateMDMAppleBMCommand() *cli.Command {
Usage: "The output path for the Apple Business Manager public key certificate.",
Value: bmPublicKeyCertPath,
},
&cli.StringFlag{
Name: "private-key",
Usage: "The output path for the Apple Business Manager private key.",
Value: bmPrivateKeyPath,
},
},
Action: func(c *cli.Context) error {
publicKeyPath := c.String("public-key")
privateKeyPath := c.String("private-key")
publicKeyPEM, privateKeyPEM, err := apple_mdm.NewDEPKeyPairPEM()
// get the fleet API client first, so that any login requirement are met
// before printing the CSR output message.
client, err := clientFromCLI(c)
if err != nil {
return fmt.Errorf("generate key pair: %w", err)
fmt.Fprintf(c.App.ErrWriter, "client from CLI: %s", err)
return ErrGeneric
}
if err := os.WriteFile(publicKeyPath, publicKeyPEM, defaultFileMode); err != nil {
return fmt.Errorf("write public key: %w", err)
publicKey, err := client.RequestAppleABM()
if err != nil {
fmt.Fprintf(c.App.ErrWriter, "requesting ABM public key: %s", err)
return ErrGeneric
}
if err := os.WriteFile(privateKeyPath, privateKeyPEM, defaultFileMode); err != nil {
return fmt.Errorf("write private key: %w", err)
if err := os.WriteFile(publicKeyPath, publicKey, defaultFileMode); err != nil {
fmt.Fprintf(c.App.ErrWriter, "write public key: %s", err)
return ErrGeneric
}
appCfg, err := client.GetAppConfig()
if err != nil {
fmt.Fprintf(c.App.ErrWriter, "fetching app config: %s", err)
return ErrGeneric
}
fmt.Fprintf(
@ -170,14 +135,11 @@ func generateMDMAppleBMCommand() *cli.Command {
Generated your public key at %s
Generated your private key at %s
Go to %s/settings/integrations/automatic-enrollment/apple and follow the steps.
Visit https://business.apple.com/ and create a new MDM server with the public key. Then, download the new MDM server's token.
Next, deploy Fleet with with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm
`,
publicKeyPath,
privateKeyPath,
appCfg.ServerSettings.ServerURL,
)
return nil

View file

@ -1,8 +1,8 @@
package main
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
@ -14,37 +14,33 @@ import (
)
func TestGenerateMDMAppleBM(t *testing.T) {
// TODO(roberto): update when the new endpoint to get a CSR is ready
t.Skip()
outdir, err := os.MkdirTemp("", t.Name())
require.NoError(t, err)
defer os.Remove(outdir)
publicKeyPath := filepath.Join(outdir, "public-key.crt")
privateKeyPath := filepath.Join(outdir, "private-key.key")
out := runAppForTest(t, []string{
"generate", "mdm-apple-bm",
"--public-key", publicKeyPath,
"--private-key", privateKeyPath,
})
require.Contains(t, out, fmt.Sprintf("Generated your public key at %s", outdir))
require.Contains(t, out, fmt.Sprintf("Generated your private key at %s", outdir))
// validate that the keypair is valid
cert, err := tls.LoadX509KeyPair(publicKeyPath, privateKeyPath)
// validate that the certificate is valid
certPEMBlock, err := os.ReadFile(publicKeyPath)
require.NoError(t, err)
parsed, err := x509.ParseCertificate(cert.Certificate[0])
parsed, err := x509.ParseCertificate(certPEMBlock)
require.NoError(t, err)
require.Equal(t, "FleetDM", parsed.Issuer.CommonName)
}
func TestGenerateMDMApple(t *testing.T) {
t.Run("missing input", func(t *testing.T) {
runAppCheckErr(t, []string{"generate", "mdm-apple"}, `Required flags "email, org" not set`)
runAppCheckErr(t, []string{"generate", "mdm-apple", "--email", "user@example.com"}, `Required flag "org" not set`)
runAppCheckErr(t, []string{"generate", "mdm-apple", "--org", "Acme"}, `Required flag "email" not set`)
})
t.Run("CSR API call fails", func(t *testing.T) {
// TODO(roberto): update when the new endpoint to get a CSR is ready
t.Skip()
_, _ = runServerWithMockedDS(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// fail this call
@ -57,14 +53,14 @@ func TestGenerateMDMApple(t *testing.T) {
t,
[]string{
"generate", "mdm-apple",
"--email", "user@example.com",
"--org", "Acme",
},
`POST /api/latest/fleet/mdm/apple/request_csr received status 422 Validation Failed: this email address is not valid: bad request`,
)
})
t.Run("successful run", func(t *testing.T) {
// TODO(roberto): update when the new endpoint to get a CSR is ready
t.Skip()
_, _ = runServerWithMockedDS(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@ -76,29 +72,24 @@ func TestGenerateMDMApple(t *testing.T) {
outdir, err := os.MkdirTemp("", "TestGenerateMDMApple")
require.NoError(t, err)
defer os.Remove(outdir)
apnsKeyPath := filepath.Join(outdir, "apns.key")
scepCertPath := filepath.Join(outdir, "scep.crt")
scepKeyPath := filepath.Join(outdir, "scep.key")
csrPath := filepath.Join(outdir, "csr.csr")
out := runAppForTest(t, []string{
"generate", "mdm-apple",
"--email", "user@example.com",
"--org", "Acme",
"--apns-key", apnsKeyPath,
"--scep-cert", scepCertPath,
"--scep-key", scepKeyPath,
"--csr", csrPath,
"--debug",
"--context", "default",
})
require.Contains(t, out, fmt.Sprintf("Generated your APNs key at %s", apnsKeyPath))
require.Contains(t, out, fmt.Sprintf("Generated your SCEP certificate at %s", scepCertPath))
require.Contains(t, out, fmt.Sprintf("Generated your SCEP key at %s", scepKeyPath))
require.Contains(t, out, fmt.Sprintf("Generated your SCEP key at %s", csrPath))
// validate that the keypair is valid
scepCrt, err := tls.LoadX509KeyPair(scepCertPath, scepKeyPath)
// validate that the CSR is valid
csrPEM, err := os.ReadFile(csrPath)
require.NoError(t, err)
parsed, err := x509.ParseCertificate(scepCrt.Certificate[0])
block, _ := pem.Decode(csrPEM)
require.NotNil(t, block)
require.Equal(t, "CERTIFICATE REQUEST", block.Type)
_, err = x509.ParseCertificateRequest(block.Bytes)
require.NoError(t, err)
require.Equal(t, "FleetDM", parsed.Issuer.CommonName)
})
}

View file

@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@ -20,6 +22,8 @@ import (
"github.com/fleetdm/fleet/v4/pkg/spec"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/stretchr/testify/assert"
@ -1996,13 +2000,35 @@ func TestGetAppleMDM(t *testing.T) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
// 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)
out := runAppForTest(t, []string{"get", "mdm_apple"})
assert.Contains(t, out, "Common name (CN):")
assert.Contains(t, out, "Serial number:")
assert.Contains(t, out, "Issuer:")
}
func TestGetAppleBM(t *testing.T) {
depStorage := new(nanodep_mock.Storage)
depSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
case "/account":
_, _ = w.Write([]byte(`{"admin_id": "abc", "org_name": "test_org"}`))
}
}))
t.Cleanup(depSrv.Close)
depStorage.RetrieveConfigFunc = func(p0 context.Context, p1 string) (*nanodep_client.Config, error) {
return &nanodep_client.Config{BaseURL: depSrv.URL}, nil
}
depStorage.RetrieveAuthTokensFunc = func(ctx context.Context, name string) (*nanodep_client.OAuth1Tokens, error) {
return &nanodep_client.OAuth1Tokens{}, nil
}
depStorage.StoreAssignerProfileFunc = func(ctx context.Context, name string, profileUUID string) error {
return nil
}
t.Run("free license", func(t *testing.T) {
runServerWithMockedDS(t)
@ -2013,10 +2039,14 @@ func TestGetAppleBM(t *testing.T) {
})
t.Run("premium license", func(t *testing.T) {
runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage})
expected := `No Apple Business Manager server token found`
assert.Contains(t, runAppForTest(t, []string{"get", "mdm_apple_bm"}), expected)
out := runAppForTest(t, []string{"get", "mdm_apple_bm"})
assert.Contains(t, out, "Apple ID:")
assert.Contains(t, out, "Organization name:")
assert.Contains(t, out, "MDM server URL:")
assert.Contains(t, out, "Renew date:")
assert.Contains(t, out, "Default team:")
})
}

View file

@ -51,14 +51,22 @@ func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() {
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, testBMToken, "../../server/service/testdata")
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
fleetCfg.Osquery.EnrollCooldown = 0
mdmStorage, err := s.ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
err = s.ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
{Name: fleet.MDMAssetAPNSCert, Value: testCertPEM},
{Name: fleet.MDMAssetAPNSKey, Value: testKeyPEM},
{Name: fleet.MDMAssetCACert, Value: testCertPEM},
{Name: fleet.MDMAssetCAKey, Value: testKeyPEM},
})
require.NoError(s.T(), err)
depStorage, err := s.ds.NewMDMAppleDEPStorage(*testBMToken)
mdmStorage, err := s.ds.NewMDMAppleMDMStorage()
require.NoError(s.T(), err)
scepStorage, err := s.ds.NewSCEPDepot(testCertPEM, testKeyPEM)
depStorage, err := s.ds.NewMDMAppleDEPStorage()
require.NoError(s.T(), err)
scepStorage, err := s.ds.NewSCEPDepot()
require.NoError(s.T(), err)
redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)

View file

@ -47,14 +47,14 @@ func (s *integrationGitopsTestSuite) SetupSuite() {
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, testBMToken, "../../server/service/testdata")
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
fleetCfg.Osquery.EnrollCooldown = 0
mdmStorage, err := s.ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
mdmStorage, err := s.ds.NewMDMAppleMDMStorage()
require.NoError(s.T(), err)
depStorage, err := s.ds.NewMDMAppleDEPStorage(*testBMToken)
depStorage, err := s.ds.NewMDMAppleDEPStorage()
require.NoError(s.T(), err)
scepStorage, err := s.ds.NewSCEPDepot(testCertPEM, testKeyPEM)
scepStorage, err := s.ds.NewSCEPDepot()
require.NoError(s.T(), err)
redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)

View file

@ -13,10 +13,12 @@ import (
"time"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mock"
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
@ -265,12 +267,12 @@ func TestFullGlobalGitOps(t *testing.T) {
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
// License is not needed because we are not using any premium features in our config.
_, ds := runServerWithMockedDS(
t, &service.TestServerOpts{
MDMStorage: new(mock.MDMAppleStore),
MDMStorage: new(mdmmock.MDMAppleStore),
MDMPusher: mockPusher{},
FleetConfig: &fleetCfg,
},
@ -432,13 +434,13 @@ func TestFullTeamGitOps(t *testing.T) {
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
// License is not needed because we are not using any premium features in our config.
_, ds := runServerWithMockedDS(
t, &service.TestServerOpts{
License: license,
MDMStorage: new(mock.MDMAppleStore),
MDMStorage: new(mdmmock.MDMAppleStore),
MDMPusher: mockPusher{},
FleetConfig: &fleetCfg,
NoCacheDatastore: true,
@ -942,11 +944,27 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) {
return *savedTeamPtr, nil
}
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
require.NoError(t, err)
crt, key, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
scepCert := tokenpki.PEMCertificate(crt.Raw)
scepKey := tokenpki.PEMRSAPrivateKey(key)
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetCACert: {Value: scepCert},
fleet.MDMAssetCAKey: {Value: scepKey},
fleet.MDMAssetAPNSKey: {Value: apnsKey},
fleet.MDMAssetAPNSCert: {Value: apnsCert},
}, nil
}
globalFile := "./testdata/gitops/global_config_no_paths.yml"
teamFile := "./testdata/gitops/team_config_no_paths.yml"
// Dry run on global file should fail because Apple BM Default Team does not exist (and has not been provided)
_, err := runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"})
_, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "team name not found"))
@ -1032,12 +1050,12 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata")
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
_, ds := runServerWithMockedDS(
t, &service.TestServerOpts{
MDMStorage: new(mock.MDMAppleStore),
MDMStorage: new(mdmmock.MDMAppleStore),
MDMPusher: mockPusher{},
FleetConfig: &fleetCfg,
License: license,

View file

@ -12,6 +12,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
"github.com/fleetdm/fleet/v4/server/mock"
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/google/uuid"
@ -158,7 +159,7 @@ func TestMDMRunCommand(t *testing.T) {
for _, lic := range []string{fleet.TierFree, fleet.TierPremium} {
t.Run(lic, func(t *testing.T) {
enqueuer := new(mock.MDMAppleStore)
enqueuer := new(mdmmock.MDMAppleStore)
license := &fleet.LicenseInfo{Tier: lic, Expiration: time.Now().Add(24 * time.Hour)}
_, ds := runServerWithMockedDS(t, &service.TestServerOpts{
@ -1203,7 +1204,7 @@ func writeTmpMobileconfig(t *testing.T, name string) string {
// sets up the test server with the mock datastore and returns the mock datastore
func setupTestServer(t *testing.T) *mock.Store {
enqueuer := new(mock.MDMAppleStore)
enqueuer := new(mdmmock.MDMAppleStore)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
enqueuer.EnqueueDeviceLockCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error {

View file

@ -2,7 +2,9 @@ package main
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"io"
@ -206,6 +208,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st
for _, item := range []string{
filepath.Join(previewDir, "logs"),
filepath.Join(previewDir, "vulndb"),
filepath.Join(previewDir, "config"),
} {
if err := os.MkdirAll(item, 0o777); err != nil {
return fmt.Errorf("create directory %q: %w", item, err)
@ -215,6 +218,48 @@ Use the stop and reset subcommands to manage the server and dependencies once st
}
}
generatePrivateKey := func(n int) (string, error) {
bytes := make([]byte, n/2)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes)[:n], nil
}
// Create a random private key for MDM asset encryption and save it to the filesystem
// for use in subsequent runs. If one already exists, use that one.
getPrivateKey := func() (string, error) {
pkFilename := filepath.Join(previewDir, "config", ".private_key")
filePK, err := os.ReadFile(pkFilename)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
genPK, err := generatePrivateKey(32) // use AES-256
if err != nil {
return "", fmt.Errorf("generating private key: %w", err)
}
if err := os.WriteFile(pkFilename, []byte(genPK), 0o777); err != nil {
return "", fmt.Errorf("writing private key file: %w", err)
}
return genPK, nil
}
return "", fmt.Errorf("reading private key file: %w", err)
}
return string(filePK), nil
}
pk, err := getPrivateKey()
if err != nil {
return fmt.Errorf("getting private key: %w", err)
}
if err := os.Setenv("FLEET_SERVER_PRIVATE_KEY", pk); err != nil {
return fmt.Errorf("failed to set private key: %w", err)
}
if err := os.Setenv("FLEET_VERSION", c.String(tagFlagName)); err != nil {
return fmt.Errorf("failed to set Fleet version: %w", err)
}

View file

@ -16,7 +16,6 @@ import (
"github.com/fleetdm/fleet/v4/server/datastore/cached_mysql"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
nanodepClient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/test"
@ -87,14 +86,6 @@ func (ts *withServer) getTestToken(email string, password string) string {
return jsn.Token
}
var testBMToken = &nanodepClient.OAuth1Tokens{
ConsumerKey: "test_consumer",
ConsumerSecret: "test_secret",
AccessToken: "test_access_token",
AccessSecret: "test_access_secret",
AccessTokenExpiry: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC),
}
// runServerWithMockedDS runs the fleet server with several mocked DS methods.
//
// NOTE: Assumes the current session is always from the admin user (see ds.SessionByKeyFunc below).
@ -130,6 +121,32 @@ func runServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
require.NoError(t, err)
certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t)
require.NoError(t, err)
ds.GetAllMDMConfigAssetsHashesFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
return map[fleet.MDMAssetName]string{
fleet.MDMAssetABMCert: "abmcert",
fleet.MDMAssetABMKey: "abmkey",
fleet.MDMAssetABMToken: "abmtoken",
fleet.MDMAssetAPNSCert: "apnscert",
fleet.MDMAssetAPNSKey: "apnskey",
fleet.MDMAssetCACert: "scepcert",
fleet.MDMAssetCAKey: "scepkey",
}, nil
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM},
fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM},
fleet.MDMAssetABMToken: {Name: fleet.MDMAssetABMToken, Value: tokenBytes},
fleet.MDMAssetAPNSCert: {Name: fleet.MDMAssetAPNSCert, Value: apnsCert},
fleet.MDMAssetAPNSKey: {Name: fleet.MDMAssetAPNSKey, Value: apnsKey},
fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: certPEM},
fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: keyPEM},
}, nil
}
var cachedDS fleet.Datastore
if len(opts) > 0 && opts[0].NoCacheDatastore {

View file

@ -12,6 +12,7 @@ import (
)
func TestVulnerabilityDataStream(t *testing.T) {
t.Skip("TODO: removeme before merging the feature branch")
nettest.Run(t)
runAppCheckErr(t, []string{"vulnerability-data-stream"}, "No directory provided")

View file

@ -678,6 +678,23 @@ Setting to true will disable the origin check.
websockets_allow_unsafe_origin: true
```
##### server_private_key
The private key used to encrypt sensitive data in Fleet, for example, MDM certificates and keys.
The key must be at least 32 bytes long. If the key is longer than 32 bytes, only the first 32 bytes
will be used (the data is encrypted using AES-256, which requires a 32 byte key). This key is
required for enabling MDM features in Fleet. If you are using the `FLEET_APPLE_APNS_*` and
`FLEET_APPLE_SCEP_*` variables, Fleet will automatically encrypt the values of those variables using
`FLEET_SERVER_PRIVATE_KEY` and save them in the database when you restart after updating.
- Default value: ""
- Environment variable: FLEET_SERVER_PRIVATE_KEY
- Config file format:
```yaml
server:
private_key: 72414F4A688151F75D032F5CDA095FC4
```
##### Example YAML
```yaml

View file

@ -20,16 +20,18 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
depclient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/fleetdm/fleet/v4/server/worker"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/go-kit/log/level"
"github.com/google/uuid"
)
@ -38,11 +40,6 @@ func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
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.AppConfigObfuscated(ctx)
if err != nil {
return nil, err
@ -51,8 +48,24 @@ func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
if err != nil {
return nil, err
}
tok, err := svc.config.MDM.AppleBM()
abmAssets, err := svc.ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{
fleet.MDMAssetABMKey,
fleet.MDMAssetABMCert,
fleet.MDMAssetABMToken,
})
if err != nil {
if errors.Is(err, mysql.ErrPartialResult) {
_, hasABMKey := abmAssets[fleet.MDMAssetABMKey]
_, hasABMCert := abmAssets[fleet.MDMAssetABMCert]
_, hasABMToken := abmAssets[fleet.MDMAssetABMToken]
// to preserve existing behavior, if the ABM setup is
// incomplete, return a not found error
if hasABMKey && hasABMCert && !hasABMToken {
return nil, notFoundError{}
}
}
return nil, err
}
@ -61,8 +74,13 @@ func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
return nil, err
}
token, err := assets.ABMToken(ctx, svc.ds)
if err != nil {
return nil, err
}
// fill the rest of the AppleBM fields
appleBM.RenewDate = tok.AccessTokenExpiry
appleBM.RenewDate = token.AccessTokenExpiry
appleBM.DefaultTeam = appCfg.MDM.AppleBMDefaultTeam
appleBM.MDMServerURL = mdmServerURL
@ -171,16 +189,16 @@ func (svc *Service) MDMListHostConfigurationProfiles(ctx context.Context, hostID
}
func (svc *Service) MDMAppleEnableFileVaultAndEscrow(ctx context.Context, teamID *uint) error {
cert, _, _, err := svc.config.MDM.AppleSCEP()
cert, err := assets.X509Cert(ctx, svc.ds, fleet.MDMAssetCACert)
if err != nil {
return ctxerr.Wrap(ctx, err, "enabling FileVault")
return ctxerr.Wrap(ctx, err, "retrieving CA cert")
}
var contents bytes.Buffer
params := fileVaultProfileOptions{
PayloadIdentifier: mobileconfig.FleetFileVaultPayloadIdentifier,
PayloadName: mdm.FleetFileVaultProfileName,
Base64DerCertificate: base64.StdEncoding.EncodeToString(cert.Leaf.Raw),
Base64DerCertificate: base64.StdEncoding.EncodeToString(cert.Raw),
}
if err := fileVaultProfileTemplate.Execute(&contents, params); err != nil {
return ctxerr.Wrap(ctx, err, "enabling FileVault")
@ -1154,11 +1172,16 @@ func (svc *Service) GetMDMManualEnrollmentProfile(ctx context.Context) ([]byte,
return nil, ctxerr.Wrap(ctx, err)
}
topic, err := assets.APNSTopic(ctx, svc.ds)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert")
}
mobileConfig, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
appConfig.OrgInfo.OrgName,
appConfig.ServerSettings.ServerURL,
svc.config.MDM.AppleSCEPChallenge,
svc.mdmPushCertTopic,
topic,
)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)

View file

@ -15,6 +15,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
nanodep_storage "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
@ -63,7 +64,6 @@ func setupMockDatastorePremiumService() (*mock.Store, *eeservice.Service, contex
depStorage,
nil,
nil,
"",
nil,
nil,
)
@ -79,7 +79,6 @@ func setupMockDatastorePremiumService() (*mock.Store, *eeservice.Service, contex
clock.C,
depStorage,
nil,
"",
nil,
nil,
nil,
@ -139,6 +138,7 @@ func TestGetOrCreatePreassignTeam(t *testing.T) {
ds.LabelIDsByNameFuncInvoked = false
ds.SetOrUpdateMDMAppleDeclarationFuncInvoked = false
ds.BulkSetPendingMDMHostProfilesFuncInvoked = false
ds.GetAllMDMConfigAssetsByNameFuncInvoked = false
}
setupDS := func(t *testing.T) {
resetInvoked()
@ -201,6 +201,21 @@ func TestGetOrCreatePreassignTeam(t *testing.T) {
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error {
return nil
}
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
require.NoError(t, err)
certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t)
require.NoError(t, err)
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM},
fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM},
fleet.MDMAssetABMToken: {Name: fleet.MDMAssetABMToken, Value: tokenBytes},
fleet.MDMAssetAPNSCert: {Name: fleet.MDMAssetAPNSCert, Value: apnsCert},
fleet.MDMAssetAPNSKey: {Name: fleet.MDMAssetAPNSKey, Value: apnsKey},
fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: certPEM},
fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: keyPEM},
}, nil
}
}
authzCtx := &authz_ctx.AuthorizationContext{}

View file

@ -6,7 +6,6 @@ import (
"strings"
"testing"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
@ -17,14 +16,18 @@ import (
func setup(t *testing.T) (*mock.Store, *Service) {
ds := new(mock.Store)
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetCACert: {Value: []byte(testCert)},
fleet.MDMAssetCAKey: {Value: []byte(testKey)},
fleet.MDMAssetAPNSKey: {Value: []byte(testKey)},
fleet.MDMAssetAPNSCert: {Value: []byte(testCert)},
}, nil
}
svc := &Service{
ds: ds,
config: config.FleetConfig{
MDM: config.MDMConfig{
AppleSCEPCertBytes: testCert,
AppleSCEPKeyBytes: testKey,
},
},
}
return ds, svc
}
@ -34,7 +37,10 @@ func TestMDMAppleEnableFileVaultAndEscrow(t *testing.T) {
t.Run("fails if SCEP is not configured", func(t *testing.T) {
ds := new(mock.Store)
svc := &Service{ds: ds, config: config.FleetConfig{}}
svc := &Service{ds: ds}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return nil, nil
}
err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, nil)
require.Error(t, err)
})

View file

@ -24,7 +24,6 @@ type Service struct {
authz *authz.Authorizer
depStorage storage.AllDEPStorage
mdmAppleCommander fleet.MDMAppleCommandIssuer
mdmPushCertTopic string
ssoSessionStore sso.SessionStore
depService *apple_mdm.DEPService
profileMatcher fleet.ProfileMatcher
@ -40,7 +39,6 @@ func NewService(
c clock.Clock,
depStorage storage.AllDEPStorage,
mdmAppleCommander fleet.MDMAppleCommandIssuer,
mdmPushCertTopic string,
sso sso.SessionStore,
profileMatcher fleet.ProfileMatcher,
softwareInstallStore fleet.SoftwareInstallerStore,
@ -59,7 +57,6 @@ func NewService(
authz: authorizer,
depStorage: depStorage,
mdmAppleCommander: mdmAppleCommander,
mdmPushCertTopic: mdmPushCertTopic,
ssoSessionStore: sso,
depService: apple_mdm.NewDEPService(ds, depStorage, logger),
profileMatcher: profileMatcher,

View file

@ -86,7 +86,7 @@ const App = ({ children, location }: IAppProps): JSX.Element => {
const abmInfo = await mdmAppleBMAPI.getAppleBMInfo();
setABMExpiry(abmInfo.renew_date);
}
if (configResponse.mdm.apple_bm_enabled_and_configured) {
if (configResponse.mdm.enabled_and_configured) {
const apnsInfo = await mdmAppleAPI.getAppleAPNInfo();
setAPNsExpiry(apnsInfo.renew_date);
}

View file

@ -4,8 +4,8 @@ import classnames from "classnames";
import Button from "components/buttons/Button";
import Card from "components/Card";
import { GraphicNames } from "components/graphics";
import Graphic from "components/Graphic";
import Icon from "components/Icon";
import Graphic from "components/Graphic";
const baseClass = "file-uploader";
@ -22,12 +22,37 @@ type ISupportedGraphicNames = Extract<
| "file-pem"
>;
export const FileDetails = ({
details: { name, platform },
graphicName = "file-pkg",
}: {
details: {
name: string;
platform?: string;
};
graphicName?: ISupportedGraphicNames;
}) => (
<div className={`${baseClass}__selected-file`}>
<Graphic name={graphicName} />
<div className={`${baseClass}__selected-file--details`}>
<div className={`${baseClass}__selected-file--details--name`}>{name}</div>
{platform && (
<div className={`${baseClass}__selected-file--details--platform`}>
{platform}
</div>
)}
</div>
</div>
);
interface IFileUploaderProps {
graphicName: ISupportedGraphicNames | ISupportedGraphicNames[];
message: string;
additionalInfo?: string;
/** Controls the loading spinner on the upload button */
isLoading?: boolean;
/** Disables the upload button */
diabled?: boolean;
/** A comma seperated string of one or more file types accepted to upload.
* This is the same as the html accept attribute.
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
@ -46,18 +71,19 @@ interface IFileUploaderProps {
/** If provided FileUploader will display this component when the file is
* selected. This is used for previewing the file before uploading.
*/
filePreview?: ReactNode;
filePreview?: ReactNode; // TODO: refactor this to be a function that returns a ReactNode?
onFileUpload: (files: FileList | null) => void;
}
/**
* A component that encapsulates the UI for uploading a file.
*/
const FileUploader = ({
export const FileUploader = ({
graphicName: graphicNames,
message,
additionalInfo,
isLoading = false,
diabled = false,
accept,
filePreview,
className,
@ -107,6 +133,7 @@ const FileUploader = ({
className={`${baseClass}__upload-button`}
variant={buttonVariant}
isLoading={isLoading}
disabled={diabled}
>
<label htmlFor="upload-file">
{buttonType === "link" && <Icon name="upload" />}

View file

@ -16,6 +16,26 @@
padding: $pad-medium $pad-large;
}
&__selected-file {
display: flex;
gap: $pad-medium;
align-items: center;
width: 100%;
text-align: left;
&--details {
&--name {
font-size: $x-small;
font-weight: $bold;
}
&--platform {
font-size: $xx-small;
color: $ui-fleet-black-75;
}
}
}
&__graphics {
display: flex;
align-items: center;

View file

@ -1,3 +1,5 @@
import { IConfigServerSettings } from "./config";
export interface IMdmApple {
common_name: string;
serial_number: string;
@ -13,6 +15,10 @@ export interface IMdmAppleBm {
renew_date: string;
}
export const getMdmServerUrl = ({ server_url }: IConfigServerSettings) => {
return server_url.concat("/mdm/apple/mdm");
};
export const MDM_ENROLLMENT_STATUS = {
"On (manual)": "manual",
"On (automatic)": "automatic",

View file

@ -4,15 +4,18 @@ import getInstallScript from "utilities/software_install_scripts";
import Spinner from "components/Spinner";
import Button from "components/buttons/Button";
import FileUploader from "components/FileUploader";
import Graphic from "components/Graphic";
import Editor from "components/Editor";
import FileUploader, {
FileDetails,
} from "components/FileUploader/FileUploader";
import { getFileDetails } from "utilities/file/fileUtils";
import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions";
import { generateFormValidation, getFileDetails } from "./helpers";
import { generateFormValidation } from "./helpers";
const baseClass = "add-software-form";
export const baseClass = "add-software-form";
const UploadingSoftware = () => {
return (
@ -23,28 +26,6 @@ const UploadingSoftware = () => {
);
};
// TODO: if we reuse this one more time, we should consider moving this
// into FileUploader as a default preview. Currently we have this in
// AddProfileModal.tsx and here.
const FileDetails = ({
details: { name, platform },
}: {
details: {
name: string;
platform: string;
};
}) => (
<div className={`${baseClass}__selected-file`}>
<Graphic name="file-pkg" />
<div className={`${baseClass}__selected-file--details`}>
<div className={`${baseClass}__selected-file--details--name`}>{name}</div>
<div className={`${baseClass}__selected-file--details--platform`}>
{platform}
</div>
</div>
</div>
);
export interface IAddSoftwareFormData {
software: File | null;
installScript: string;

View file

@ -1,5 +1,4 @@
.add-software-form {
&__uploading-message {
display: flex;
align-items: center;
@ -7,7 +6,7 @@
gap: $pad-large;
p {
margin: 0
margin: 0;
}
}
@ -20,24 +19,4 @@
&__file-uploader {
box-sizing: border-box;
}
&__selected-file {
display: flex;
gap: $pad-medium;
align-items: center;
width: 100%;
text-align: left;
&--details {
&--name {
font-size: $x-small;
font-weight: $bold;
}
&--platform {
font-size: $xx-small;
color: $ui-fleet-black-75;
}
}
}
}

View file

@ -2,7 +2,6 @@ import validator from "validator";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
import { getPlatformDisplayName } from "utilities/file/fileUtils";
import { IAddSoftwareFormData, IFormValidation } from "./AddSoftwareForm";
@ -155,9 +154,4 @@ export const generateFormValidation = (
return formValidation;
};
export const getFileDetails = (file: File) => {
return {
name: file.name,
platform: getPlatformDisplayName(file),
};
};
export default generateFormValidation;

View file

@ -0,0 +1,267 @@
import React, { useCallback, useContext, useState } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router";
import { AxiosError } from "axios";
import PATHS from "router/paths";
import { NotificationContext } from "context/notification";
import { getErrorReason } from "interfaces/errors";
import { IMdmAppleBm } from "interfaces/mdm";
import mdmAppleBmAPI from "services/entities/mdm_apple_bm";
import { readableDate } from "utilities/helpers";
import BackLink from "components/BackLink";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink/CustomLink";
import DataError from "components/DataError";
import FileUploader from "components/FileUploader";
import MainContent from "components/MainContent";
import Spinner from "components/Spinner";
import DownloadKey from "../../../../components/DownloadFileButtons/DownloadABMKey";
import DisableAutomaticEnrollmentModal from "./modals/DisableAutomaticEnrollmentModal";
import RenewTokenModal from "./modals/RenewTokenModal";
const baseClass = "apple-automatic-enrollment-page";
const AppleAutomaticEnrollmentPage = ({
router,
}: {
router: InjectedRouter;
}) => {
const { renderFlash } = useContext(NotificationContext);
const [isUploading, setIsUploading] = useState(false);
const [showDisableModal, setShowDisableModal] = useState(false);
const [showRenewModal, setShowRenewModal] = useState(false);
const {
data: mdmAppleBm,
error: errorMdmAppleBm,
isLoading,
isRefetching,
refetch,
} = useQuery<IMdmAppleBm, AxiosError, IMdmAppleBm>(
["mdmAppleBmAPI"],
() => mdmAppleBmAPI.getAppleBMInfo(),
{
refetchOnWindowFocus: false,
retry: (tries, error) => error.status !== 404 && tries <= 3,
}
);
const uploadToken = useCallback(
async (data: FileList | null) => {
setIsUploading(true);
const token = data?.[0];
if (!token) {
setIsUploading(false);
renderFlash("error", "No token selected.");
return;
}
try {
await mdmAppleBmAPI.uploadToken(token);
renderFlash(
"success",
"Automatic enrollment for macOS hosts is enabled."
);
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT);
} catch (e) {
const msg = getErrorReason(e);
if (msg.toLowerCase().includes("valid token")) {
renderFlash("error", msg);
} else {
renderFlash("error", "Couldnt enable. Please try again.");
}
} finally {
setIsUploading(false);
}
},
[renderFlash, router]
);
const onClickDisable = useCallback(() => {
setShowDisableModal(true);
}, []);
const onClickRenew = useCallback(() => {
setShowRenewModal(true);
}, []);
const disableAutomaticEnrollment = useCallback(async () => {
try {
await mdmAppleBmAPI.disableAutomaticEnrollment();
renderFlash("success", "Automatic enrollment disabled successfully.");
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT);
} catch (e) {
renderFlash(
"error",
"Couldnt disable automatic enrollment. Please try again."
);
setShowDisableModal(false);
}
}, [renderFlash, router]);
const onCancelDisable = useCallback(() => {
setShowDisableModal(false);
}, []);
const onRenew = useCallback(() => {
refetch();
setShowRenewModal(false);
}, [refetch]);
const onCancelRenew = useCallback(() => {
setShowRenewModal(false);
}, []);
if (isLoading || isRefetching) {
return <Spinner />;
}
if (errorMdmAppleBm && errorMdmAppleBm?.status !== 404) {
return (
<MainContent className={baseClass}>
<DataError />
</MainContent>
);
}
return (
<MainContent className={baseClass}>
<>
<BackLink
text="Back to automatic enrollment"
path={PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT}
className={`${baseClass}__back-to-automatic-enrollment`}
/>
<h1>Apple Business Manager (ABM)</h1>
{mdmAppleBm ? (
<div>
<h4>Apple ID</h4>
<p>{mdmAppleBm.apple_id}</p>
<h4>Organization name</h4>
<p>{mdmAppleBm.org_name}</p>
<h4>MDM server URL</h4>
<p>{mdmAppleBm.mdm_server_url}</p>
<h4>Renew date</h4>
<p>{readableDate(mdmAppleBm.renew_date)}</p>
<div className={`${baseClass}__button-wrap`}>
<Button variant="inverse" onClick={onClickDisable}>
Disable automatic enrollment
</Button>
<Button variant="brand" onClick={onClickRenew}>
Renew token
</Button>
</div>
</div>
) : (
<>
<p>
Connect Fleet to your Apple Business Manager account to
automatically enroll macOS hosts to Fleet when theyre first
booted.{" "}
</p>
{/* Ideally we'd use the native browser list styles and css to display
the list numbers but this does not allow us to style the list items as we'd
like so we write the numbers in the JSX instead. */}
<ol className={`${baseClass}__setup-list`}>
<li>
<span>1.</span>
<p>
Download your public key.{" "}
<DownloadKey baseClass={baseClass} />
</p>
</li>
<li>
<span>2.</span>
<span>
<span>
Sign in to{" "}
<CustomLink
newTab
text="Apple Business Manager"
url="https://business.apple.com"
/>
<br />
If your organization doesnt have an account, select{" "}
<b>Enroll now</b>.
</span>
</span>
</li>
<li>
<span>3.</span>
<span>
Select your <b>account name</b> at the bottom left of the
screen, then select <b>Preferences</b>.
</span>
</li>
<li>
<span>4.</span>
<span>
In the <b>Your MDM Servers</b> section, select <b>Add</b>.
</span>
</li>
<li>
<span>5.</span>
<span>Enter a name for the server such as Fleet.</span>
</li>
<li>
<span>6.</span>
<span>
Under <b>MDM Server Settings</b>, upload the public key
downloaded in the first step and select <b>Save</b>.
</span>
</li>
<li>
<span>7.</span>
<span>
In the <b>Default Device Assignment</b> section, select{" "}
<b>Change</b>, then assign the newly created server as the
default for your Macs, and select <b>Done</b>.
</span>
</li>
<li>
<span>8.</span>
<span>
Select newly created server in the sidebar, then select{" "}
<b>Download Token</b> on the top.
</span>
</li>
<li>
<span>9.</span>
<span>Upload the downloaded token (.p7m file).</span>
</li>
</ol>
<FileUploader
className={`${baseClass}__file-uploader ${
isUploading ? `${baseClass}__file-uploader--loading` : ""
}`}
accept=".p7m"
message="ABM token (.p7m)"
graphicName={"file-p7m"}
buttonType="link"
buttonMessage={isUploading ? "Uploading..." : "Upload"}
onFileUpload={uploadToken}
/>
</>
)}
</>
{showDisableModal && (
<DisableAutomaticEnrollmentModal
onCancel={onCancelDisable}
onConfirm={disableAutomaticEnrollment}
/>
)}
{showRenewModal && (
<RenewTokenModal onCancel={onCancelRenew} onRenew={onRenew} />
)}
</MainContent>
);
};
export default AppleAutomaticEnrollmentPage;

View file

@ -0,0 +1,105 @@
.apple-automatic-enrollment-page {
&__back-to-automatic-enrollment {
margin-bottom: $pad-xlarge;
}
h1 {
margin-bottom: $pad-xxlarge;
font-size: $large;
}
h4 {
margin-bottom: 0;
font-size: $x-small;
}
p {
font-size: $x-small;
margin: 0 0 $pad-large;
}
&__setup-list {
font-size: $x-small;
display: flex;
flex-direction: column;
gap: $pad-large;
padding: 0;
margin: 0;
max-width: 660px;
list-style: none;
li {
display: flex;
flex-direction: row;
gap: $pad-small;
p {
display: flex;
flex-direction: column;
align-items: flex-start;
margin: 0;
}
}
}
&__url-inputs-wrapper {
display: flex;
flex-direction: column;
gap: $pad-icon;
margin-top: $pad-large;
}
&__url-input {
margin-bottom: 0;
}
&__permissions-list {
margin-top: $pad-large;
list-style: disc;
display: flex;
flex-direction: column;
gap: $pad-medium;
}
&__request-button {
display: flex;
gap: $pad-small;
align-items: center;
margin-top: $pad-small;
label {
display: flex;
gap: $pad-small;
cursor: pointer;
}
}
&__file-uploader {
margin-top: $pad-medium;
margin-left: $pad-medium;
border-radius: 6px;
.file-uploader__message {
color: $ui-fleet-black-75;
margin: 0;
}
button {
margin-top: 0;
}
&--loading {
label {
opacity: 0.5;
}
}
}
&__button-wrap {
display: flex;
gap: $pad-medium;
}
.data-error {
margin-top: $pad-xxlarge;
}
}

View file

@ -0,0 +1 @@
export { default } from "./AppleAutomaticEnrollmentPage";

View file

@ -0,0 +1,57 @@
import React, { useCallback, useState } from "react";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
const baseClass = "modal disable-automatic-enrollment-modal";
interface IDisableAutomaticEnrollmentModalProps {
onCancel: () => void;
onConfirm: () => void;
}
const DisableAutomaticEnrollmentModal = ({
onConfirm,
onCancel,
}: IDisableAutomaticEnrollmentModalProps): JSX.Element => {
const [isDeleting, setIsDeleting] = useState(false);
const onClickConfirm = useCallback(() => {
setIsDeleting(true);
onConfirm();
}, [onConfirm]);
return (
<Modal
title="Disable macOS automatic enrollment"
onExit={onCancel}
className={baseClass}
>
<div className={baseClass}>
New macOS hosts wont automatically enroll to Fleet. If you want to
enable automatic enrollment, youll have to upload a new token.{" "}
<div className="modal-cta-wrap">
<Button
type="button"
variant="alert"
onClick={onClickConfirm}
disabled={isDeleting}
isLoading={isDeleting}
>
Disable
</Button>
<Button
onClick={onCancel}
disabled={isDeleting}
variant="inverse-alert"
>
Cancel
</Button>
</div>
</div>
</Modal>
);
};
export default DisableAutomaticEnrollmentModal;

View file

@ -0,0 +1,131 @@
import React, { useState, useContext, useCallback } from "react";
import { NotificationContext } from "context/notification";
import { getErrorReason } from "interfaces/errors";
import mdmAppleBmAPI from "services/entities/mdm_apple_bm";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import {
FileUploader,
FileDetails,
} from "components/FileUploader/FileUploader";
import Modal from "components/Modal";
const baseClass = "modal renew-token-modal";
interface IRenewCertModalProps {
onCancel: () => void;
onRenew: () => void;
}
const RenewCertModal = ({
onCancel,
onRenew,
}: IRenewCertModalProps): JSX.Element => {
const { renderFlash } = useContext(NotificationContext);
const [isUploading, setIsUploading] = useState(false);
const [tokenFile, setTokenFile] = useState<File | null>(null);
const onSelectFile = useCallback((files: FileList | null) => {
const file = files?.[0];
if (file) {
setTokenFile(file);
}
}, []);
const onRenewClick = useCallback(async () => {
if (!tokenFile) {
// this shouldn'r happen, but just in case
renderFlash("error", "Please provide a token file.");
return;
}
setIsUploading(true);
try {
await mdmAppleBmAPI.uploadToken(tokenFile);
renderFlash("success", "ABM token renewed successfully.");
setIsUploading(false);
onRenew();
} catch (e) {
const msg = getErrorReason(e);
if (msg.toLowerCase().includes("valid token")) {
renderFlash("error", msg);
} else {
renderFlash("error", "Couldnt renew. Please try again.");
}
setIsUploading(false);
}
}, [tokenFile, renderFlash, onRenew]);
return (
<Modal title="Renew token" onExit={onCancel} className={baseClass}>
<div className={`${baseClass}__page-content ${baseClass}__setup-content`}>
<ol className={`${baseClass}__setup-instructions-list`}>
<li>
<p>
1. Sign in to{" "}
<CustomLink
url="https://business.apple.com/"
text="Apple Business Manager"
newTab
/>
</p>
</li>
<li>
<p>
2. Select your <b>account name</b> at the bottom left of the
screen, then select <b>Preferences</b>.
</p>
</li>
<li>
<p>
3. In the <b>Your MDM Servers</b> section, select your Fleet
server, then select <b>Download Token</b> at the top.
</p>
</li>
<li>
<p>
4. Upload the downloaded token (.p7m file) below.
<FileUploader
className={`${baseClass}__file-uploader`}
accept=".p7m"
buttonMessage="Choose file"
buttonType="link"
graphicName="file-p7m"
message="ABM token (.p7m)"
onFileUpload={onSelectFile}
filePreview={
tokenFile && (
<FileDetails
details={{ name: tokenFile.name }}
graphicName="file-p7m"
/>
)
}
/>
</p>
</li>
</ol>
<div className={`${baseClass}__button-wrap`}>
<Button
className={`${baseClass}__submit-button ${
isUploading ? `uploading` : ""
}`}
variant="brand"
disabled={!tokenFile || isUploading}
isLoading={isUploading}
type="button"
onClick={onRenewClick}
>
Renew token
</Button>
</div>
</div>
</Modal>
);
};
export default RenewCertModal;

View file

@ -0,0 +1,69 @@
.renew-token-modal {
width: 760px;
&__info-header {
margin-bottom: $pad-xlarge;
}
&__setup-content {
display: flex;
flex-direction: column;
gap: $pad-large;
color: $core-fleet-black;
p {
margin: 0;
}
}
&__setup-instructions-list {
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $pad-large;
li > p {
margin: 0;
}
}
&__request-button {
display: flex;
gap: $pad-small;
align-items: center;
margin-top: $pad-small;
margin-left: $pad-medium;
label {
display: flex;
gap: $pad-small;
cursor: pointer;
}
}
&__file-uploader {
margin-top: $pad-medium;
margin-left: $pad-medium;
border-radius: 6px;
.file-uploader__message {
color: $ui-fleet-black-75;
margin: 0;
}
button {
margin-top: 0;
}
}
&__button-wrap {
display: flex;
justify-content: flex-end;
.renew-token-modal__submit-button.uploading.button--disabled {
opacity: 1;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./RenewTokenModal";

View file

@ -1,10 +1,11 @@
import React, { useContext } from "react";
import React, { useCallback, useContext, useState } from "react";
import { useQuery } from "react-query";
import { AxiosError } from "axios";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { IConfig } from "interfaces/config";
import { IMdmApple } from "interfaces/mdm";
import mdmAppleAPI from "services/entities/mdm_apple";
@ -13,9 +14,10 @@ import DataError from "components/DataError";
import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatureMessage";
import EmptyTable from "components/EmptyTable/EmptyTable";
import Button from "components/buttons/Button/Button";
import AppleBusinessManagerSection from "./components/AppleBusinessManagerSection/AppleBusinessManagerSection";
import IdpSection from "./components/IdpSection/IdpSection";
import MdmPlatformsSection from "./components/MdmPlatformsSection/MdmPlatformsSection";
import DefaultTeamSection from "./components/DefaultTeamSection/DefaultTeamSection";
import IdpSection from "./components/IdpSection/IdpSection";
import EulaSection from "./components/EulaSection/EulaSection";
const baseClass = "automatic-enrollment";
@ -27,7 +29,7 @@ interface IAutomaticEnrollment {
const AutomaticEnrollment = ({ router }: IAutomaticEnrollment) => {
const { config, isPremiumTier } = useContext(AppContext);
const { isLoading: isLoadingMdmApple, error: errorMdmApple } = useQuery<
const { isLoading: isLoadingAPNInfo, error: errorAPNInfo } = useQuery<
IMdmApple,
AxiosError
>(["appleAPNInfo"], () => mdmAppleAPI.getAppleAPNInfo(), {
@ -42,7 +44,7 @@ const AutomaticEnrollment = ({ router }: IAutomaticEnrollment) => {
if (!isPremiumTier) return <PremiumFeatureMessage />;
if (isLoadingMdmApple) {
if (isLoadingAPNInfo) {
return (
<div className={baseClass}>
<Spinner />
@ -50,7 +52,7 @@ const AutomaticEnrollment = ({ router }: IAutomaticEnrollment) => {
);
}
if (errorMdmApple?.status === 404) {
if (errorAPNInfo?.status === 404) {
return (
<EmptyTable
header="Automatic enrollment for macOS hosts"
@ -61,15 +63,20 @@ const AutomaticEnrollment = ({ router }: IAutomaticEnrollment) => {
);
}
if (errorMdmApple) {
if (errorAPNInfo) {
return <DataError />;
}
return (
<div className={baseClass}>
<div className={`${baseClass}__section`}>
<AppleBusinessManagerSection router={router} />
<MdmPlatformsSection router={router} />
</div>
{!!config?.mdm.apple_bm_enabled_and_configured && (
<div className={`${baseClass}__section`}>
<DefaultTeamSection />
</div>
)}
<div className={`${baseClass}__section`}>
<IdpSection />
</div>

View file

@ -3,7 +3,7 @@
margin-top: 0;
}
&__section:not(:last-child) {
margin-bottom: $pad-xxxlarge;
&__section {
margin-bottom: $pad-xxlarge;
}
}

View file

@ -1,251 +0,0 @@
import React, { useContext, useState } from "react";
import { useQuery } from "react-query";
import { AxiosError } from "axios";
import FileSaver from "file-saver";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { IMdmAppleBm } from "interfaces/mdm";
import mdmAppleBmAPI from "services/entities/mdm_apple_bm";
import { readableDate } from "utilities/helpers";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import TooltipWrapper from "components/TooltipWrapper";
import DataError from "components/DataError";
import Spinner from "components/Spinner/Spinner";
import SectionHeader from "components/SectionHeader";
import EditTeamModal from "../EditTeamModal";
import WindowsAutomaticEnrollmentCard from "./components/WindowsAutomaticEnrollmentCard/WindowsAutomaticEnrollmentCard";
const baseClass = "apple-business-manager-section";
interface IABMKeys {
decodedPublic: string;
decodedPrivate: string;
}
interface IAppleBusinessManagerSectionProps {
router: InjectedRouter;
}
const AppleBusinessManagerSection = ({
router,
}: IAppleBusinessManagerSectionProps) => {
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
const [defaultTeamName, setDefaultTeamName] = useState("No team");
const { renderFlash } = useContext(NotificationContext);
const { config } = useContext(AppContext);
const {
data: mdmAppleBm,
isLoading: isLoadingMdmAppleBm,
error: errorMdmAppleBm,
} = useQuery<IMdmAppleBm, AxiosError, IMdmAppleBm>(
["mdmAppleBmAPI"],
() => mdmAppleBmAPI.getAppleBMInfo(),
{
refetchOnWindowFocus: false,
retry: (tries, error) => error.status !== 404 && tries <= 3,
onSuccess: (appleBmData) => {
setDefaultTeamName(appleBmData.default_team ?? "No team");
},
}
);
const {
data: keys,
error: fetchKeysError,
isFetching: isFetchingKeys,
} = useQuery<IABMKeys, Error>(["keys"], () => mdmAppleBmAPI.loadKeys(), {
refetchOnWindowFocus: false,
retry: false,
});
const toggleEditTeamModal = () => {
setShowEditTeamModal(!showEditTeamModal);
};
const navigateToWindowsAutomaticEnrollment = () => {
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS);
};
const onDownloadKeys = (evt: React.MouseEvent) => {
evt.preventDefault();
// MDM TODO: Confirm error flash message
if (isFetchingKeys || fetchKeysError) {
renderFlash(
"error",
"Your MDM business manager keys could not be downloaded. Please try again."
);
return false;
}
if (keys) {
const publicFilename = "fleet-apple-mdm-bm-public-key.crt";
const publicFile = new global.window.File(
[keys.decodedPublic],
publicFilename,
{
type: "application/x-pem-file",
}
);
const privateFilename = "fleet-apple-mdm-bm-private.key";
const privateFile = new global.window.File(
[keys.decodedPrivate],
privateFilename,
{
type: "application/x-pem-file",
}
);
FileSaver.saveAs(publicFile);
setTimeout(() => {
FileSaver.saveAs(privateFile);
}, 100);
} else {
renderFlash(
"error",
"Your MDM business manager keys could not be downloaded. Please try again."
);
}
return false;
};
const renderAppleBMInfo = () => {
// we want to give a more useful error message for 400s.
if (errorMdmAppleBm && errorMdmAppleBm.status === 400) {
return (
<DataError>
<span className={`${baseClass}__400-error-info`}>
The Apple Business Manager certificate or server token is invalid.
Restart Fleet with a valid certificate and token.
</span>
<span className={`${baseClass}__400-error-info`}>
See our{" "}
<CustomLink
url="https://fleetdm.com/learn-more-about/setup-abm"
text="ABM documentation"
newTab
/>{" "}
for help.
</span>
</DataError>
);
}
// The API returns a 404 error if ABM is not configured yet, in that case we
// want to prompt the user to download the certs and keys to configure the
// server instead of the default error message.
const showMdmAppleBmError =
errorMdmAppleBm && errorMdmAppleBm.status !== 404;
if (showMdmAppleBmError) {
return <DataError />;
}
// no error, but no apple bm data yet. TODO: when does this happen?
if (!mdmAppleBm) {
return (
<>
<div className={`${baseClass}__section-description`}>
Connect Fleet to your Apple Business Manager account to
automatically enroll macOS hosts to Fleet when they&apos;re first
setup.
</div>
<div className={`${baseClass}__section-instructions`}>
<p>1. Download your public and private keys.</p>
<Button onClick={onDownloadKeys} variant="brand">
Download
</Button>
<p>
2. Sign in to{" "}
<CustomLink
url="https://business.apple.com/"
text="Apple Business Manager"
newTab
/>
<br />
If your organization doesn&apos;t have an account, select{" "}
<b>Enroll now</b>.
</p>
<p>
3. In Apple Business Manager, upload your public key and download
your server token.
</p>
<p>
4. Deploy Fleet with <b>mdm</b> configuration.{" "}
<CustomLink
url="https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm"
text="See how"
newTab
/>
</p>
</div>
</>
);
}
// we have the apple bm data and render it
return (
<>
<div className={`${baseClass}__section-description`}>
To use automatically enroll macOS hosts to Fleet when theyre first
unboxed, Apple Inc. requires a server token.
</div>
<div className={`${baseClass}__section-information`}>
<h4>
<TooltipWrapper tipContent="macOS hosts will be added to this team when theyre first unboxed.">
Team
</TooltipWrapper>
</h4>
<p>
{defaultTeamName}{" "}
<Button
className={`${baseClass}__edit-team-btn`}
onClick={toggleEditTeamModal}
variant="text-icon"
>
Edit <Icon name="pencil" />
</Button>
</p>
<h4>Apple ID</h4>
<p>{mdmAppleBm.apple_id}</p>
<h4>Organization name</h4>
<p>{mdmAppleBm.org_name}</p>
<h4>MDM server URL</h4>
<p>{mdmAppleBm.mdm_server_url}</p>
<h4>Renew date</h4>
<p>{readableDate(mdmAppleBm.renew_date)}</p>
</div>
</>
);
};
return (
<div className={baseClass}>
<SectionHeader title="Apple Business Manager" />
{isLoadingMdmAppleBm ? <Spinner /> : renderAppleBMInfo()}
<WindowsAutomaticEnrollmentCard
viewDetails={navigateToWindowsAutomaticEnrollment}
/>
{showEditTeamModal && (
<EditTeamModal
onCancel={toggleEditTeamModal}
defaultTeamName={defaultTeamName}
onUpdateSuccess={(newDefaultTeamName) =>
setDefaultTeamName(newDefaultTeamName)
}
/>
)}
</div>
);
};
export default AppleBusinessManagerSection;

View file

@ -1,40 +0,0 @@
.apple-business-manager-section {
h4 {
margin-bottom: 0;
}
.mdm-settings-team-btn {
margin-left: 12px;
.children-wrapper {
gap: $pad-small;
}
}
.component__tooltip-wrapper__tip-text {
max-width: initial;
}
&__section-description,
&__section-instructions,
&__section-information {
font-size: $x-small;
color: $core-fleet-black;
width: 100%;
}
&__section-information {
p {
margin: 0;
}
}
&__400-error-info {
display: block;
color: $core-fleet-black;
font-weight: normal;
font-size: $x-small;
text-align: left;
margin-top: 10px;
}
}

View file

@ -1,18 +0,0 @@
.windows-automatic-enrollment-card {
margin-top: $pad-xxxlarge;
font-size: $x-small;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
font-size: $x-small;
font-weight: $bold;
margin: 0 0 $pad-xsmall;
}
p {
margin: 0;
max-width: 520px;
}
}

View file

@ -1 +0,0 @@
export { default } from "./AppleBusinessManagerSection";

View file

@ -0,0 +1,55 @@
import React, { useCallback, useContext, useState } from "react";
import { AppContext } from "context/app";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import SectionHeader from "components/SectionHeader";
import TooltipWrapper from "components/TooltipWrapper";
import EditTeamModal from "../EditTeamModal";
const baseClass = "default-team-section";
const DefaultTeamSection = () => {
const { config } = useContext(AppContext);
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
const toggleEditTeamModal = useCallback(() => {
setShowEditTeamModal((prev) => !prev);
}, []);
const defaultTeamName = config?.mdm?.apple_bm_default_team || "No team";
return (
<div className={`${baseClass}`}>
<SectionHeader title="Default team" />
<p>macOS hosts automatically enroll to this team.</p>
<h4>
<TooltipWrapper
position="top-start"
tipContent="macOS hosts will be added to this team when theyre first unboxed."
>
Team
</TooltipWrapper>
</h4>
<p>
{config?.mdm?.apple_bm_default_team || "No team"}{" "}
<Button
className={`${baseClass}__edit-team-btn`}
onClick={toggleEditTeamModal}
variant="text-icon"
>
Edit <Icon name="pencil" />
</Button>
</p>
{showEditTeamModal && (
<EditTeamModal
defaultTeamName={defaultTeamName}
onCancel={toggleEditTeamModal}
/>
)}
</div>
);
};
export default DefaultTeamSection;

View file

@ -0,0 +1,16 @@
.default-team-section {
h4 {
margin: $pad-medium 0 0;
font-size: $x-small;
}
p {
display: flex;
align-items: center;
margin: 0;
}
&__edit-team-btn {
margin-left: $pad-medium;
}
}

View file

@ -0,0 +1 @@
export { default } from "./DefaultTeamSection";

View file

@ -1,6 +1,7 @@
import React, { useState, useContext, FormEvent } from "react";
import React, { useState, useContext, FormEvent, useCallback } from "react";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import {
APP_CONTEXT_NO_TEAM_ID,
APP_CONTEX_NO_TEAM_SUMMARY,
@ -15,7 +16,6 @@ import Button from "components/buttons/Button";
interface IEditTeamModal {
onCancel: () => void;
defaultTeamName: string;
onUpdateSuccess: (newName: string) => void;
}
const baseClass = "edit-team-modal";
@ -23,9 +23,9 @@ const baseClass = "edit-team-modal";
const EditTeamModal = ({
onCancel,
defaultTeamName,
onUpdateSuccess,
}: IEditTeamModal): JSX.Element => {
const { availableTeams } = useContext(AppContext);
const { availableTeams, setConfig } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const [selectedTeam, setSelectedTeam] = useState(defaultTeamName);
@ -43,19 +43,34 @@ const EditTeamModal = ({
const [isLoading, setIsLoading] = useState(false);
const onFormSubmit = async (event: FormEvent) => {
event.preventDefault();
try {
const handleUpdateTeam = useCallback(
async (newName: string) => {
try {
const configData = await configAPI.update({
mdm: { apple_bm_default_team: newName },
});
renderFlash("success", "Default team updated successfully.");
setConfig(configData);
} catch (e) {
renderFlash(
"error",
"Unable to update default team. Please try again."
);
} finally {
onCancel();
}
},
[renderFlash, setConfig, onCancel]
);
const onFormSubmit = useCallback(
(evt: FormEvent<HTMLFormElement>) => {
evt.preventDefault();
setIsLoading(true);
const configData = await configAPI.update({
mdm: { apple_bm_default_team: selectedTeam },
});
setIsLoading(false);
onUpdateSuccess(configData.mdm.apple_bm_default_team);
} finally {
onCancel();
}
};
handleUpdateTeam(selectedTeam);
},
[selectedTeam, setIsLoading, handleUpdateTeam]
);
return (
<Modal title="Edit team" onExit={onCancel} className={baseClass}>

View file

@ -0,0 +1,91 @@
import React, { useContext } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router";
import { AxiosError } from "axios";
import PATHS from "router/paths";
import { IMdmAppleBm } from "interfaces/mdm";
import mdmAppleBmAPI from "services/entities/mdm_apple_bm";
import { AppContext } from "context/app";
import DataError from "components/DataError";
import Spinner from "components/Spinner/Spinner";
import SectionHeader from "components/SectionHeader";
import WindowsAutomaticEnrollmentCard from "./components/WindowsAutomaticEnrollmentCard";
import AppleAutomaticEnrollmentCard from "./components/AppleAutomaticEnrollmentCard";
const baseClass = "mdm-platforms-section";
interface IMdmPlatformsSectionProps {
router: InjectedRouter;
}
const MdmPlatformsSection = ({ router }: IMdmPlatformsSectionProps) => {
const { config } = useContext(AppContext);
const {
data: mdmAppleBm,
isLoading: isLoadingMdmAppleBm,
error: errorMdmAppleBm,
} = useQuery<IMdmAppleBm, AxiosError, IMdmAppleBm>(
["mdmAppleBmAPI"],
() => mdmAppleBmAPI.getAppleBMInfo(),
{
refetchOnWindowFocus: false,
retry: (tries, error) => error.status !== 404 && tries <= 3,
}
);
const navigateToWindowsAutomaticEnrollment = () => {
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS);
};
const navigateToAppleAutomaticEnrollment = () => {
router.push(PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_APPLE);
};
const navigateToApplePushCertSetup = () => {
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
};
if (isLoadingMdmAppleBm) {
return (
<div className={baseClass}>
<Spinner />
</div>
);
}
const showMdmAppleBmError =
errorMdmAppleBm &&
// API returns a 404 error if ABM is not configured yet
errorMdmAppleBm.status !== 404 &&
// API returns a 400 error if ABM credentials are invalid
errorMdmAppleBm.status !== 400; // TODO: does this still signal expire/invalid credentials? do we need any special error handling? can anything else result in 400?
if (showMdmAppleBmError) {
return <DataError />;
}
return (
<div className={baseClass}>
<SectionHeader title="Apple Business Manager" />
<AppleAutomaticEnrollmentCard
viewDetails={navigateToAppleAutomaticEnrollment}
turnOn={
!config?.mdm.enabled_and_configured
? navigateToApplePushCertSetup
: undefined
}
configured={!!config?.mdm.apple_bm_enabled_and_configured}
/>
<WindowsAutomaticEnrollmentCard
viewDetails={navigateToWindowsAutomaticEnrollment}
/>
</div>
);
};
export default MdmPlatformsSection;

View file

@ -0,0 +1,80 @@
.mdm-platforms-section {
h4 {
margin-bottom: 0;
}
.section-header {
margin-bottom: $pad-xxlarge;
}
.mdm-settings-team-btn {
margin-left: 12px;
.children-wrapper {
gap: $pad-small;
}
}
.component__tooltip-wrapper__tip-text {
max-width: initial;
}
&__section-description,
&__section-instructions,
&__section-information {
font-size: $x-small;
color: $core-fleet-black;
width: 100%;
}
&__section-information {
p {
margin: 0;
}
}
&__400-error-info {
display: block;
color: $core-fleet-black;
font-weight: normal;
font-size: $x-small;
text-align: left;
margin-top: 10px;
}
}
.automatic-enrollment-card {
margin-top: 0;
margin-bottom: $pad-xxlarge;
font-size: $x-small;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
font-size: $x-small;
font-weight: $bold;
margin: 0 0 $pad-xsmall;
}
p {
margin: 0;
max-width: 520px;
span {
display: flex;
align-items: center;
gap: $pad-small;
}
}
&__turn-on-mdm {
flex-direction: column;
align-items: flex-start;
gap: $pad-medium;
.button {
height: auto;
}
}
}

View file

@ -0,0 +1,81 @@
import React from "react";
import Card from "components/Card";
import Button from "components/buttons/Button";
import Icon from "components/Icon/Icon";
const baseClass = "automatic-enrollment-card";
interface IAppleAutomaticEnrollmentCardProps {
viewDetails: () => void;
turnOn?: () => void;
configured?: boolean;
}
const AppleAutomaticEnrollmentCard = ({
viewDetails,
turnOn,
configured,
}: IAppleAutomaticEnrollmentCardProps) => {
let icon = "";
let msg =
"To enable automatic enrollment for macOS devices, first turn on macOS MDM.";
if (!turnOn && !configured) {
msg =
"Automatically enroll newly purchased macOS devices when theyre first unboxed and set up by your end user.";
} else if (!turnOn && configured) {
msg = "Automatic enrollment for macOS enabled.";
icon = "success";
}
return (
<Card
className={`${baseClass} ${turnOn ? `${baseClass}__turn-on-mdm` : ""}`}
color="gray"
>
<div>
{!icon && <h3>Automatic enrollment for macOS hosts</h3>}
<p>
{icon ? (
<span>
<Icon name="success" />
{msg}
</span>
) : (
msg
)}
</p>
</div>
{turnOn && (
<Button
className="apple-details-button"
onClick={turnOn}
variant="text-icon"
>
Turn on MDM
</Button>
)}
{!turnOn && !configured && (
<Button
className="apple-details-button"
onClick={viewDetails}
variant="brand"
>
Enable
</Button>
)}
{!turnOn && configured && (
<Button
className="apple-details-button"
onClick={viewDetails}
variant="text-icon"
>
<Icon name="pencil" />
Edit
</Button>
)}
</Card>
);
};
export default AppleAutomaticEnrollmentCard;

View file

@ -0,0 +1 @@
export { default } from "./AppleAutomaticEnrollmentCard";

View file

@ -1,11 +1,10 @@
import React from "react";
import { noop } from "lodash";
import Card from "components/Card";
import Button from "components/buttons/Button";
import Icon from "components/Icon/Icon";
const baseClass = "windows-automatic-enrollment-card";
const baseClass = "automatic-enrollment-card";
interface IWindowsAutomaticEnrollmentCardProps {
viewDetails: () => void;

View file

@ -0,0 +1 @@
export { default } from "./MdmPlatformsSection";

View file

@ -1,86 +0,0 @@
import React, { useState, useContext, FormEvent } from "react";
import { AppContext } from "context/app";
import {
APP_CONTEXT_NO_TEAM_ID,
APP_CONTEX_NO_TEAM_SUMMARY,
} from "interfaces/team";
import configAPI from "services/entities/config";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
interface IRenameTeamModal {
onCancel: () => void;
defaultTeamName: string;
onUpdateSuccess: (newName: string) => void;
}
const baseClass = "edit-team-modal";
const RenameTeamModal = ({
onCancel,
defaultTeamName,
onUpdateSuccess,
}: IRenameTeamModal): JSX.Element => {
const { availableTeams } = useContext(AppContext);
const [selectedTeam, setSelectedTeam] = useState(defaultTeamName);
const teamNameOptions = availableTeams
?.filter((t) => t.id >= APP_CONTEXT_NO_TEAM_ID)
.map((teamSummary) => {
return {
value:
teamSummary.name === APP_CONTEX_NO_TEAM_SUMMARY.name
? ""
: teamSummary.name,
label: teamSummary.name,
};
});
const [isLoading, setIsLoading] = useState(false);
const onFormSubmit = async (event: FormEvent) => {
event.preventDefault();
try {
setIsLoading(true);
const configData = await configAPI.update({
mdm: { apple_bm_default_team: selectedTeam },
});
setIsLoading(false);
onUpdateSuccess(configData.mdm.apple_bm_default_team);
} finally {
onCancel();
}
};
return (
<Modal title="Rename team" onExit={onCancel} className={baseClass}>
<form className={`${baseClass}__form`} onSubmit={onFormSubmit}>
<div className="bottom-label">
<Dropdown
placeholder={selectedTeam}
options={teamNameOptions}
onChange={setSelectedTeam}
value={selectedTeam}
label="Team"
helpText="macOS hosts will be added to this team when they're first unboxed."
/>
</div>
<div className="modal-cta-wrap">
<Button type="submit" variant="brand" isLoading={isLoading}>
Save
</Button>
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
</div>
</form>
</Modal>
);
};
export default RenameTeamModal;

View file

@ -1 +0,0 @@
export { default } from "./RenameTeamModal";

View file

@ -1,127 +1,44 @@
import React, { useContext, useState } from "react";
import React, { useCallback, useContext, useState } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router";
import { AxiosError } from "axios";
import PATHS from "router/paths";
import mdmAppleAPI from "services/entities/mdm_apple";
import { IMdmApple } from "interfaces/mdm";
import { readableDate } from "utilities/helpers";
import { IMdmApple, getMdmServerUrl } from "interfaces/mdm";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import BackLink from "components/BackLink";
import MainContent from "components/MainContent";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import DataError from "components/DataError";
import Spinner from "components/Spinner";
import RequestCSRModal from "../components/RequestCSRModal";
const baseClass = "mac-os-mdm-page";
import ApplePushCertSetup from "./components/content/ApplePushCertSetup";
import ApplePushCertInfo from "./components/content/ApplePushCertInfo";
interface IApplePuushCertificatePortalSetupProps {
onClickRequest: () => void;
}
import RenewCertModal from "./components/modals/RenewCertModal";
import TurnOffMacOsMdmModal from "./components/modals/TurnOffMacOsMdmModal";
const ApplePushCertificatePortalSetup = ({
onClickRequest,
}: IApplePuushCertificatePortalSetupProps) => {
return (
<div className={`${baseClass}__page-content ${baseClass}__setup-content`}>
<p className={`${baseClass}__setup-description`}>
Connect Fleet to Apple Push Certificates Portal to change settings and
install software on your macOS hosts.
</p>
<ol className={`${baseClass}__setup-instructions-list`}>
<li>
<p>
1. Request a certificate signing request (CSR) and key for Apple
Push Notification Service (APNs) and a certificate and key for
Simple Certificate Enrollment Protocol (SCEP).
</p>
<Button
className={`${baseClass}__request-button`}
onClick={onClickRequest}
variant="brand"
>
Request
</Button>
</li>
<li>
<p>2. Go to your email to download your CSR.</p>
</li>
<li>
<p>
3.{" "}
<CustomLink
url="https://identity.apple.com/pushcert/"
text="Sign in to Apple Push Certificates Portal"
newTab
/>
<br />
If you don&apos;t have an Apple ID, select <b>Create yours now</b>.
</p>
</li>
<li>
<p>
4. In Apple Push Certificates Portal, select{" "}
<b>Create a Certificate</b>, upload your CSR, and download your APNs
certificate.
</p>
</li>
<li>
<p>
5. Deploy Fleet with <b>mdm</b> configuration.{" "}
<CustomLink
url="https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm"
text="See how"
newTab
/>
</p>
</li>
</ol>
</div>
);
};
export const baseClass = "mac-os-mdm-page";
interface IApplePushCertificatePortalSetupInfoProps {
appleAPNInfo: IMdmApple;
}
const ApplePushCertificatePortalSetupInfo = ({
appleAPNInfo,
}: IApplePushCertificatePortalSetupInfoProps) => {
return (
<dl className={`${baseClass}__page-content ${baseClass}__apc-info`}>
<div>
<dt>Common name (CN)</dt>
<dd>{appleAPNInfo.common_name}</dd>
</div>
<div>
<dt>Serial number</dt>
<dd>{appleAPNInfo.serial_number}</dd>
</div>
<div>
<dt>Issuer</dt>
<dd>{appleAPNInfo.issuer}</dd>
</div>
<div>
<dt>Renew date</dt>
<dd>{readableDate(appleAPNInfo.renew_date)}</dd>
</div>
</dl>
);
};
const MacOSMdmPage = () => {
const MacOSMdmPage = ({ router }: { router: InjectedRouter }) => {
const { config } = useContext(AppContext);
const [showRequestCSRModal, setShowRequestCSRModal] = useState(false);
const { renderFlash } = useContext(NotificationContext);
const [isUpdating, setIsUpdating] = useState(false);
const [showRenewCertModal, setShowRenewCertModal] = useState(false);
const [showTurnOffMdmModal, setShowTurnOffMdmModal] = useState(false);
// Currently the status of this API call is what determines various UI states on
// this page. Because of this we will not render any of this components UI until this API
// call has completed.
const {
data: appleAPNInfo,
isLoading: isLoadingMdmApple,
isLoading,
isRefetching,
refetch,
error: errorMdmApple,
} = useQuery<IMdmApple, AxiosError, IMdmApple>(
["appleAPNInfo"],
@ -130,34 +47,49 @@ const MacOSMdmPage = () => {
retry: (tries, error) => error.status !== 404 && tries <= 3,
enabled: config?.mdm.enabled_and_configured,
staleTime: 5000,
refetchOnWindowFocus: false,
onSettled: () => setIsUpdating(false),
}
);
const toggleRequestCSRModal = () => {
setShowRequestCSRModal((prevState) => !prevState);
const toggleRenewCertModal = () => {
setShowRenewCertModal((prevState) => !prevState);
};
const renderPageContent = () => {
// The API returns a 404 error if APNs is not configured yet, in that case we
// want to prompt the user to download the certs and keys to configure the
// server instead of the default error message.
const showMdmAppleError = errorMdmApple && errorMdmApple.status !== 404;
if (showMdmAppleError) {
return <DataError />;
}
if (!appleAPNInfo) {
return (
<ApplePushCertificatePortalSetup
onClickRequest={toggleRequestCSRModal}
/>
);
}
return <ApplePushCertificatePortalSetupInfo appleAPNInfo={appleAPNInfo} />;
const toggleTurnOffMdmModal = () => {
setShowTurnOffMdmModal((prevState) => !prevState);
};
const turnOffMdm = useCallback(async () => {
setIsUpdating(true);
toggleTurnOffMdmModal();
try {
await mdmAppleAPI.deleteApplePushCertificate();
renderFlash("success", "macOS MDM turned off successfully.");
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
} catch (e) {
renderFlash("error", "Couldnt turn off MDM. Please try again.");
setIsUpdating(false);
}
}, [renderFlash, router]);
const onRenewCert = useCallback(() => {
refetch();
toggleRenewCertModal();
}, [refetch]);
const onSetupSuccess = useCallback(() => {
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
}, [router]);
// The API returns a 404 error if APNs is not configured yet, in that case we
// want to prompt the user to configure the server instead of the default error message.
const isMdmNotConfigured = errorMdmApple && errorMdmApple.status !== 404;
const showSpinner = isLoading || isUpdating || isRefetching;
const showError = !config || isMdmNotConfigured;
const showContent = !showSpinner && !showError;
return (
<MainContent className={baseClass}>
<>
@ -167,9 +99,35 @@ const MacOSMdmPage = () => {
className={`${baseClass}__back-to-mdm`}
/>
<h1>Apple Push Certificate Portal</h1>
{isLoadingMdmApple ? <Spinner /> : renderPageContent()}
{showRequestCSRModal && (
<RequestCSRModal onCancel={toggleRequestCSRModal} />
{showSpinner && <Spinner />}
{showError && <DataError />}
{showContent &&
(!appleAPNInfo ? (
<ApplePushCertSetup
baseClass={baseClass}
onSetupSuccess={onSetupSuccess}
/>
) : (
<ApplePushCertInfo
baseClass={baseClass}
appleAPNInfo={appleAPNInfo}
orgName={config.org_info.org_name}
serverUrl={getMdmServerUrl(config.server_settings)}
onClickRenew={toggleRenewCertModal}
onClickTurnOff={toggleTurnOffMdmModal}
/>
))}
{showRenewCertModal && (
<RenewCertModal
onCancel={toggleRenewCertModal}
onRenew={onRenewCert}
/>
)}
{showTurnOffMdmModal && (
<TurnOffMacOsMdmModal
onCancel={toggleTurnOffMdmModal}
onConfirm={turnOffMdm}
/>
)}
</>
</MainContent>

View file

@ -39,10 +39,33 @@
display: flex;
flex-direction: column;
gap: $pad-large;
};
li {
display: flex;
flex-direction: row;
gap: $pad-small;
p {
display: flex;
flex-direction: row;
align-items: flex-start;
margin: 0;
gap: $pad-small;
}
}
}
&__request-button {
display: flex;
gap: $pad-small;
align-items: center;
margin-top: $pad-small;
label {
display: flex;
gap: $pad-small;
cursor: pointer;
}
}
&__apc-info {
@ -55,4 +78,31 @@
margin-bottom: $pad-xsmall;
}
}
&__apns-button-wrap {
display: flex;
gap: $pad-medium;
align-items: center;
margin-top: $pad-xxlarge;
}
&__file-uploader {
margin-top: $pad-medium;
margin-left: $pad-medium;
border-radius: 6px;
.file-uploader__message {
color: $ui-fleet-black-75;
}
button {
margin-top: 0;
}
&--loading {
label {
opacity: 0.5;
}
}
}
}

View file

@ -0,0 +1,57 @@
import React from "react";
import { IMdmApple } from "interfaces/mdm";
import { readableDate } from "utilities/helpers";
import Button from "components/buttons/Button";
interface IApplePushCertInfoProps {
baseClass: string;
appleAPNInfo: IMdmApple;
orgName: string;
serverUrl: string;
onClickRenew: () => void;
onClickTurnOff: () => void;
}
const ApplePushCertInfo = ({
baseClass,
appleAPNInfo,
orgName,
serverUrl,
onClickRenew,
onClickTurnOff,
}: IApplePushCertInfoProps) => {
return (
<>
<dl className={`${baseClass}__page-content ${baseClass}__apc-info`}>
<div>
<dt>Common name (CN)</dt>
<dd>{appleAPNInfo.common_name}</dd>
</div>
<div>
<dt>Organization name</dt>
<dd>{orgName}</dd>
</div>
<div>
<dt>MDM server URL</dt>
<dd>{serverUrl}</dd>
</div>
<div>
<dt>Renew date</dt>
<dd>{readableDate(appleAPNInfo.renew_date)}</dd>
</div>
</dl>
<div className={`${baseClass}__apns-button-wrap`}>
<Button variant="inverse" onClick={onClickTurnOff}>
Turn off MDM
</Button>
<Button className="save-loading" variant="brand" onClick={onClickRenew}>
Renew certificate
</Button>
</div>
</>
);
};
export default ApplePushCertInfo;

View file

@ -0,0 +1,119 @@
import React, { useCallback, useContext, useState } from "react";
import { NotificationContext } from "context/notification";
import { getErrorReason } from "interfaces/errors";
import mdmAppleApi from "services/entities/mdm_apple";
import CustomLink from "components/CustomLink";
import FileUploader from "components/FileUploader";
import DownloadCSR from "../../../../../../components/DownloadFileButtons/DownloadCSR";
interface IApplePushCertSetupProps {
baseClass: string;
onSetupSuccess: () => void;
}
const ApplePushCertSetup = ({
baseClass,
onSetupSuccess,
}: IApplePushCertSetupProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isUploading, setIsUploading] = useState(false);
const onFileUpload = useCallback(
async (files: FileList | null) => {
if (!files?.length) {
renderFlash("error", "No file selected");
return;
}
setIsUploading(true);
try {
await mdmAppleApi.uploadApplePushCertificate(files[0]);
renderFlash("success", "macOS MDM turned on successfully.");
onSetupSuccess();
} catch (e) {
const msg = getErrorReason(e);
if (msg.toLowerCase().includes("invalid certificate")) {
renderFlash("error", msg);
} else {
renderFlash("error", "Couldnt connect. Please try again.");
}
setIsUploading(false);
}
},
[renderFlash, onSetupSuccess]
);
const onDownloadError = useCallback(
(e: unknown) => {
const msg = getErrorReason(e);
if (msg.toLowerCase().includes("email address")) {
renderFlash("error", msg);
} else {
renderFlash("error", "Somethings gone wrong. Please try again.");
}
},
[renderFlash]
);
return (
<div className={`${baseClass}__page-content ${baseClass}__setup-content`}>
<p className={`${baseClass}__setup-description`}>
Connect Fleet to Apple Push Certificates Portal to turn on MDM.
</p>
<div>
<ol className={`${baseClass}__setup-instructions-list`}>
<li>
<span>1. </span>
<span>
<span>
Download a certificate signing request (CSR) for Apple Push
Notification service (APNs).
</span>
<DownloadCSR baseClass={baseClass} onError={onDownloadError} />
</span>
</li>
<li>
<span>2. </span>
<span>
Sign in to{" "}
<CustomLink
url="https://identity.apple.com/pushcert/"
text="Apple Push Certificates Portal"
newTab
/>
<br />
If you don&apos;t have an Apple ID, select <b>Create yours now</b>
.
</span>
</li>
<li>
<span>3. </span>
<span>
In Apple Push Certificates Portal, select{" "}
<b>Create a Certificate</b>, upload your CSR, and download your
APNs certificate.
</span>
</li>
<li>
<span>4. </span>
<span>Upload APNs certificate (.pem file) below.</span>
</li>
</ol>
<FileUploader
className={`${baseClass}__file-uploader ${
isUploading ? `${baseClass}__file-uploader--loading` : ""
}`}
accept=".pem"
buttonMessage={isUploading ? "Uploading..." : "Upload"}
buttonType="link"
diabled={isUploading}
graphicName="file-pem"
message="APNs certificate (.pem)"
onFileUpload={onFileUpload}
/>
</div>
</div>
);
};
export default ApplePushCertSetup;

View file

@ -0,0 +1,146 @@
import React, { useState, useContext, useEffect, useCallback } from "react";
import { NotificationContext } from "context/notification";
import mdmAppleApi from "services/entities/mdm_apple";
import { getErrorReason } from "interfaces/errors";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import {
FileUploader,
FileDetails,
} from "components/FileUploader/FileUploader";
import Modal from "components/Modal";
import DownloadCSR from "../../../../../../../components/DownloadFileButtons/DownloadCSR";
const baseClass = "modal renew-cert-modal";
interface IRenewCertModalProps {
onCancel: () => void;
onRenew: () => void;
}
const RenewCertModal = ({
onCancel,
onRenew,
}: IRenewCertModalProps): JSX.Element => {
const { renderFlash } = useContext(NotificationContext);
const [isUploading, setIsUploading] = useState(false);
const [certFile, setCertFile] = useState<File | null>(null);
const onSelectFile = useCallback((files: FileList | null) => {
const file = files?.[0];
if (file) {
setCertFile(file);
}
}, []);
const onRenewClick = useCallback(async () => {
if (!certFile) {
// this shouldn'r happen, but just in case
renderFlash("error", "Please provide a certificate file.");
return;
}
setIsUploading(true);
try {
await mdmAppleApi.uploadApplePushCertificate(certFile);
renderFlash("success", "APNs certificate renewed successfully.");
setIsUploading(false);
onRenew();
} catch (e) {
console.error(e);
const msg = getErrorReason(e);
if (msg.toLowerCase().includes("valid certificate")) {
renderFlash("error", msg);
} else {
renderFlash("error", "Couldnt renew. Please try again.");
}
setIsUploading(false);
onCancel();
}
}, [certFile, renderFlash, onCancel, onRenew]);
const onDownloadError = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(e: unknown) => {
renderFlash("error", "Somethings gone wrong. Please try again.");
},
[renderFlash]
);
return (
<Modal title="Renew certificate" onExit={onCancel} className={baseClass}>
<div className={`${baseClass}__page-content ${baseClass}__setup-content`}>
<ol className={`${baseClass}__setup-instructions-list`}>
<li>
<p>
1. Download a certificate signing request (CSR) for Apple Push
Notification service (APNs).
</p>
<DownloadCSR baseClass={baseClass} onError={onDownloadError} />
</li>
<li>
<p>
2. Sign in to{" "}
<CustomLink
url="https://identity.apple.com/pushcert/"
text="Apple Push Certificates Portal"
newTab
/>
</p>
</li>
<li>
<p>
3. In Apple Push Certificates Portal, select <b>Renew</b> next to
your certificate (make sure that the certificate&apos;s{" "}
<b>Common Name (CN)</b> matches the one presented in Fleet).
</p>
</li>
<li>
<p>4. Upload your CSR and download new APNs certificate.</p>
</li>
<li>
<p>
5. Upload APNs certificate (.pem file) below.
<FileUploader
className={`${baseClass}__file-uploader`}
accept=".pem"
buttonMessage="Choose file"
buttonType="link"
graphicName="file-pem"
message="APNs certificate (.pem)"
onFileUpload={onSelectFile}
filePreview={
certFile && (
<FileDetails
details={{ name: certFile.name }}
graphicName="file-pem"
/>
)
}
/>
</p>
</li>
</ol>
<div className={`${baseClass}__button-wrap`}>
<Button
className={`${baseClass}__submit-button ${
isUploading ? `uploading` : ""
}`}
variant="brand"
disabled={!certFile || isUploading}
isLoading={isUploading}
type="button"
onClick={onRenewClick}
>
Renew certificate
</Button>
</div>
</div>
</Modal>
);
};
export default RenewCertModal;

View file

@ -0,0 +1,69 @@
.renew-cert-modal {
width: 730px;
&__info-header {
margin-bottom: $pad-xlarge;
}
&__setup-content {
display: flex;
flex-direction: column;
gap: $pad-large;
color: $core-fleet-black;
p {
margin: 0;
}
}
&__setup-instructions-list {
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $pad-large;
li > p {
margin: 0;
}
}
&__request-button {
display: flex;
gap: $pad-small;
align-items: center;
margin-top: $pad-small;
margin-left: $pad-medium;
label {
display: flex;
gap: $pad-small;
cursor: pointer;
}
}
&__file-uploader {
margin-top: $pad-medium;
margin-left: $pad-medium;
border-radius: 6px;
.file-uploader__message {
color: $ui-fleet-black-75;
margin: 0;
}
button {
margin-top: 0;
}
}
&__button-wrap {
display: flex;
justify-content: flex-end;
.renew-cert-modal__submit-button.uploading.button--disabled {
opacity: 1;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./RenewCertModal";

View file

@ -0,0 +1,53 @@
import React, { useCallback, useState } from "react";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
const baseClass = "modal turn-off-mdm-modal";
interface ITurnOffMacOsMdmModalProps {
onCancel: () => void;
onConfirm: () => void;
}
const TurnOffMacOsMdmModal = ({
onConfirm,
onCancel,
}: ITurnOffMacOsMdmModalProps): JSX.Element => {
const [isDeleting, setIsDeleting] = useState(false);
const onClickConfirm = useCallback(() => {
setIsDeleting(true);
onConfirm();
}, [onConfirm]);
return (
<Modal title="Turn off macOS MDM" onExit={onCancel} className={baseClass}>
<div className={baseClass}>
If you want to use MDM features again, youll have to upload a new APNs
certificate and all end users will have to turn MDM off and back on.
<div className="modal-cta-wrap">
<Button
type="button"
variant="alert"
onClick={onClickConfirm}
isLoading={isDeleting}
disabled={isDeleting}
>
Turn off
</Button>
<Button
onClick={onCancel}
disabled={isDeleting}
variant="inverse-alert"
>
Cancel
</Button>
</div>
</div>
</Modal>
);
};
export default TurnOffMacOsMdmModal;

View file

@ -0,0 +1,14 @@
.turn-off-mdm-modal {
&__info-header {
margin-bottom: $pad-xlarge;
}
&__button-wrap {
display: flex;
justify-content: flex-end;
.renew-cert-modal__submit-button.uploading.button--disabled {
opacity: 1;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./TurnOffMacOsMdmModal";

View file

@ -37,8 +37,14 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
["appleAPNInfo"],
() => mdmAppleAPI.getAppleAPNInfo(),
{
retry: (tries, error) => error.status !== 404 && tries <= 3,
enabled: config?.mdm.enabled_and_configured,
retry: (tries, error) =>
error.status !== 404 && error.status !== 400 && tries <= 3,
// TODO: There is a potential race condition here immediately after MDM is turned off. This
// component gets remounted and stale config data is used to determine it this API call is
// enabled, resulting in a 400 response. The race really should be fixed higher up the chain where
// we're fetching and setting the config, but for now we'll just assume that any 400 response
// means that MDM is not enabled and we'll show the "Turn on MDM" button.
enabled: !!config?.mdm.enabled_and_configured,
staleTime: 5000,
}
);

View file

@ -18,13 +18,10 @@ const TurnOnMacOSMdm = ({ onClickTurnOn }: ITurnOnMacOSMdmProps) => {
<div className={`${baseClass}__turn-on-mac-os`}>
<div>
<h3>Turn on macOS MDM</h3>
<p>
Connect Fleet to Apple Push Certificates Portal to change settings and
install software on your macOS hosts.
</p>
<p>Enforce settings, OS updates, disk encryption, and more.</p>
</div>
<Button variant="brand" onClick={onClickTurnOn}>
Connect APNS
Turn on
</Button>
</div>
);
@ -42,8 +39,8 @@ const SeeDetailsMacOSMdm = ({ onClickDetails }: ITurnOffMacOSMdmProps) => {
<p>macOS MDM turned on</p>
</div>
<Button onClick={onClickDetails} variant="text-icon">
Details
<Icon name="chevron-right" color="core-fleet-blue" />
<Icon name="pencil" />
Edit
</Button>
</div>
);
@ -68,9 +65,10 @@ const MacOSMdmCard = ({
turnOnMacOSMdm,
viewDetails,
}: IMacOSMdmCardProps) => {
// The API returns a 404 error if APNS is not configured yet. If there is any
// other error we will show the DataError component.
const showError = errorData !== null && errorData.status !== 404;
// The API returns an error if MDM is turned off or APNS is not configured yet.
// If there is any other error we will show the DataError component.
const showError =
errorData !== null && errorData.status !== 404 && errorData.status !== 400;
if (showError) {
return <DataError />;

View file

@ -1,216 +0,0 @@
import React, { FormEvent, useState, useContext } from "react";
import { AppContext } from "context/app";
import MdmAPI from "services/entities/mdm";
import Button from "components/buttons/Button";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import DataError from "components/DataError";
import Icon from "components/Icon";
import Modal from "components/Modal";
import validEmail from "components/forms/validators/valid_email";
import validate_presence from "components/forms/validators/validate_presence";
export interface IRequestCSRFormData {
email: string;
orgName: string;
}
const baseClass = "modal request-csr-modal";
interface IRequestCSRModalProps {
onCancel: () => void;
}
interface IFormField {
name: string;
value: string;
}
const FILES: CSRFile[] = [
{ name: "mdmcert.download.push.key", key: "apns_key" }, // APNS key
{ name: "fleet-mdm-apple-scep.key", key: "scep_key" }, // SCEP key
{ name: "fleet-mdm-apple-scep.crt", key: "scep_cert" }, // SCEP cert
];
const downloadFile = (tokens: string, fileName: string) => {
const linkSource = `data:application/octet-stream;base64,${tokens}`;
const downloadLink = document.createElement("a");
downloadLink.href = linkSource;
downloadLink.download = fileName;
downloadLink.click();
};
type RequestCsrResponse = {
apns_key: string;
scep_key: string;
scep_cert: string;
};
type ResponseKeys = keyof RequestCsrResponse;
type CSRFile = {
name: string;
key: ResponseKeys;
value?: string;
};
const downloadCSRFiles = (data: RequestCsrResponse) => {
FILES.forEach((file) => {
downloadFile(data[file.key], file.name);
});
};
const RequestCSRModal = ({ onCancel }: IRequestCSRModalProps): JSX.Element => {
const { currentUser, config } = useContext(AppContext);
const [formData, setFormData] = useState<IRequestCSRFormData>({
email: currentUser?.email ?? "",
orgName: config?.org_info?.org_name ?? "",
});
const [emailError, setEmailError] = useState("");
const [orgError, setOrgError] = useState("");
const [requestState, setRequestState] = useState<
"loading" | "error" | "success" | undefined
>(undefined);
const { email, orgName } = formData;
const onInputChange = ({ name, value }: IFormField) => {
setFormData({ ...formData, [name]: value });
};
const onFormSubmit = async (evt: FormEvent) => {
evt.preventDefault();
// TODO: improve error handling. considering pulling out form err handling
// into reusable hook.
if (!validEmail(formData.email)) {
setEmailError("Email is not a valid format.");
return;
}
if (!validate_presence(formData.orgName)) {
setOrgError("Organization name is required.");
return;
}
setEmailError("");
setOrgError("");
setRequestState("loading");
try {
const data = await MdmAPI.requestCSR(email, orgName);
downloadCSRFiles(data);
setRequestState("success");
} catch (e) {
const err = e as any;
if (
err.status >= 400 &&
err.status <= 499 &&
err.data?.errors[0]?.name === "email_address"
) {
setEmailError("Email does not have the correct domain.");
setRequestState(undefined);
}
if (err.status >= 500 && err.status <= 599) {
setRequestState("error");
}
}
};
const RequestCSRSuccess = () => {
return (
<div className="success">
<Icon name="success" size="extra-large" />
<h2>You&apos;re almost there</h2>
<p>
Go to your <strong>{email}</strong> email to download your CSR.
</p>
<p>
Your APNs key and SCEP certificate and key will be downloaded in the
browser.
<br />
You&apos;ll need these later.
</p>
<Button
onClick={() => {
onCancel();
}}
>
Got it
</Button>
</div>
);
};
const renderRequestCSRForm = () => {
if (requestState === "success") {
return <RequestCSRSuccess />;
}
if (requestState === "error") {
return <DataError />;
}
return (
<>
<p>
A CSR and key for APNs and a certificate and key for SCEP are required
to connect Fleet to Apple Developer. Apple Inc. requires the following
information. <br />
<br />
fleetdm.com will send your CSR to the below email. Your APNs key and
SCEP certificate and key will be downloaded in the browser.
</p>
<form
className={`${baseClass}__form`}
onSubmit={onFormSubmit}
autoComplete="off"
>
<div className="bottom-label">
<InputField
name="email"
onChange={onInputChange}
label="Email"
parseTarget
value={email}
error={emailError}
helpText={
<>
Apple Inc. requires a work email (ex.
name@your-organization.com).
</>
}
/>
</div>
<InputField
name="orgName"
onChange={onInputChange}
label="Organization name"
parseTarget
value={orgName}
error={orgError}
/>
<div className="modal-cta-wrap">
<Button
type="submit"
variant="brand"
isLoading={requestState === "loading"}
>
Request
</Button>
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
</div>
</form>
</>
);
};
return (
<Modal title="Request" onExit={onCancel} className={baseClass}>
{renderRequestCSRForm()}
</Modal>
);
};
export default RequestCSRModal;

View file

@ -1,22 +0,0 @@
.request-csr-modal {
&__info-header {
margin-bottom: $pad-xlarge;
}
.success {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
h2,
p {
margin: 0;
}
}
.data-error__inner {
margin: 0;
}
}

View file

@ -1 +0,0 @@
export { default } from "./RequestCSRModal";

View file

@ -0,0 +1,76 @@
import React, { FormEvent, useCallback, useMemo, useState } from "react";
import mdmAppleBusinessManagerApi from "services/entities/mdm_apple_bm";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import { downloadBase64ToFile, RequestState } from "./helpers";
interface IDownloadABMKeyProps {
baseClass: string;
onSuccess?: () => void;
onError?: (e: unknown) => void;
}
const downloadKeyFile = (data: { public_key: string }) => {
downloadBase64ToFile(data.public_key, "fleet-mdm-apple-bm-public-key.crt");
};
// TODO: why can't we use Content-Dispostion for these? We're only getting one file back now.
const useDownloadABMKey = ({
onSuccess,
onError,
}: Omit<IDownloadABMKeyProps, "baseClass">) => {
const [downloadState, setDownloadState] = useState<RequestState>(undefined);
const handleDownload = useCallback(
async (evt: FormEvent) => {
evt.preventDefault();
setDownloadState("loading");
try {
const data = await mdmAppleBusinessManagerApi.downloadPublicKey();
downloadKeyFile(data);
setDownloadState("success");
onSuccess && onSuccess();
} catch (e) {
setDownloadState("error");
onError && onError(e);
}
},
[onError, onSuccess]
);
const memoized = useMemo(
() => ({
downloadState,
handleDownload,
}),
[downloadState, handleDownload]
);
return memoized;
};
export const DownloadABMKey = ({
baseClass,
onSuccess,
onError,
}: IDownloadABMKeyProps) => {
const { handleDownload } = useDownloadABMKey({ onSuccess, onError });
return (
<Button
className={`${baseClass}__request-button`}
variant="text-icon"
onClick={handleDownload}
>
<label htmlFor="download-key">
<Icon name="download" color="core-fleet-blue" size="medium" />
<span>Download public key</span>
</label>
</Button>
);
};
export default DownloadABMKey;

View file

@ -0,0 +1,76 @@
import React, { FormEvent, useCallback, useMemo, useState } from "react";
import mdmAppleApi from "services/entities/mdm_apple";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import { RequestState, downloadBase64ToFile } from "./helpers";
interface IDownloadCSRProps {
baseClass: string;
onSuccess?: () => void;
onError?: (e: unknown) => void;
}
const downloadCSRFile = (data: { csr: string }) => {
downloadBase64ToFile(data.csr, "fleet-mdm-apple.csr");
};
// TODO: why can't we use Content-Dispostion for these? We're only getting one file back now.
const useDownloadCSR = ({
onSuccess,
onError,
}: Omit<IDownloadCSRProps, "baseClass">) => {
const [downloadState, setDownloadState] = useState<RequestState>(undefined);
const handleDownload = useCallback(
async (evt: FormEvent) => {
evt.preventDefault();
setDownloadState("loading");
try {
const data = await mdmAppleApi.requestCSR();
downloadCSRFile(data);
setDownloadState("success");
onSuccess && onSuccess();
} catch (e) {
setDownloadState("error");
onError && onError(e);
}
},
[onError, onSuccess]
);
const memoized = useMemo(
() => ({
downloadState,
handleDownload,
}),
[downloadState, handleDownload]
);
return memoized;
};
export const DownloadCSR = ({
baseClass,
onSuccess,
onError,
}: IDownloadCSRProps) => {
const { handleDownload } = useDownloadCSR({ onSuccess, onError });
return (
<Button
className={`${baseClass}__request-button`}
variant="text-icon"
onClick={handleDownload}
>
<label htmlFor="request-csr">
<Icon name="download" color="core-fleet-blue" size="medium" />
<span>Download CSR</span>
</label>
</Button>
);
};
export default DownloadCSR;

View file

@ -0,0 +1,10 @@
export type RequestState = "loading" | "error" | "success" | undefined;
export const downloadBase64ToFile = (data: string, fileName: string) => {
const linkSource = `data:application/octet-stream;base64,${data}`;
const downloadLink = document.createElement("a");
downloadLink.href = linkSource;
downloadLink.download = fileName;
downloadLink.click();
};

View file

@ -59,6 +59,7 @@ import SetupExperience from "pages/ManageControlsPage/SetupExperience/SetupExper
import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage";
import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMdmPage";
import Scripts from "pages/ManageControlsPage/Scripts/Scripts";
import AppleAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AppleAutomaticEnrollmentPage";
import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage";
import HostQueryReport from "pages/hosts/details/HostQueryReport";
import SoftwarePage from "pages/SoftwarePage";
@ -157,6 +158,10 @@ const routes = (
</Route>
<Route path="integrations/mdm/windows" component={WindowsMdmPage} />
<Route path="integrations/mdm/apple" component={MacOSMdmPage} />
<Route
path="integrations/automatic-enrollment/apple"
component={AppleAutomaticEnrollmentPage}
/>
<Route
path="integrations/automatic-enrollment/windows"
component={WindowsAutomaticEnrollmentPage}

View file

@ -33,6 +33,7 @@ export default {
ADMIN_INTEGRATIONS_MDM_MAC: `${URL_PREFIX}/settings/integrations/mdm/apple`,
ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`,
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`,
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_APPLE: `${URL_PREFIX}/settings/integrations/automatic-enrollment/apple`,
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`,
ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`,
ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`,

View file

@ -80,13 +80,10 @@ const mdmService = {
timeout
);
},
requestCSR: (email: string, organization: string) => {
requestCSR: () => {
const { MDM_REQUEST_CSR } = endpoints;
return sendRequest("POST", MDM_REQUEST_CSR, {
email_address: email,
organization,
});
return sendRequest("GET", MDM_REQUEST_CSR);
},
getProfiles: (

View file

@ -8,4 +8,21 @@ export default {
const path = MDM_APPLE_PNS;
return sendRequest("GET", path);
},
uploadApplePushCertificate: (certificate: File) => {
const { MDM_APPLE_APNS_CERTIFICATE } = endpoints;
const formData = new FormData();
formData.append("certificate", certificate);
return sendRequest("POST", MDM_APPLE_APNS_CERTIFICATE, formData);
},
deleteApplePushCertificate: () => {
const { MDM_APPLE_APNS_CERTIFICATE } = endpoints;
return sendRequest("DELETE", MDM_APPLE_APNS_CERTIFICATE);
},
requestCSR: () => {
const { MDM_REQUEST_CSR } = endpoints;
return sendRequest("GET", MDM_REQUEST_CSR);
},
};

View file

@ -2,6 +2,10 @@
import sendRequest from "services";
import endpoints from "utilities/endpoints";
export interface IAppleBusinessManagerTokenFormData {
token: File | null;
}
export default {
getAppleBMInfo: () => {
const { MDM_APPLE_BM } = endpoints;
@ -28,4 +32,22 @@ export default {
return Promise.resolve({ decodedPublic, decodedPrivate });
});
},
downloadPublicKey: () => {
const { MDM_APPLE_ABM_PUBLIC_KEY } = endpoints;
return sendRequest("GET", MDM_APPLE_ABM_PUBLIC_KEY);
},
uploadToken: (token: File) => {
const { MDM_APPLE_ABM_TOKEN: MDM_APPLE_BM_TOKEN } = endpoints;
const formData = new FormData();
formData.append("token", token);
return sendRequest("POST", MDM_APPLE_BM_TOKEN, formData);
},
disableAutomaticEnrollment: () => {
const { MDM_APPLE_ABM_TOKEN: MDM_APPLE_BM_TOKEN } = endpoints;
return sendRequest("DELETE", MDM_APPLE_BM_TOKEN);
},
};

View file

@ -75,6 +75,10 @@ export default {
MACADMINS: `/${API_VERSION}/fleet/macadmins`,
// MDM endpoints
MDM_APPLE: `/${API_VERSION}/fleet/mdm/apple`,
MDM_APPLE_ABM_TOKEN: `/${API_VERSION}/fleet/mdm/apple/abm_token`,
MDM_APPLE_ABM_PUBLIC_KEY: `/${API_VERSION}/fleet/mdm/apple/abm_public_key`,
MDM_APPLE_APNS_CERTIFICATE: `/${API_VERSION}/fleet/mdm/apple/apns_certificate`,
MDM_APPLE_PNS: `/${API_VERSION}/fleet/apns`,
MDM_APPLE_BM: `/${API_VERSION}/fleet/abm`,
MDM_APPLE_BM_KEYS: `/${API_VERSION}/fleet/mdm/apple/dep/key_pair`,

View file

@ -25,3 +25,13 @@ export const getPlatformDisplayName = (file: File) => {
const fileExt = getFileExtension(file);
return FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME[fileExt];
};
/**
* This gets the file details from the file.
*/
export const getFileDetails = (file: File) => {
return {
name: file.name,
platform: getPlatformDisplayName(file),
};
};

View file

@ -20,7 +20,6 @@ import (
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
"github.com/spf13/cast"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@ -95,6 +94,7 @@ type ServerConfig struct {
SandboxEnabled bool `yaml:"sandbox_enabled"`
WebsocketsAllowUnsafeOrigin bool `yaml:"websockets_allow_unsafe_origin"`
FrequentCleanupsEnabled bool `yaml:"frequent_cleanups_enabled"`
PrivateKey string `yaml:"private_key"`
}
func (s *ServerConfig) DefaultHTTPServer(ctx context.Context, handler http.Handler) *http.Server {
@ -453,6 +453,12 @@ type MDMConfig struct {
AppleBMKey string `yaml:"apple_bm_key"`
AppleBMKeyBytes string `yaml:"apple_bm_key_bytes"`
// the following fields hold the PEM-encoded bytes for the certificate
// and private key set the first time AppleBM is called
appleBMPEMCert []byte
appleBMPEMKey []byte
appleBMRawToken []byte
// the following fields hold the decrypted, validated Apple BM token set the
// first time AppleBM is called.
appleBMToken *nanodep_client.OAuth1Tokens
@ -601,20 +607,6 @@ func (m *MDMConfig) AppleAPNs() (cert *tls.Certificate, pemCert, pemKey []byte,
return m.appleAPNs, m.appleAPNsPEMCert, m.appleAPNsPEMKey, nil
}
func (m *MDMConfig) AppleAPNsTopic() (string, error) {
apnsCert, _, _, err := m.AppleAPNs()
if err != nil {
return "", fmt.Errorf("parsing APNs certificates: %w", err)
}
mdmPushCertTopic, err := cryptoutil.TopicFromCert(apnsCert.Leaf)
if err != nil {
return "", fmt.Errorf("extracting topic from APNs certificate: %w", err)
}
return mdmPushCertTopic, nil
}
// AppleSCEP returns the parsed and validated TLS certificate for Apple SCEP.
// It parses and validates it if it hasn't been done yet.
func (m *MDMConfig) AppleSCEP() (cert *tls.Certificate, pemCert, pemKey []byte, err error) {
@ -636,10 +628,36 @@ func (m *MDMConfig) AppleSCEP() (cert *tls.Certificate, pemCert, pemKey []byte,
return m.appleSCEP, m.appleSCEPPEMCert, m.appleSCEPPEMKey, nil
}
type ParsedAppleBM struct {
CertPEM []byte
KeyPEM []byte
EncryptedToken []byte
Token *nanodep_client.OAuth1Tokens
}
func decryptAndValidateABMToken(tokenBytes []byte, cert *x509.Certificate, keyPEM []byte) (*nanodep_client.OAuth1Tokens, error) {
bmKey, err := tokenpki.RSAKeyFromPEM(keyPEM)
if err != nil {
return nil, fmt.Errorf("Apple BM configuration: parse private key: %w", err)
}
token, err := tokenpki.DecryptTokenJSON(tokenBytes, cert, bmKey)
if err != nil {
return nil, fmt.Errorf("Apple BM configuration: decrypt token: %w", err)
}
var jsonTok nanodep_client.OAuth1Tokens
if err := json.Unmarshal(token, &jsonTok); err != nil {
return nil, fmt.Errorf("Apple BM configuration: unmarshal JSON token: %w", err)
}
if jsonTok.AccessTokenExpiry.Before(time.Now()) {
return nil, errors.New("Apple BM configuration: token is expired")
}
return &jsonTok, nil
}
// AppleBM returns the parsed, validated and decrypted server token for Apple
// Business Manager. It also parses and validates the Apple BM certificate and
// private key in the process, in order to decrypt the token.
func (m *MDMConfig) AppleBM() (tok *nanodep_client.OAuth1Tokens, err error) {
func (m *MDMConfig) AppleBM() (*ParsedAppleBM, error) {
if m.appleBMToken == nil {
pair := x509KeyPairConfig{
m.AppleBMCert,
@ -655,24 +673,22 @@ func (m *MDMConfig) AppleBM() (tok *nanodep_client.OAuth1Tokens, err error) {
if err != nil {
return nil, fmt.Errorf("Apple BM configuration: %w", err)
}
bmKey, err := tokenpki.RSAKeyFromPEM(pair.keyBytes)
jsonTok, err := decryptAndValidateABMToken(encToken, cert.Leaf, pair.keyBytes)
if err != nil {
return nil, fmt.Errorf("Apple BM configuration: parse private key: %w", err)
return nil, err
}
token, err := tokenpki.DecryptTokenJSON(encToken, cert.Leaf, bmKey)
if err != nil {
return nil, fmt.Errorf("Apple BM configuration: decrypt token: %w", err)
}
var jsonTok nanodep_client.OAuth1Tokens
if err := json.Unmarshal(token, &jsonTok); err != nil {
return nil, fmt.Errorf("Apple BM configuration: unmarshal JSON token: %w", err)
}
if jsonTok.AccessTokenExpiry.Before(time.Now()) {
return nil, errors.New("Apple BM configuration: token is expired")
}
m.appleBMToken = &jsonTok
m.appleBMToken = jsonTok
m.appleBMPEMCert = pair.certBytes
m.appleBMPEMKey = pair.keyBytes
m.appleBMRawToken = encToken
}
return m.appleBMToken, nil
return &ParsedAppleBM{
CertPEM: m.appleBMPEMCert,
KeyPEM: m.appleBMPEMKey,
EncryptedToken: m.appleBMRawToken,
Token: m.appleBMToken,
}, nil
}
func (m *MDMConfig) loadAppleBMEncryptedToken() ([]byte, error) {
@ -848,6 +864,7 @@ func (man Manager) addConfigs() {
"When enabled, Fleet limits some features for the Sandbox")
man.addConfigBool("server.websockets_allow_unsafe_origin", false, "Disable checking the origin header on websocket connections, this is sometimes necessary when proxies rewrite origin headers between the client and the Fleet webserver")
man.addConfigBool("server.frequent_cleanups_enabled", false, "Enable frequent cleanups of expired data (15 minute interval)")
man.addConfigString("server.private_key", "", "Used for encrypting sensitive data, such as MDM certificates.")
// Hide the sandbox flag as we don't want it to be discoverable for users for now
sandboxFlag := man.command.PersistentFlags().Lookup(flagNameFromConfigKey("server.sandbox_enabled"))
@ -1208,6 +1225,7 @@ func (man Manager) LoadConfig() FleetConfig {
SandboxEnabled: man.getConfigBool("server.sandbox_enabled"),
WebsocketsAllowUnsafeOrigin: man.getConfigBool("server.websockets_allow_unsafe_origin"),
FrequentCleanupsEnabled: man.getConfigBool("server.frequent_cleanups_enabled"),
PrivateKey: man.getConfigString("server.private_key"),
},
Auth: AuthConfig{
BcryptCost: man.getConfigInt("auth.bcrypt_cost"),
@ -1729,6 +1747,7 @@ func TestConfig() FleetConfig {
AuditLogFile: testLogFile,
MaxSize: 500,
},
Server: ServerConfig{PrivateKey: "72414F4A688151F75D032F5CDA095FC4"},
}
}
@ -1737,33 +1756,7 @@ func TestConfig() FleetConfig {
// all required pairs and the Apple BM token is used as-is, instead of
// decrypting the encrypted value that is usually provided via the fleet
// server's flags.
func SetTestMDMConfig(t testing.TB, cfg *FleetConfig, cert, key []byte, appleBMToken *nanodep_client.OAuth1Tokens, wstepCertAndKeyDir string) {
tlsCert, err := tls.X509KeyPair(cert, key)
if err != nil {
t.Fatal(err)
}
parsed, err := x509.ParseCertificate(tlsCert.Certificate[0])
if err != nil {
t.Fatal(err)
}
tlsCert.Leaf = parsed
cfg.MDM.AppleAPNsCertBytes = string(cert)
cfg.MDM.AppleAPNsKeyBytes = string(key)
cfg.MDM.AppleSCEPCertBytes = string(cert)
cfg.MDM.AppleSCEPKeyBytes = string(key)
cfg.MDM.AppleBMCertBytes = string(cert)
cfg.MDM.AppleBMKeyBytes = string(key)
cfg.MDM.AppleBMServerTokenBytes = "whatever-will-not-be-accessed"
cfg.MDM.appleAPNs = &tlsCert
cfg.MDM.appleAPNsPEMCert = cert
cfg.MDM.appleAPNsPEMKey = key
cfg.MDM.appleSCEP = &tlsCert
cfg.MDM.appleSCEPPEMCert = cert
cfg.MDM.appleSCEPPEMKey = key
cfg.MDM.appleBMToken = appleBMToken
func SetTestMDMConfig(t testing.TB, cfg *FleetConfig, cert, key []byte, wstepCertAndKeyDir string) {
cfg.MDM.AppleSCEPSignerValidityDays = 365
cfg.MDM.AppleSCEPChallenge = "testchallenge"

View file

@ -50,6 +50,14 @@ const (
defaultQueryByNameExpiration = 1 * time.Second
queryResultsCountKey = "QueryResultsCount:%d"
defaultQueryResultsCountExpiration = 1 * time.Second
// NOTE: MDM assets are cached using their checksum as well, as it's
// important for them to always be fresh if they changed (see cachedi
// mplementation below for details)
mdmConfigAssetKey = "MDMConfigAsset:%s:%s"
// NOTE: given how mdmConfigAssetKey works, it means that once an asset
// changes, it'll linger for this amount of time. The curent
// implementation assumes infrequent asset changes.
defaultMDMConfigAssetExpiration = 15 * time.Minute
)
// cloneCache wraps the in memory cache with one that clones items before returning them.
@ -107,6 +115,7 @@ type cachedMysql struct {
teamMDMConfigExp time.Duration
queryByNameExp time.Duration
queryResultsCountExp time.Duration
mdmConfigAssetExp time.Duration
}
type Option func(*cachedMysql)
@ -159,6 +168,12 @@ func WithQueryResultsCountExpiration(d time.Duration) Option {
}
}
func WithMDMConfigAssetExpiration(d time.Duration) Option {
return func(o *cachedMysql) {
o.mdmConfigAssetExp = d
}
}
func New(ds fleet.Datastore, opts ...Option) fleet.Datastore {
c := &cachedMysql{
Datastore: ds,
@ -171,6 +186,7 @@ func New(ds fleet.Datastore, opts ...Option) fleet.Datastore {
teamMDMConfigExp: defaultTeamMDMConfigExpiration,
queryByNameExp: defaultQueryByNameExpiration,
queryResultsCountExp: defaultQueryResultsCountExpiration,
mdmConfigAssetExp: defaultMDMConfigAssetExpiration,
}
for _, fn := range opts {
fn(c)
@ -386,3 +402,49 @@ func (ds *cachedMysql) ResultCountForQuery(ctx context.Context, queryID uint) (i
return count, nil
}
func (ds *cachedMysql) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
// always reach the database to get the latest hashes
latestHashes, err := ds.Datastore.GetAllMDMConfigAssetsHashes(ctx, assetNames)
if err != nil {
return nil, err
}
cachedAssets := make(map[fleet.MDMAssetName]fleet.MDMConfigAsset)
var missingAssets []fleet.MDMAssetName
var missingKeys []string
for _, name := range assetNames {
key := fmt.Sprintf(mdmConfigAssetKey, name, latestHashes[name])
if x, found := ds.c.Get(ctx, key); found {
asset, ok := x.(fleet.MDMConfigAsset)
if ok {
cachedAssets[name] = asset
continue
}
}
missingAssets = append(missingAssets, name)
missingKeys = append(missingKeys, key)
}
if len(missingAssets) == 0 {
return cachedAssets, nil
}
// fetch missing assets from the database
assetMap, err := ds.Datastore.GetAllMDMConfigAssetsByName(ctx, missingAssets)
if err != nil {
return nil, err
}
// update the cache with the fetched assets and their hashes
for name, asset := range assetMap {
key := fmt.Sprintf(mdmConfigAssetKey, name, latestHashes[name])
ds.c.Set(ctx, key, asset, ds.mdmConfigAssetExp)
cachedAssets[name] = asset
}
return cachedAssets, nil
}

View file

@ -12,7 +12,6 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -66,7 +65,7 @@ func TestClone(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
clone, err := tc.src.Clone()
require.NoError(t, err)
assert.Equal(t, tc.want, clone)
require.Equal(t, tc.want, clone)
// ensure that writing to src does not alter the cloned value (i.e. that
// the nested fields are deeply cloned too).
@ -74,7 +73,7 @@ func TestClone(t *testing.T) {
case *fleet.AppConfig:
if len(src.ServerSettings.DebugHostIDs) > 0 {
src.ServerSettings.DebugHostIDs[0] = 999
assert.NotEqual(t, src.ServerSettings.DebugHostIDs, clone.(*fleet.AppConfig).ServerSettings.DebugHostIDs)
require.NotEqual(t, src.ServerSettings.DebugHostIDs, clone.(*fleet.AppConfig).ServerSettings.DebugHostIDs)
}
}
})
@ -111,7 +110,7 @@ func TestCachedAppConfig(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, data)
assert.Equal(t, json.RawMessage(`"TestCachedAppConfig"`), *data.Features.AdditionalQueries)
require.Equal(t, json.RawMessage(`"TestCachedAppConfig"`), *data.Features.AdditionalQueries)
})
t.Run("AppConfig", func(t *testing.T) {
@ -130,12 +129,12 @@ func TestCachedAppConfig(t *testing.T) {
},
}))
assert.True(t, mockedDS.SaveAppConfigFuncInvoked)
require.True(t, mockedDS.SaveAppConfigFuncInvoked)
ac, err := ds.AppConfig(context.Background())
require.NoError(t, err)
require.NotNil(t, ac.Features.AdditionalQueries)
assert.Equal(t, json.RawMessage(`"NewSAVED"`), *ac.Features.AdditionalQueries)
require.Equal(t, json.RawMessage(`"NewSAVED"`), *ac.Features.AdditionalQueries)
})
t.Run("External SaveAppConfig gets caught", func(t *testing.T) {
@ -152,7 +151,7 @@ func TestCachedAppConfig(t *testing.T) {
ac, err := ds.AppConfig(context.Background())
require.NoError(t, err)
require.NotNil(t, ac.Features.AdditionalQueries)
assert.Equal(t, json.RawMessage(`"SavedSomewhereElse"`), *ac.Features.AdditionalQueries)
require.Equal(t, json.RawMessage(`"SavedSomewhereElse"`), *ac.Features.AdditionalQueries)
})
}
@ -746,3 +745,104 @@ func TestCachedResultCountForQuery(t *testing.T) {
require.Equal(t, testCount, c3)
require.True(t, mockedDS.ResultCountForQueryFuncInvoked)
}
func TestGetAllMDMConfigAssetsByName(t *testing.T) {
t.Parallel()
mockedDS := new(mock.Store)
ds := New(mockedDS)
assetNames := []fleet.MDMAssetName{"asset1", "asset2", "asset3"}
assetMap := map[fleet.MDMAssetName]fleet.MDMConfigAsset{
"asset1": {Name: "asset1", Value: []byte("value1")},
"asset2": {Name: "asset2", Value: []byte("value2")},
"asset3": {Name: "asset2", Value: []byte("value3")},
}
assetHashes := map[fleet.MDMAssetName]string{
"asset1": "hash1",
"asset2": "hash2",
"asset3": "hash3",
}
mockedDS.GetAllMDMConfigAssetsHashesFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
result := map[fleet.MDMAssetName]string{}
for _, n := range assetNames {
result[n] = assetHashes[n]
}
return result, nil
}
mockedDS.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
result := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
for _, n := range assetNames {
result[n] = assetMap[n]
}
return result, nil
}
// returns cached assets if hashes match
result, err := ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2"})
require.NoError(t, err)
require.True(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked)
require.True(t, mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked)
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
require.Equal(t, assetMap["asset1"], result["asset1"])
require.Equal(t, assetMap["asset2"], result["asset2"])
require.NotContains(t, result, "asset3")
result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2"})
require.NoError(t, err)
require.False(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked)
require.True(t, mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked)
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
require.Equal(t, assetMap["asset1"], result["asset1"])
require.Equal(t, assetMap["asset2"], result["asset2"])
require.NotContains(t, result, "asset3")
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
// fetches missing assets from the db
result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2", "asset3"})
require.NoError(t, err)
require.Equal(t, assetMap["asset1"], result["asset1"])
require.Equal(t, assetMap["asset2"], result["asset2"])
require.Equal(t, assetMap["asset3"], result["asset3"])
require.True(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked)
require.True(t, mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked)
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
// fetches updated assets from the db
assetHashes["asset1"] = "newhash"
assetMap["asset1"] = fleet.MDMConfigAsset{Name: "asset1", Value: []byte("newvalue")}
result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), assetNames)
require.NoError(t, err)
require.Equal(t, assetMap["asset1"], result["asset1"])
require.Equal(t, assetMap["asset2"], result["asset2"])
require.Equal(t, assetMap["asset3"], result["asset3"])
require.True(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked)
require.True(t, mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked)
mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked = false
mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false
// passes errors fetching assets from downstream
mockedDS.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return nil, errors.New("error fetching assets")
}
_, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"not exists"})
require.Error(t, err)
require.Equal(t, "error fetching assets", err.Error())
// passes errors fetching hashes from downstream
mockedDS.GetAllMDMConfigAssetsHashesFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
return nil, errors.New("error fetching hashes")
}
_, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"not exists"})
require.Error(t, err)
require.Equal(t, "error fetching hashes", err.Error())
}

View file

@ -169,7 +169,7 @@ func applyEnrollSecretsDB(ctx context.Context, q sqlx.ExtContext, teamID *uint,
args = append(args, s.Secret, teamID, secretCreatedAt)
}
if _, err := q.ExecContext(ctx, sql, args...); err != nil {
if isDuplicate(err) {
if IsDuplicate(err) {
// Obfuscate the secret in the error message
err = alreadyExists("secret", fleet.MaskedPassword)
}

View file

@ -3,10 +3,14 @@ package mysql
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
@ -48,7 +52,7 @@ INSERT INTO
profUUID, teamID, cp.Identifier, cp.Name, cp.Mobileconfig, cp.Mobileconfig, cp.Name, teamID, cp.Name, teamID)
if err != nil {
switch {
case isDuplicate(err):
case IsDuplicate(err):
return ctxerr.Wrap(ctx, formatErrorDuplicateConfigProfile(err, &cp))
default:
return ctxerr.Wrap(ctx, err, "creating new apple mdm config profile")
@ -2825,7 +2829,7 @@ func (ds *Datastore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fle
_, err := ds.writer(ctx).ExecContext(ctx, stmt, bp.TeamID, bp.Name, bp.Sha256, bp.Bytes, bp.Token)
if err != nil {
if isDuplicate(err) {
if IsDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("BootstrapPackage", fmt.Sprintf("for team %d", bp.TeamID)))
}
return ctxerr.Wrap(ctx, err, "create bootstrap package")
@ -2852,7 +2856,7 @@ WHERE team_id = 0
`
_, err := tx.ExecContext(ctx, insertStmt, toTeamID, uuid.New().String())
if err != nil {
if isDuplicate(err) {
if IsDuplicate(err) {
return ctxerr.Wrap(ctx, &existsError{
ResourceType: "BootstrapPackage",
TeamID: &toTeamID,
@ -3765,7 +3769,7 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO
declUUID, tmID, declaration.Identifier, declaration.Name, declaration.RawJSON, checksum, declaration.Name, tmID, declaration.Name, tmID)
if err != nil {
switch {
case isDuplicate(err):
case IsDuplicate(err):
return ctxerr.Wrap(ctx, formatErrorDuplicateDeclaration(err, declaration))
default:
return ctxerr.Wrap(ctx, err, "creating new apple mdm declaration")
@ -4177,6 +4181,185 @@ VALUES
return nil
}
func encrypt(plainText []byte, privateKey string) ([]byte, error) {
block, err := aes.NewCipher([]byte(privateKey))
if err != nil {
return nil, fmt.Errorf("create new cipher: %w", err)
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create new gcm: %w", err)
}
nonce := make([]byte, aesGCM.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("generate nonce: %w", err)
}
return aesGCM.Seal(nonce, nonce, plainText, nil), nil
}
func decrypt(encrypted []byte, privateKey string) ([]byte, error) {
block, err := aes.NewCipher([]byte(privateKey))
if err != nil {
return nil, fmt.Errorf("create new cipher: %w", err)
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create new gcm: %w", err)
}
// Get the nonce size
nonceSize := aesGCM.NonceSize()
// Extract the nonce from the encrypted data
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
decrypted, err := aesGCM.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("generate nonce: %w", err)
}
return decrypted, nil
}
func (ds *Datastore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error {
stmt := `
INSERT INTO mdm_config_assets
(name, value, md5_checksum)
VALUES
%s`
var args []any
var insertVals strings.Builder
for _, a := range assets {
encryptedVal, err := encrypt(a.Value, ds.serverPrivateKey)
if err != nil {
return ctxerr.Wrap(ctx, err, fmt.Sprintf("encrypting mdm config asset %s", a.Name))
}
hexChecksum := md5ChecksumBytes(encryptedVal)
insertVals.WriteString(`(?, ?, UNHEX(?)),`)
args = append(args, a.Name, encryptedVal, hexChecksum)
}
stmt = fmt.Sprintf(stmt, strings.TrimSuffix(insertVals.String(), ","))
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, stmt, args...)
return err
})
return ctxerr.Wrap(ctx, err, "writing mdm config assets to db")
}
func (ds *Datastore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
if len(assetNames) == 0 {
return nil, nil
}
stmt := `
SELECT
name, value
FROM
mdm_config_assets
WHERE
name IN (?)
AND deletion_uuid = ''
`
stmt, args, err := sqlx.In(stmt, assetNames)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In statement")
}
var res []fleet.MDMConfigAsset
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get mdm config assets by name")
}
if len(res) == 0 {
return nil, notFound("MDMConfigAsset")
}
assetMap := make(map[fleet.MDMAssetName]fleet.MDMConfigAsset, len(res))
for _, asset := range res {
decryptedVal, err := decrypt(asset.Value, ds.serverPrivateKey)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "decrypting mdm config asset %s", asset.Name)
}
assetMap[asset.Name] = fleet.MDMConfigAsset{Name: asset.Name, Value: decryptedVal}
}
if len(res) < len(assetNames) {
return assetMap, ErrPartialResult
}
return assetMap, nil
}
func (ds *Datastore) GetAllMDMConfigAssetsHashes(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) {
if len(assetNames) == 0 {
return nil, nil
}
stmt := `
SELECT name, HEX(md5_checksum) as md5_checksum
FROM mdm_config_assets
WHERE name IN (?) AND deletion_uuid = ''`
stmt, args, err := sqlx.In(stmt, assetNames)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In statement")
}
var res []fleet.MDMConfigAsset
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get mdm config checksums by name")
}
if len(res) == 0 {
return nil, notFound("MDMConfigAsset")
}
assetMap := make(map[fleet.MDMAssetName]string, len(res))
for _, asset := range res {
assetMap[asset.Name] = asset.MD5Checksum
}
if len(res) < len(assetNames) {
return assetMap, ErrPartialResult
}
return assetMap, nil
}
func (ds *Datastore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) error {
stmt := `
UPDATE
mdm_config_assets
SET
deleted_at = CURRENT_TIMESTAMP(),
deletion_uuid = ?
WHERE
name IN (?) AND deletion_uuid = ''
`
deletionUUID := uuid.New().String()
stmt, args, err := sqlx.In(stmt, deletionUUID, assetNames)
if err != nil {
return ctxerr.Wrap(ctx, err, "sqlx.In DeleteMDMConfigAssetsByName")
}
_, err = ds.writer(ctx).ExecContext(ctx, stmt, args...)
return ctxerr.Wrap(ctx, err, "deleting mdm config assets")
}
// ListIOSAndIPadOSToRefetch returns the UUIDs of iPhones/iPads that should be refetched
// (their details haven't been updated in the given `interval`).
func (ds *Datastore) ListIOSAndIPadOSToRefetch(ctx context.Context, interval time.Duration) (uuids []string, err error) {

View file

@ -16,13 +16,11 @@ import (
"github.com/VividCortex/mysqlerr"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
fleetmdm "github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
"github.com/fleetdm/fleet/v4/server/ptr"
@ -76,6 +74,7 @@ func TestMDMApple(t *testing.T) {
{"MDMAppleSetPendingDeclarationsAs", testMDMAppleSetPendingDeclarationsAs},
{"SetOrUpdateMDMAppleDeclaration", testSetOrUpdateMDMAppleDDMDeclaration},
{"DEPAssignmentUpdates", testMDMAppleDEPAssignmentUpdates},
{"TestMDMConfigAsset", testMDMConfigAsset},
{"ListIOSAndIPadOSToRefetch", testListIOSAndIPadOSToRefetch},
{"MDMAppleUpsertHostIOSiPadOS", testMDMAppleUpsertHostIOSIPadOS},
{"IngestMDMAppleDevicesFromDEPSyncIOSIPadOS", testIngestMDMAppleDevicesFromDEPSyncIOSIPadOS},
@ -3001,14 +3000,10 @@ func testGetMDMAppleCommandResults(t *testing.T, ds *Datastore) {
}
func createMDMAppleCommanderAndStorage(t *testing.T, ds *Datastore) (*apple_mdm.MDMAppleCommander, *NanoMDMStorage) {
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
mdmStorage, err := ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
mdmStorage, err := ds.NewMDMAppleMDMStorage()
require.NoError(t, err)
return apple_mdm.NewMDMAppleCommander(mdmStorage, pusherFunc(okPusherFunc), config.MDMConfig{}), mdmStorage
return apple_mdm.NewMDMAppleCommander(mdmStorage, pusherFunc(okPusherFunc)), mdmStorage
}
func okPusherFunc(ctx context.Context, ids []string) (map[string]*push.Response, error) {
@ -4571,7 +4566,7 @@ func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) {
// default state
checkLockWipeState(t, status, true, false, false, false, false, false)
appleStore, err := ds.NewMDMAppleMDMStorage(nil, nil)
appleStore, err := ds.NewMDMAppleMDMStorage()
require.NoError(t, err)
// record a request to lock the host
@ -5506,6 +5501,95 @@ func createRawAppleCmd(reqType, cmdUUID string) string {
</plist>`, reqType, cmdUUID)
}
func testMDMConfigAsset(t *testing.T, ds *Datastore) {
ctx := context.Background()
assets := []fleet.MDMConfigAsset{
{
Name: fleet.MDMAssetCACert,
Value: []byte("some bytes"),
},
{
Name: fleet.MDMAssetCAKey,
Value: []byte("some other bytes"),
},
}
wantAssets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
for _, a := range assets {
wantAssets[a.Name] = a
}
err := ds.InsertMDMConfigAssets(ctx, assets)
require.NoError(t, err)
a, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
require.NoError(t, err)
require.Equal(t, wantAssets, a)
h, err := ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
require.NoError(t, err)
require.Len(t, h, 2)
require.NotEmpty(t, h[fleet.MDMAssetCACert])
require.NotEmpty(t, h[fleet.MDMAssetCAKey])
// try to fetch an asset that doesn't exist
var nfe fleet.NotFoundError
a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetABMCert})
require.ErrorAs(t, err, &nfe)
require.Nil(t, a)
h, err = ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{fleet.MDMAssetABMCert})
require.ErrorAs(t, err, &nfe)
require.Nil(t, h)
// try to fetch a mix of assets that exist and doesn't exist
a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetABMCert})
require.ErrorIs(t, err, ErrPartialResult)
require.Len(t, a, 1)
h, err = ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetABMCert})
require.ErrorIs(t, err, ErrPartialResult)
require.Len(t, h, 1)
require.NotEmpty(t, h[fleet.MDMAssetCACert])
// Soft delete the assets
err = ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
require.NoError(t, err)
a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
require.ErrorAs(t, err, &nfe)
require.Nil(t, a)
h, err = ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
require.ErrorAs(t, err, &nfe)
require.Nil(t, h)
// Verify that they're still in the DB. Values should be encrypted.
type assetRow struct {
Name string `db:"name"`
Value []byte `db:"value"`
DeletionUUID string `db:"deletion_uuid"`
DeletedAt time.Time `db:"deleted_at"`
}
var ar []assetRow
err = sqlx.SelectContext(ctx, ds.reader(ctx), &ar, "SELECT name, value, deletion_uuid, deleted_at FROM mdm_config_assets WHERE name IN (?, ?) ORDER BY name", fleet.MDMAssetCACert, fleet.MDMAssetCAKey)
require.NoError(t, err)
require.Len(t, ar, 2)
for i, a := range ar {
require.Equal(t, assets[i].Name, fleet.MDMAssetName(a.Name))
require.NotEmpty(t, a.Value)
d, err := decrypt(a.Value, ds.serverPrivateKey)
require.NoError(t, err)
require.Equal(t, assets[i].Value, d)
require.NotEmpty(t, a.DeletionUUID)
require.NotEmpty(t, a.DeletedAt)
}
}
func testListIOSAndIPadOSToRefetch(t *testing.T, ds *Datastore) {
ctx := context.Background()

View file

@ -25,6 +25,7 @@ type dbOptions struct {
tracingConfig *config.LoggingConfig
minLastOpenedAtDiff time.Duration
sqlMode string
privateKey string
}
// Logger adds a logger to the datastore.
@ -73,6 +74,7 @@ func TracingEnabled(lconfig *config.LoggingConfig) DBOption {
func WithFleetConfig(conf *config.FleetConfig) DBOption {
return func(o *dbOptions) error {
o.minLastOpenedAtDiff = conf.Osquery.MinSoftwareLastOpenedAtDiff
o.privateKey = conf.Server.PrivateKey
return nil
}
}

View file

@ -112,7 +112,7 @@ func (e *existsError) Resource() string {
return e.ResourceType
}
func isDuplicate(err error) bool {
func IsDuplicate(err error) bool {
err = ctxerr.Cause(err)
if driverErr, ok := err.(*mysql.MySQLError); ok {
if driverErr.Number == mysqlerr.ER_DUP_ENTRY {
@ -190,3 +190,7 @@ func isMySQLAccessDenied(err error) bool {
}
return false
}
// ErrPartialResult indicates that a batch operation was completed,
// but some of the results are missing or incomplete.
var ErrPartialResult = errors.New("batch operation completed with partial results")

View file

@ -2354,7 +2354,7 @@ func (ds *Datastore) SetOrUpdateDeviceAuthToken(ctx context.Context, hostID uint
`
_, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, authToken)
if err != nil {
if isDuplicate(err) {
if IsDuplicate(err) {
return fleet.ConflictError{Message: "auth token conflicts with another host"}
}
return ctxerr.Wrap(ctx, err, "upsert host's device auth token")

View file

@ -27,7 +27,7 @@ func (ds *Datastore) NewInvite(ctx context.Context, i *fleet.Invite) (*fleet.Inv
result, err := tx.ExecContext(ctx, sqlStmt, i.InvitedBy, i.Email,
i.Name, i.Position, i.Token, i.SSOEnabled, i.GlobalRole)
if err != nil && isDuplicate(err) {
if err != nil && IsDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("Invite", i.Email))
} else if err != nil {
return ctxerr.Wrap(ctx, err, "create invite")

View file

@ -1008,7 +1008,7 @@ func (ds *Datastore) MDMInsertEULA(ctx context.Context, eula *fleet.MDMEULA) err
_, err := ds.writer(ctx).ExecContext(ctx, stmt, eula.Name, eula.Bytes, eula.Token)
if err != nil {
if isDuplicate(err) {
if IsDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("MDMEULA", eula.Token))
}
return ctxerr.Wrap(ctx, err, "create EULA")

View file

@ -13,9 +13,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
mdm_types "github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth"
"github.com/fleetdm/fleet/v4/server/ptr"
@ -6112,14 +6110,10 @@ func testMDMEULA(t *testing.T, ds *Datastore) {
func testSCEPRenewalHelpers(t *testing.T, ds *Datastore) {
ctx := context.Background()
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
scepDepot, err := ds.NewSCEPDepot(testCertPEM, testKeyPEM)
scepDepot, err := ds.NewSCEPDepot()
require.NoError(t, err)
nanoStorage, err := ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
nanoStorage, err := ds.NewMDMAppleMDMStorage()
require.NoError(t, err)
addCert := func(notAfter time.Time, h *fleet.Host) {

View file

@ -91,7 +91,7 @@ func (ds *Datastore) MDMWindowsInsertEnrolledDevice(ctx context.Context, device
device.MDMNotInOOBE,
device.HostUUID)
if err != nil {
if isDuplicate(err) {
if IsDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsEnrolledDevice", device.MDMHardwareID))
}
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsEnrolledDevice")
@ -153,7 +153,7 @@ func (ds *Datastore) mdmWindowsInsertCommandForHostsDB(ctx context.Context, tx s
VALUES (?, ?, ?)
`
if _, err := tx.ExecContext(ctx, stmt, cmd.CommandUUID, cmd.RawCommand, cmd.TargetLocURI); err != nil {
if isDuplicate(err) {
if IsDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommand", cmd.CommandUUID))
}
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommand")
@ -175,7 +175,7 @@ VALUES ((SELECT id FROM mdm_windows_enrollments WHERE host_uuid = ? OR mdm_devic
`
if _, err := tx.ExecContext(ctx, stmt, hostUUIDOrDeviceID, hostUUIDOrDeviceID, commandUUID); err != nil {
if isDuplicate(err) {
if IsDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommandQueue", commandUUID))
}
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommandQueue")
@ -1513,7 +1513,7 @@ INSERT INTO
res, err := tx.ExecContext(ctx, insertProfileStmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID, cp.Name, teamID)
if err != nil {
switch {
case isDuplicate(err):
case IsDuplicate(err):
return &existsError{
ResourceType: "MDMWindowsConfigProfile.Name",
Identifier: cp.Name,
@ -1579,7 +1579,7 @@ ON DUPLICATE KEY UPDATE
res, err := ds.writer(ctx).ExecContext(ctx, stmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID, cp.Name, teamID)
if err != nil {
switch {
case isDuplicate(err):
case IsDuplicate(err):
return &existsError{
ResourceType: "MDMWindowsConfigProfile.Name",
Identifier: cp.Name,

View file

@ -0,0 +1,48 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20240521143023, Down_20240521143023)
}
func Up_20240521143023(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE mdm_config_assets (
id int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
-- name is used for humans to identify what value is stored in this row
name varchar(256) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
-- value holds the raw value of the asset
value longblob NOT NULL,
-- deleted_at is used to track the date in which the row was marked as
-- deleted for auditing/debugging purposes.
deleted_at timestamp NULL DEFAULT NULL,
-- deletion_uuid is used as part of an UNIQUE KEY to guarantee that only
-- one non-deleted row with a given name exists. This value should be filled
-- along with deleted_at
deletion_uuid varchar(127) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
-- md5_checksum holds the binary checksum of the value column.
md5_checksum BINARY(16) NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY idx_mdm_config_assets_name_deletion_uuid (name, deletion_uuid)
)`)
if err != nil {
return fmt.Errorf("creating mdm_config_assets table: %w", err)
}
return nil
}
func Down_20240521143023(tx *sql.Tx) error {
return nil
}

Some files were not shown because too many files have changed in this diff Show more