diff --git a/changes/19864-vpp-token-crud b/changes/19864-vpp-token-crud new file mode 100644 index 0000000000..ee4a92e80f --- /dev/null +++ b/changes/19864-vpp-token-crud @@ -0,0 +1,2 @@ +- Adds the functionality for the `POST /mdm/apple/vpp_token`, `DELETE /mdm/apple/vpp_token` and +`GET /vpp` endpoints. \ No newline at end of file diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index e207d977d7..ea94d88a01 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -4224,41 +4224,20 @@ func decrypt(encrypted []byte, privateKey string) ([]byte, error) { decrypted, err := aesGCM.Open(nil, nonce, ciphertext, nil) if err != nil { - return nil, fmt.Errorf("generate nonce: %w", err) + return nil, fmt.Errorf("decrypting: %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)) + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if err := insertMDMConfigAssets(ctx, tx, assets, ds.serverPrivateKey); err != nil { + return ctxerr.Wrap(ctx, err, "insert mdm config assets") } - 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 nil }) - - 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) { @@ -4344,6 +4323,16 @@ WHERE name IN (?) AND deletion_uuid = ''` } func (ds *Datastore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if err := softDeleteMDMConfigAssetsByName(ctx, tx, assetNames); err != nil { + return ctxerr.Wrap(ctx, err, "delete mdm config assets by name") + } + + return nil + }) +} + +func softDeleteMDMConfigAssetsByName(ctx context.Context, tx sqlx.ExtContext, assetNames []fleet.MDMAssetName) error { stmt := ` UPDATE mdm_config_assets @@ -4358,13 +4347,60 @@ WHERE stmt, args, err := sqlx.In(stmt, deletionUUID, assetNames) if err != nil { - return ctxerr.Wrap(ctx, err, "sqlx.In DeleteMDMConfigAssetsByName") + return ctxerr.Wrap(ctx, err, "sqlx.In softDeleteMDMConfigAssetsByName") } - _, err = ds.writer(ctx).ExecContext(ctx, stmt, args...) + _, err = tx.ExecContext(ctx, stmt, args...) return ctxerr.Wrap(ctx, err, "deleting mdm config assets") } +func insertMDMConfigAssets(ctx context.Context, tx sqlx.ExtContext, assets []fleet.MDMConfigAsset, privateKey string) 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, privateKey) + 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 := tx.ExecContext(ctx, stmt, args...) + + return ctxerr.Wrap(ctx, err, "writing mdm config assets to db") +} + +func (ds *Datastore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var names []fleet.MDMAssetName + for _, a := range assets { + names = append(names, a.Name) + } + + if err := softDeleteMDMConfigAssetsByName(ctx, tx, names); err != nil { + return ctxerr.Wrap(ctx, err, "upsert mdm config assets soft delete") + } + + if err := insertMDMConfigAssets(ctx, tx, assets, ds.serverPrivateKey); err != nil { + return ctxerr.Wrap(ctx, err, "upsert mdm config assets insert") + } + + return nil + }) +} + // 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) { diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 142cd81294..c9cd02e4bf 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -5508,11 +5508,11 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { assets := []fleet.MDMConfigAsset{ { Name: fleet.MDMAssetCACert, - Value: []byte("some bytes"), + Value: []byte("a"), }, { Name: fleet.MDMAssetCAKey, - Value: []byte("some other bytes"), + Value: []byte("b"), }, } wantAssets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{} @@ -5552,6 +5552,37 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { require.Len(t, h, 1) require.NotEmpty(t, h[fleet.MDMAssetCACert]) + // Replace the assets + + newAssets := []fleet.MDMConfigAsset{ + { + Name: fleet.MDMAssetCACert, + Value: []byte("c"), + }, + { + Name: fleet.MDMAssetCAKey, + Value: []byte("d"), + }, + } + + wantNewAssets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{} + for _, a := range newAssets { + wantNewAssets[a.Name] = a + } + + err = ds.ReplaceMDMConfigAssets(ctx, newAssets) + require.NoError(t, err) + + a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) + require.NoError(t, err) + require.Equal(t, wantNewAssets, 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]) + // Soft delete the assets err = ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) @@ -5576,19 +5607,25 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { 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) + err = sqlx.SelectContext(ctx, ds.reader(ctx), &ar, "SELECT name, value, deletion_uuid, deleted_at FROM mdm_config_assets") require.NoError(t, err) - require.Len(t, ar, 2) + require.Len(t, ar, 4) - 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) + expected := make(map[string]fleet.MDMConfigAsset) + + for _, a := range append(assets, newAssets...) { + expected[string(a.Value)] = a + } + + for _, got := range ar { + d, err := decrypt(got.Value, ds.serverPrivateKey) require.NoError(t, err) - require.Equal(t, assets[i].Value, d) - require.NotEmpty(t, a.DeletionUUID) - require.NotEmpty(t, a.DeletedAt) + require.Equal(t, expected[string(d)].Name, fleet.MDMAssetName(got.Name)) + require.NotEmpty(t, got.Value) + require.Equal(t, expected[string(d)].Value, d) + require.NotEmpty(t, got.DeletionUUID) + require.NotEmpty(t, got.DeletedAt) } } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 55addb499b..2ae2ac1a7f 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1294,6 +1294,11 @@ type Datastore interface { // DeleteMDMConfigAssetsByName soft deletes the given MDM config assets. DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) error + // ReplaceMDMConfigAssets replaces (soft delete if they exist + insert) `MDMConfigAsset`s in a + // single transaction. Useful for "renew" flows where users are updating the assets with newly + // generated ones. + ReplaceMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset) error + /////////////////////////////////////////////////////////////////////////////// // Microsoft MDM diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index a7e695c870..6b4052c6bb 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -564,6 +564,8 @@ const ( // MDMAssetSCEPChallenge defines the shared secret used to issue SCEP // certificatges to Apple devices. MDMAssetSCEPChallenge MDMAssetName = "scep_challenge" + // MDMAssetVPPToken is the name of the token used by MDM to authenticate to Apple's VPP service. + MDMAssetVPPToken MDMAssetName = "vpp_token" ) type MDMConfigAsset struct { @@ -628,3 +630,29 @@ func FilterMacOSOnlyProfilesFromIOSIPadOS(profiles []*MDMAppleProfilePayload) [] // RefetchCommandUUIDPrefix is the prefix used for MDM commands used to refetch information from iOS/iPadOS devices. const RefetchCommandUUIDPrefix = "REFETCH-" + +// VPPTokenInfo is the representation of the VPP token that we send out via API. +type VPPTokenInfo struct { + OrgName string `json:"org_name"` + RenewDate string `json:"renew_date"` + Location string `json:"location"` +} + +// VPPTokenRaw is the representation of the decoded JSON object that is downloaded from ABM. +type VPPTokenRaw struct { + OrgName string `json:"orgName"` + Token string `json:"token"` + ExpDate string `json:"expDate"` +} + +// VPPTokenData is the VPP data we store in the DB. +type VPPTokenData struct { + // Location comes from an Apple API: + // https://developer.apple.com/documentation/devicemanagement/client_config. It is the name of + // the "library" of apps in ABM that is associated with this VPP token. + Location string `json:"location"` + + // Token is the token that is downloaded from ABM. It is a base64 encoded JSON object with the + // structure of `VPPTokenRaw`. + Token string `json:"token"` +} diff --git a/server/fleet/service.go b/server/fleet/service.go index dea06339ee..543836edef 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -703,6 +703,10 @@ type Service interface { UploadMDMAppleAPNSCert(ctx context.Context, cert io.ReadSeeker) error DeleteMDMAppleAPNSCert(ctx context.Context) error + UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSeeker) error + GetMDMAppleVPPToken(ctx context.Context) (*VPPTokenInfo, error) + DeleteMDMAppleVPPToken(ctx context.Context) error + // GetHostDEPAssignment retrieves the host DEP assignment for the specified host. GetHostDEPAssignment(ctx context.Context, host *Host) (*HostDEPAssignment, error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index d6acddb92e..849076f210 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -847,6 +847,8 @@ type GetAllMDMConfigAssetsHashesFunc func(ctx context.Context, assetNames []flee type DeleteMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) error +type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset) error + type WSTEPStoreCertificateFunc func(ctx context.Context, name string, crt *x509.Certificate) error type WSTEPNewSerialFunc func(ctx context.Context) (*big.Int, error) @@ -2222,6 +2224,9 @@ type DataStore struct { DeleteMDMConfigAssetsByNameFunc DeleteMDMConfigAssetsByNameFunc DeleteMDMConfigAssetsByNameFuncInvoked bool + ReplaceMDMConfigAssetsFunc ReplaceMDMConfigAssetsFunc + ReplaceMDMConfigAssetsFuncInvoked bool + WSTEPStoreCertificateFunc WSTEPStoreCertificateFunc WSTEPStoreCertificateFuncInvoked bool @@ -5321,6 +5326,13 @@ func (s *DataStore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames return s.DeleteMDMConfigAssetsByNameFunc(ctx, assetNames) } +func (s *DataStore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error { + s.mu.Lock() + s.ReplaceMDMConfigAssetsFuncInvoked = true + s.mu.Unlock() + return s.ReplaceMDMConfigAssetsFunc(ctx, assets) +} + func (s *DataStore) WSTEPStoreCertificate(ctx context.Context, name string, crt *x509.Certificate) error { s.mu.Lock() s.WSTEPStoreCertificateFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 2d7ad4ded3..66026bc90a 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -722,6 +722,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/mdm/apple/apns_certificate", uploadMDMAppleAPNSCertEndpoint, uploadMDMAppleAPNSCertRequest{}) ue.DELETE("/api/_version_/fleet/mdm/apple/apns_certificate", deleteMDMAppleAPNSCertEndpoint, deleteMDMAppleAPNSCertRequest{}) + ue.POST("/api/_version_/fleet/mdm/apple/vpp_token", uploadMDMAppleVPPTokenEndpoint, uploadMDMAppleVPPTokenRequest{}) + ue.GET("/api/_version_/fleet/vpp", getMDMAppleVPPTokenEndpoint, getMDMAppleVPPTokenRequest{}) + ue.DELETE("/api/_version_/fleet/mdm/apple/vpp_token", deleteMDMAppleVPPTokenEndpoint, deleteMDMAppleVPPTokenRequest{}) + // Deprecated: GET /mdm/apple_bm is now deprecated, replaced by the // GET /abm endpoint. ue.GET("/api/_version_/fleet/mdm/apple_bm", getAppleBMEndpoint, nil) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index bd909f0102..c8f072a594 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -94,6 +94,7 @@ type integrationMDMTestSuite struct { mdmCommander *apple_mdm.MDMAppleCommander logger kitlog.Logger scepChallenge string + appleVPPConfigSrv *httptest.Server mockedDownloadFleetdmMeta fleetdbase.Metadata } @@ -302,8 +303,27 @@ func (s *integrationMDMTestSuite) SetupSuite() { } _, _ = w.Write(resp) })) + + s.appleVPPConfigSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := []byte(`{"locationName": "Fleet Location One"}`) + if strings.Contains(r.URL.RawQuery, "invalidToken") { + // This replicates the response sent back from Apple's VPP endpoints when an invalid + // token is passed. For more details see: + // https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes + // https://developer.apple.com/documentation/devicemanagement/client_config + // https://developer.apple.com/documentation/devicemanagement/errorresponse + // Note that the Apple server returns 200 in this case. + resp = []byte(`{"errorNumber": 9622,"errorMessage": "Invalid authentication token"}`) + } + + if strings.Contains(r.URL.RawQuery, "serverError") { + resp = []byte(`{"errorNumber": 9603,"errorMessage": "Internal server error"}`) + w.WriteHeader(http.StatusInternalServerError) + } + + _, _ = w.Write(resp) + })) s.T().Setenv("TEST_FLEETDM_API_URL", fleetdmSrv.URL) - s.T().Cleanup(fleetdmSrv.Close) s.mockedDownloadFleetdmMeta = fleetdbase.Metadata{ MSIURL: fmt.Sprintf("https://download-testing.fleetdm.com/archive/stable/%s/fleetd-base.msi", uuid.NewString()), @@ -319,7 +339,6 @@ func (s *integrationMDMTestSuite) SetupSuite() { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) require.NoError(s.T(), json.NewEncoder(w).Encode(s.mockedDownloadFleetdmMeta)) - } })) s.T().Setenv("FLEET_DEV_DOWNLOAD_FLEETDM_URL", downloadFleetdmSrv.URL) @@ -334,6 +353,9 @@ func (s *integrationMDMTestSuite) SetupSuite() { // enable MDM flows s.appleCoreCertsSetup() s.enableABM() + + s.T().Cleanup(fleetdmSrv.Close) + s.T().Cleanup(s.appleVPPConfigSrv.Close) } func (s *integrationMDMTestSuite) TearDownSuite() { @@ -960,7 +982,7 @@ func (s *integrationMDMTestSuite) TestGetMDMCSR() { // Validate errors if no private key is set testSetEmptyPrivateKey = true t.Cleanup(func() { testSetEmptyPrivateKey = false }) - s.uploadAPNSCert([]byte("-----BEGIN CERTIFICATE-----\nZm9vCg==\n-----END CERTIFICATE-----"), http.StatusInternalServerError, "Couldn't upload APNs certificate. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", []byte("-----BEGIN CERTIFICATE-----\nZm9vCg==\n-----END CERTIFICATE-----"), http.StatusInternalServerError, "Couldn't upload APNs certificate. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") r := s.Do("GET", "/api/latest/fleet/mdm/apple/request_csr", getMDMAppleCSRRequest{}, http.StatusInternalServerError) require.Contains(t, extractServerErrorText(r.Body), "Couldn't download signed CSR. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") @@ -978,7 +1000,7 @@ func (s *integrationMDMTestSuite) TestGetMDMCSR() { require.Nil(t, assets) // trying to upload a certificate without generating a private key first is not allowed - s.uploadAPNSCert([]byte("-----BEGIN CERTIFICATE-----\nZm9vCg==\n-----END CERTIFICATE-----"), http.StatusBadRequest, "Please generate a private key first.") + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", []byte("-----BEGIN CERTIFICATE-----\nZm9vCg==\n-----END CERTIFICATE-----"), http.StatusBadRequest, "Please generate a private key first.") // Check that we return bad gateway if the website API errors s.FailNextCSRRequestWith(http.StatusInternalServerError) @@ -988,22 +1010,22 @@ func (s *integrationMDMTestSuite) TestGetMDMCSR() { require.Contains(t, errResp.Errors[0].Reason, "FleetDM CSR request failed") // Invalid APNS cert upload attempt - s.uploadAPNSCert([]byte("invalid-cert"), http.StatusUnprocessableEntity, "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal.") + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", []byte("invalid-cert"), http.StatusUnprocessableEntity, "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal.") // simulate a renew flow s.appleCoreCertsSetup() } -func (s *integrationMDMTestSuite) uploadAPNSCert(pemBytes []byte, expectedStatus int, wantErr string) { +func (s *integrationMDMTestSuite) uploadDataViaForm(endpoint, fieldName, fileName string, data []byte, expectedStatus int, wantErr string) { t := s.T() var b bytes.Buffer w := multipart.NewWriter(&b) // add the package field - fw, err := w.CreateFormFile("certificate", "certificate.pem") + fw, err := w.CreateFormFile(fieldName, fileName) require.NoError(t, err) - _, err = io.Copy(fw, bytes.NewBuffer(pemBytes)) + _, err = io.Copy(fw, bytes.NewBuffer(data)) require.NoError(t, err) w.Close() @@ -1014,13 +1036,59 @@ func (s *integrationMDMTestSuite) uploadAPNSCert(pemBytes []byte, expectedStatus "Authorization": fmt.Sprintf("Bearer %s", s.token), } - res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/apns_certificate", b.Bytes(), expectedStatus, headers) + res := s.DoRawWithHeaders("POST", endpoint, b.Bytes(), expectedStatus, headers) if wantErr != "" { errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, wantErr) } } +func (s *integrationMDMTestSuite) TestMDMVPPToken() { + t := s.T() + // Invalid token + testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL + "?invalidToken" + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusUnprocessableEntity, "Invalid token. Please provide a valid content token from Apple Business Manager.") + + // Simulate a server error from the Apple API + testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL + "?serverError" + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "calling Apple VPP config endpoint failed with status 500") + + // Valid token + orgName := "Fleet Device Management Inc." + location := "Fleet Location One" + token := "mycooltoken" + expDate := "2025-06-24T15:50:50+0000" + tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName) + testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") + + // Get the token + var resp getMDMAppleVPPTokenResponse + s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusOK, &resp) + require.NoError(t, resp.Err) + require.Equal(t, orgName, resp.OrgName) + require.Equal(t, location, resp.Location) + require.Equal(t, expDate, resp.RenewDate) + + // Simulate renewal flow + orgName = "Fleet Device Management Inc. New Org Name" + token = "myothercooltoken" + expDate = "2026-06-24T15:50:50+0000" + tokenJSON = fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName) + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") + + resp = getMDMAppleVPPTokenResponse{} + s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusOK, &resp) + require.NoError(t, resp.Err) + require.Equal(t, orgName, resp.OrgName) + require.Equal(t, location, resp.Location) + require.Equal(t, expDate, resp.RenewDate) + + // Delete and check that it's not appearing anymore + s.Do("DELETE", "/api/latest/fleet/mdm/apple/vpp_token", &deleteMDMAppleVPPTokenRequest{}, http.StatusNoContent) + s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusNotFound, &resp) +} + func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() { t := s.T() @@ -6402,7 +6470,7 @@ func (s *integrationMDMTestSuite) TestRunMDMCommands() { // create a Windows host enrolled in MDM enrolledWindows := createOrbitEnrolledHost(t, "windows", "h1", s.ds) - //deviceID := "DB257C3A08778F4FB61E2749066C1F27" + // deviceID := "DB257C3A08778F4FB61E2749066C1F27" mdmDevice := mdmtest.NewTestMDMClientWindowsProgramatic(s.server.URL, *enrolledWindows.OrbitNodeKey) err := mdmDevice.Enroll() require.NoError(t, err) @@ -8245,7 +8313,6 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { // lock the host without viewing the PIN s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent) - } func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { @@ -8927,7 +8994,7 @@ func (s *integrationMDMTestSuite) appleCoreCertsSetup() { certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, testCert, csr.PublicKey, testKey) require.NoError(t, err) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - s.uploadAPNSCert(certPEM, http.StatusAccepted, "") + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", certPEM, http.StatusAccepted, "") assets, err = s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey, fleet.MDMAssetAPNSCert}) require.NoError(t, err) diff --git a/server/service/mdm.go b/server/service/mdm.go index 4c74601bc0..76199a860e 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "errors" @@ -2444,3 +2445,254 @@ func (svc *Service) DeleteMDMAppleAPNSCert(ctx context.Context) error { return svc.ds.SaveAppConfig(ctx, appCfg) } + +//////////////////////////////////////////////////////////////////////////////// +// POST /mdm/apple/vpp_token +//////////////////////////////////////////////////////////////////////////////// + +type uploadMDMAppleVPPTokenRequest struct { + File *multipart.FileHeader +} + +func (uploadMDMAppleVPPTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + decoded := uploadMDMAppleVPPTokenRequest{} + + err := r.ParseMultipartForm(512 * units.MiB) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { + return nil, &fleet.BadRequestError{ + Message: "token multipart field is required", + InternalErr: err, + } + } + + decoded.File = r.MultipartForm.File["token"][0] + + return &decoded, nil +} + +type uploadMDMAppleVPPTokenResponse struct { + Err error `json:"error,omitempty"` +} + +func (r uploadMDMAppleVPPTokenResponse) Status() int { return http.StatusAccepted } + +func (r uploadMDMAppleVPPTokenResponse) error() error { + return r.Err +} + +func uploadMDMAppleVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*uploadMDMAppleVPPTokenRequest) + file, err := req.File.Open() + if err != nil { + return uploadMDMAppleAPNSCertResponse{Err: err}, nil + } + defer file.Close() + + if err := svc.UploadMDMAppleVPPToken(ctx, file); err != nil { + return &uploadMDMAppleVPPTokenResponse{Err: err}, nil + } + + return &uploadMDMAppleVPPTokenResponse{}, nil +} + +func (svc *Service) UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSeeker) error { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { + return err + } + + privateKey := svc.config.Server.PrivateKey + if testSetEmptyPrivateKey { + privateKey = "" + } + + if len(privateKey) == 0 { + return ctxerr.New(ctx, "Couldn't upload content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } + + if token == nil { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) + } + + tokenBytes, err := io.ReadAll(token) + if err != nil { + return ctxerr.Wrap(ctx, err, "reading VPP token") + } + + locName, tokenValid, err := getVPPConfig(string(tokenBytes)) + if err != nil { + return ctxerr.Wrap(ctx, err, "validating VPP token with Apple") + } + + if !tokenValid { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) + } + + decodedTokenBytes, err := base64.StdEncoding.DecodeString(string(tokenBytes)) + if err != nil { + return ctxerr.Wrap(ctx, err, "decoding VPP token") + } + + data := fleet.VPPTokenData{ + Token: string(decodedTokenBytes), + Location: locName, + } + + dataBytes, err := json.Marshal(data) + if err != nil { + return ctxerr.Wrap(ctx, err, "creating VPP data object for storage") + } + + err = svc.ds.ReplaceMDMConfigAssets(ctx, []fleet.MDMConfigAsset{ + {Name: fleet.MDMAssetVPPToken, Value: dataBytes}, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "writing VPP token to db") + } + + return nil +} + +var testOverrideAppleVPPConfigURL string + +// getVPPConfig fetches the VPP config from Apple's VPP API. This doubles as a verification that the +// user-provided VPP token is valid. +func getVPPConfig(token string) (string, bool, error) { + url := "https://vpp.itunes.apple.com/mdm/v2/client/config" + if testOverrideAppleVPPConfigURL != "" { + url = testOverrideAppleVPPConfigURL + } + + bearer := fmt.Sprintf("Bearer %s", token) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", false, fmt.Errorf("creating request to Apple VPP endpoint: %w", err) + } + + req.Header.Add("Authorization", bearer) + + client := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)) + resp, err := client.Do(req) + if err != nil { + return "", false, fmt.Errorf("making request to Apple VPP endpoint: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", false, fmt.Errorf("reading response body from Apple VPP endpoint: %w", err) + } + + // For some reason, Apple returns 200 OK even if you pass an invalid token in the Auth header. + // We will need to parse the response and check to see if it contains an error. + + var respJSON struct { + LocationName string `json:"locationName"` + ErrorNumber int `json:"errorNumber"` + } + + if err := json.Unmarshal(body, &respJSON); err != nil { + return "", false, fmt.Errorf("parsing response body from Apple VPP endpoint: %w", err) + } + + // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes + if resp.StatusCode == 401 || respJSON.ErrorNumber == 9622 { + return "", false, nil + } + + if resp.StatusCode != http.StatusOK { + return "", false, fmt.Errorf("calling Apple VPP config endpoint failed with status %d", resp.StatusCode) + } + + return respJSON.LocationName, true, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// GET /vpp +//////////////////////////////////////////////////////////////////////////////// + +type getMDMAppleVPPTokenRequest struct{} + +type getMDMAppleVPPTokenResponse struct { + *fleet.VPPTokenInfo + Err error `json:"error,omitempty"` +} + +func (r getMDMAppleVPPTokenResponse) error() error { + return r.Err +} + +func getMDMAppleVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + vpp, err := svc.GetMDMAppleVPPToken(ctx) + if err != nil { + return &getMDMAppleVPPTokenResponse{Err: err}, nil + } + + return &getMDMAppleVPPTokenResponse{VPPTokenInfo: vpp}, nil +} + +func (svc *Service) GetMDMAppleVPPToken(ctx context.Context) (*fleet.VPPTokenInfo, error) { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionRead); err != nil { + return nil, err + } + + assetMap, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken}) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get mdm config assets by name VPP token") + } + + var tokenData fleet.VPPTokenData + if err := json.Unmarshal(assetMap[fleet.MDMAssetVPPToken].Value, &tokenData); err != nil { + return nil, ctxerr.Wrap(ctx, err, "unmarshaling VPP token data") + } + + var rawToken fleet.VPPTokenRaw + if err := json.Unmarshal([]byte(tokenData.Token), &rawToken); err != nil { + return nil, ctxerr.Wrap(ctx, err, "unmarshaling VPP token") + } + + info := fleet.VPPTokenInfo{ + Location: tokenData.Location, + RenewDate: rawToken.ExpDate, + OrgName: rawToken.OrgName, + } + + return &info, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// DELETE /mdm/apple/vpp_token +//////////////////////////////////////////////////////////////////////////////// + +type deleteMDMAppleVPPTokenRequest struct{} + +type deleteMDMAppleVPPTokenResponse struct { + Err error `json:"error,omitempty"` +} + +func (r deleteMDMAppleVPPTokenResponse) error() error { return r.Err } + +func (r deleteMDMAppleVPPTokenResponse) Status() int { return http.StatusNoContent } + +func deleteMDMAppleVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + if err := svc.DeleteMDMAppleVPPToken(ctx); err != nil { + return &deleteMDMAppleVPPTokenResponse{Err: err}, nil + } + + return &deleteMDMAppleVPPTokenResponse{}, nil +} + +func (svc *Service) DeleteMDMAppleVPPToken(ctx context.Context) error { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { + return err + } + + return svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken}) +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 52e8cb0d41..014f6a17ad 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -153,6 +153,15 @@ func TestMDMAppleAuthorization(t *testing.T) { err = svc.DeleteMDMAppleAPNSCert(ctx) // Don't expect anything other than an authz error here, since this is pretty much just a DB wrapper. checkAuthErr(t, shouldFailWithAuth, err) + + err = svc.UploadMDMAppleVPPToken(ctx, nil) + checkAuthErr(t, shouldFailWithAuth, err) + + _, err = svc.GetMDMAppleVPPToken(ctx) + checkAuthErr(t, shouldFailWithAuth, err) + + err = svc.DeleteMDMAppleVPPToken(ctx) + checkAuthErr(t, shouldFailWithAuth, err) } // Only global admins can access the endpoints.