From 34cc36c9258fe3771d01a287c56eb743b9cb1011 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Tue, 2 Jul 2024 11:46:59 -0400 Subject: [PATCH 01/38] feat: VPP token CRUD (#20108) # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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] Manual QA for all new/changed functionality --- changes/19864-vpp-token-crud | 2 + server/datastore/mysql/apple_mdm.go | 92 ++++++--- server/datastore/mysql/apple_mdm_test.go | 59 +++++- server/fleet/datastore.go | 5 + server/fleet/mdm.go | 28 +++ server/fleet/service.go | 4 + server/mock/datastore_mock.go | 12 ++ server/service/handler.go | 4 + server/service/integration_mdm_test.go | 91 ++++++-- server/service/mdm.go | 252 +++++++++++++++++++++++ server/service/mdm_test.go | 9 + 11 files changed, 507 insertions(+), 51 deletions(-) create mode 100644 changes/19864-vpp-token-crud 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. From d258c2f6530e01eb814bcb17be9fa5fe5edabfae Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Wed, 3 Jul 2024 17:34:24 -0400 Subject: [PATCH 02/38] feat: wip schema design (#20176) > Related issue: #19865 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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] 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 --- changes/19865-db-schema | 1 + .../tables/20240701113709_VPPDBUpdates.go | 87 +++++++++++++++++++ server/datastore/mysql/schema.sql | 49 ++++++++++- 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 changes/19865-db-schema create mode 100644 server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go diff --git a/changes/19865-db-schema b/changes/19865-db-schema new file mode 100644 index 0000000000..ede5f90ed0 --- /dev/null +++ b/changes/19865-db-schema @@ -0,0 +1 @@ +- Adds DB updates to support the VPP software feature. \ No newline at end of file diff --git a/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go new file mode 100644 index 0000000000..1e343006c5 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go @@ -0,0 +1,87 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240701113709, Down_20240701113709) +} + +func Up_20240701113709(tx *sql.Tx) error { + _, err := tx.Exec(` +-- This table is the VPP equivalent of the "software_installers" table. +-- This table is also used as a cache of the response from the "Get Assets" +-- Apple endpoint as well as the FleetDM website endpoint which will return +-- the app metadata. +-- If an asset has an entry here and an entry in vpp_apps_teams, then it has +-- been added to Fleet. +CREATE TABLE vpp_apps ( + adam_id VARCHAR(16) NOT NULL, + + -- This is a count of how many licenses are still available for this asset + available_count INT UNSIGNED, + + bundle_identifier VARCHAR(255) NOT NULL DEFAULT '', + icon_url VARCHAR(255) NOT NULL DEFAULT '', + name VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (adam_id) +)`) + if err != nil { + return fmt.Errorf("failed to create table vpp_apps: %w", err) + } + + _, err = tx.Exec(` +CREATE TABLE vpp_apps_teams ( + adam_id VARCHAR(16) NOT NULL, + team_id INT(10) UNSIGNED NOT NULL, + global_or_team_id INT(10) NOT NULL DEFAULT 0, + FOREIGN KEY (adam_id) REFERENCES vpp_apps (adam_id) ON DELETE CASCADE, + FOREIGN KEY (team_id) REFERENCES teams (id) ON DELETE CASCADE, + UNIQUE KEY idx_global_or_team_id_adam_id (global_or_team_id, adam_id) +)`) + if err != nil { + return fmt.Errorf("failed to create table vpp_apps_teams: %w", err) + } + + _, err = tx.Exec(` +-- This table is the VPP equivalent of the host_software_installs table. +-- It tracks the installation of VPP software on particular hosts. +CREATE TABLE host_vpp_software_installs ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + host_id INT(10) UNSIGNED NOT NULL, + + -- This is the adam_id of the VPP software that's being installed + adam_id VARCHAR(16) NOT NULL, + + -- This is the UUID of the MDM command issued to install the software + command_uuid VARCHAR(127), + user_id INT(10) UNSIGNED NULL, + + -- This indicates whether or not this was a self-service install + self_service TINYINT(1) NOT NULL DEFAULT FALSE, + + -- This is an ID for the event of "associating" the software with a host. + -- This value comes from the "eventId" field in the response here: + -- https://developer.apple.com/documentation/devicemanagement/associate_assets + associated_event_id VARCHAR(36), + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY(id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL, + FOREIGN KEY (adam_id) REFERENCES vpp_apps (adam_id) ON DELETE CASCADE +)`) + if err != nil { + return fmt.Errorf("failed to create table host_vpp_software_installs: %w", err) + } + + return nil +} + +func Down_20240701113709(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 8926760c43..8ad7cc3b55 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -549,6 +549,25 @@ CREATE TABLE `host_users` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; +CREATE TABLE `host_vpp_software_installs` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `host_id` int(10) unsigned NOT NULL, + `adam_id` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL, + `command_uuid` varchar(127) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `user_id` int(10) unsigned DEFAULT NULL, + `self_service` tinyint(1) NOT NULL DEFAULT '0', + `associated_event_id` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + KEY `adam_id` (`adam_id`), + CONSTRAINT `host_vpp_software_installs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `host_vpp_software_installs_ibfk_2` FOREIGN KEY (`adam_id`) REFERENCES `vpp_apps` (`adam_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `hosts` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `osquery_host_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, @@ -941,9 +960,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=276 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=277 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240701113709,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1641,6 +1660,32 @@ CREATE TABLE `users` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; +CREATE TABLE `vpp_apps` ( + `adam_id` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL, + `available_count` int(10) unsigned DEFAULT NULL, + `bundle_identifier` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `icon_url` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`adam_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `vpp_apps_teams` ( + `adam_id` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL, + `team_id` int(10) unsigned NOT NULL, + `global_or_team_id` int(10) NOT NULL DEFAULT '0', + UNIQUE KEY `idx_global_or_team_id_adam_id` (`global_or_team_id`,`adam_id`), + KEY `adam_id` (`adam_id`), + KEY `team_id` (`team_id`), + CONSTRAINT `vpp_apps_teams_ibfk_1` FOREIGN KEY (`adam_id`) REFERENCES `vpp_apps` (`adam_id`) ON DELETE CASCADE, + CONSTRAINT `vpp_apps_teams_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `vulnerability_host_counts` ( `cve` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, `team_id` int(10) unsigned NOT NULL DEFAULT '0', From d9797d2a3f250880b8382d92e280a63a928eac0e Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Fri, 5 Jul 2024 18:04:41 -0300 Subject: [PATCH 03/38] add shared pieces of db functionality (#20239) part of #18867 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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`). --- server/authz/policy.rego | 16 ++++----- .../tables/20240701113709_VPPDBUpdates.go | 13 ++++++- server/datastore/mysql/schema.sql | 5 ++- server/fleet/software_installer.go | 2 +- server/fleet/vpp.go | 35 +++++++++++++++++++ 5 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 server/fleet/vpp.go diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 639b1510ff..fbdaf51659 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -643,32 +643,32 @@ allow { action == read } -# Global admins and maintainers can read any software installer. +# Global admins and maintainers can read any installable entity (software installer or VPP app) allow { - object.type == "software_installer" + object.type == "installable_entity" subject.global_role == [admin, maintainer][_] action == read } -# Global admins, maintainers, and gitops can write any software installer. +# Global admins, maintainers, and gitops can write any installable entity (software installer or VPP app) allow { - object.type == "software_installer" + object.type == "installable_entity" subject.global_role == [admin, maintainer, gitops][_] action == write } -# Team admins and maintainers can read any software installer in their teams. +# Team admins and maintainers can read any installable entity (software installer or VPP app) in their teams. allow { not is_null(object.team_id) - object.type == "software_installer" + object.type == "installable_entity" team_role(subject, object.team_id) == [admin, maintainer][_] action == read } -# Team admins, maintainers, and gitops can write any software installer in their teams. +# Team admins, maintainers, and gitops can write any installable entity (software installer or VPP app) in their teams. allow { not is_null(object.team_id) - object.type == "software_installer" + object.type == "installable_entity" team_role(subject, object.team_id) == [admin, maintainer, gitops][_] action == write } diff --git a/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go index 1e343006c5..de2cd6513e 100644 --- a/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go +++ b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go @@ -23,12 +23,23 @@ CREATE TABLE vpp_apps ( -- This is a count of how many licenses are still available for this asset available_count INT UNSIGNED, + -- FK to the "software title" this app matches + title_id int(10) unsigned DEFAULT NULL, + bundle_identifier VARCHAR(255) NOT NULL DEFAULT '', icon_url VARCHAR(255) NOT NULL DEFAULT '', name VARCHAR(255) NOT NULL DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (adam_id) + + PRIMARY KEY (adam_id), + + + CONSTRAINT fk_vpp_apps_title + FOREIGN KEY (title_id) + REFERENCES software_titles (id) + ON DELETE SET NULL + ON UPDATE CASCADE )`) if err != nil { return fmt.Errorf("failed to create table vpp_apps: %w", err) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 442e4a9ff0..a3e2dbe149 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1666,12 +1666,15 @@ CREATE TABLE `users` ( CREATE TABLE `vpp_apps` ( `adam_id` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL, `available_count` int(10) unsigned DEFAULT NULL, + `title_id` int(10) unsigned DEFAULT NULL, `bundle_identifier` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `icon_url` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`adam_id`) + PRIMARY KEY (`adam_id`), + KEY `fk_vpp_apps_title` (`title_id`), + CONSTRAINT `fk_vpp_apps_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index ee7f544d15..85e2f5ab51 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -104,7 +104,7 @@ type SoftwareInstaller struct { // AuthzType implements authz.AuthzTyper. func (s *SoftwareInstaller) AuthzType() string { - return "software_installer" + return "installable_entity" } // SoftwareInstallerStatusSummary represents aggregated status metrics for a software installer package. diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go new file mode 100644 index 0000000000..cef055a5e7 --- /dev/null +++ b/server/fleet/vpp.go @@ -0,0 +1,35 @@ +package fleet + +import "time" + +// VPPApp represents a VPP (Volume Purchase Program) application, +// this is used by Apple MDM to manage applications via Apple +// Bussines Manager. +type VPPApp struct { + // AdamID is a unique identifier assigned to each app in + // the App Store, this value is managed by Apple. + AdamID string `db:"adam_id"` + // AvailableCount keeps track of how many licenses are + // available for the specific software, this value is + // managed by Apple and tracked in the DB as a helper. + // + // TODO(roberto): could we omit this and rely on API errors + // from Apple instead? seems safer unless we really need to + // display this value in the API. + AvailableCount uint `db:"available_count"` + // BundleIdentifier is the unique bundle identifier of the + // Application. + BundleIdentifier string `db:"bundle_identifier"` + // IconURL is the URL of this App icon + IconURL string `db:"icon_url"` + // Name is the user-facing name of this app. + Name string `db:"name"` + + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +// AuthzType implements authz.AuthzTyper. +func (v *VPPApp) AuthzType() string { + return "installable_entity" +} From e9dba549caf2e673bc2d47665e0ce75074bd2871 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 8 Jul 2024 10:50:20 -0300 Subject: [PATCH 04/38] add a client for VPP calls (#20243) Co-authored-by: Martin Angers --- server/mdm/apple/vpp/api.go | 244 +++++++++++++++++++++++++ server/mdm/apple/vpp/api_test.go | 244 +++++++++++++++++++++++++ server/service/integration_mdm_test.go | 8 +- server/service/mdm.go | 69 +------ 4 files changed, 501 insertions(+), 64 deletions(-) create mode 100644 server/mdm/apple/vpp/api.go create mode 100644 server/mdm/apple/vpp/api_test.go diff --git a/server/mdm/apple/vpp/api.go b/server/mdm/apple/vpp/api.go new file mode 100644 index 0000000000..a0bd17f655 --- /dev/null +++ b/server/mdm/apple/vpp/api.go @@ -0,0 +1,244 @@ +package vpp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "time" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" +) + +// Asset is a product in the store. +// +// https://developer.apple.com/documentation/devicemanagement/asset +type Asset struct { + // AdamID is the unique identifier for a product in the store. + AdamID string `json:"adamId"` + // PricingParam is the quality of a product in the store. + // Possible Values are `STDQ` and `PLUS` + PricingParam string `json:"pricingParam"` +} + +// ErrorResponse represents the response that contains the error that occurs. +// +// https://developer.apple.com/documentation/devicemanagement/errorresponse +type ErrorResponse struct { + ErrorInfo ResponseErrorInfo `json:"errorInfo"` + ErrorMessage string `json:"errorMessage"` + ErrorNumber int32 `json:"errorNumber"` +} + +// Error implements the Erorrer interface +func (e *ErrorResponse) Error() string { + return fmt.Sprintf("Apple VPP endpoint returned error: %s (error number: %d)", e.ErrorMessage, e.ErrorNumber) +} + +// ResponseErrorInfo represents the request-specific information regarding the +// failure. +// +// https://developer.apple.com/documentation/devicemanagement/responseerrorinfo +type ResponseErrorInfo struct { + Assets []Asset `json:"assets"` + ClientUserIds []string `json:"clientUserIds"` + SerialNumbers []string `json:"serialNumbers"` +} + +// client is a package-level client (similar to http.DefaultClient) so it can +// be reused instead of created as needed, as the internal Transport typically +// has internal state (cached connections, etc) and it's safe for concurrent +// use. +var client = fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)) + +// GetConfig fetches the VPP config from Apple's VPP API. This doubles as a +// verification that the user-provided VPP token is valid. +// +// https://developer.apple.com/documentation/devicemanagement/client_config-a40 +func GetConfig(token string) (string, error) { + req, err := http.NewRequest(http.MethodGet, getBaseURL()+"/client/config", nil) + if err != nil { + return "", fmt.Errorf("creating request to Apple VPP endpoint: %w", err) + } + + var respJSON struct { + LocationName string `json:"locationName"` + } + + if err := do(req, token, &respJSON); err != nil { + return "", fmt.Errorf("making request to Apple VPP endpoint: %w", err) + } + + return respJSON.LocationName, nil +} + +// AssociateAssetsRequest is the request for asset management. +type AssociateAssetsRequest struct { + // Assets are the assets to assign. + Assets []Asset `json:"assets"` + // SerialNumbers is the set of identifiers for devices to assign the + // assets to. + SerialNumbers []string `json:"serialNumbers"` +} + +// AssociateAssets associates assets to serial numbers according the the +// request parameters provided. +// +// https://developer.apple.com/documentation/devicemanagement/associate_assets +func AssociateAssets(token string, params *AssociateAssetsRequest) error { + var reqBody bytes.Buffer + if err := json.NewEncoder(&reqBody).Encode(params); err != nil { + return fmt.Errorf("encoding params as JSON: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, getBaseURL()+"/assets/associate", &reqBody) + if err != nil { + return fmt.Errorf("creating request to Apple VPP endpoint: %w", err) + } + + if err := do[any](req, token, nil); err != nil { + return fmt.Errorf("making request to Apple VPP endpoint: %w", err) + } + return nil +} + +// AssetFilter represents the filters for querying assets. +type AssetFilter struct { + // PageIndex is the requested page index. + PageIndex int32 `json:"pageIndex"` + + // ProductType is the filter for the asset product type. + // Possible Values: App, Book + ProductType string `json:"productType"` + + // PricingParam is the filter for the asset product quality. + // Possible Values: STDQ, PLUS + PricingParam string `json:"pricingParam"` + + // Revocable is the filter for asset revocability. + Revocable *bool `json:"revocable"` + + // DeviceAssignable is the filter for asset device assignability. + DeviceAssignable *bool `json:"deviceAssignable"` + + // MaxAvailableCount is the filter for the maximum inclusive assets available count. + MaxAvailableCount int32 `json:"maxAvailableCount"` + + // MinAvailableCount is the filter for the minimum inclusive assets available count. + MinAvailableCount int32 `json:"minAvailableCount"` + + // MaxAssignedCount is the filter for the maximum inclusive assets assigned count. + MaxAssignedCount int32 `json:"maxAssignedCount"` + + // MinAssignedCount is the filter for the minimum inclusive assets assigned count. + MinAssignedCount int32 `json:"minAssignedCount"` + + // AdamID is the filter for the asset product unique identifier. + AdamID string `json:"adamId"` +} + +// GetAssets fetches the assets from Apple's VPP API with optional filters. +func GetAssets(token string, filter *AssetFilter) ([]Asset, error) { + baseURL := getBaseURL() + "/assets" + reqURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("parsing base URL: %w", err) + } + + if filter != nil { + query := url.Values{} + addFilter(query, "adamId", filter.AdamID) + addFilter(query, "pricingParam", filter.PricingParam) + addFilter(query, "productType", filter.ProductType) + addFilter(query, "revocable", filter.Revocable) + addFilter(query, "deviceAssignable", filter.DeviceAssignable) + addFilter(query, "maxAvailableCount", filter.MaxAvailableCount) + addFilter(query, "minAvailableCount", filter.MinAvailableCount) + addFilter(query, "maxAssignedCount", filter.MaxAssignedCount) + addFilter(query, "minAssignedCount", filter.MinAssignedCount) + addFilter(query, "pageIndex", filter.PageIndex) + reqURL.RawQuery = query.Encode() + } + + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating request to Apple VPP endpoint: %w", err) + } + + var bodyResp struct { + Assets []Asset `json:"assets"` + } + + if err = do(req, token, &bodyResp); err != nil { + return nil, fmt.Errorf("retrieving assets: %w", err) + } + + return bodyResp.Assets, nil +} + +func do[T any](req *http.Request, token string, dest *T) error { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("making request to Apple VPP endpoint: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 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 errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && (errResp.ErrorMessage != "" || errResp.ErrorNumber != 0) { + return &errResp + } + + if resp.StatusCode != http.StatusOK { + limitedBody := body + if len(limitedBody) > 1000 { + limitedBody = limitedBody[:1000] + } + return fmt.Errorf("calling Apple VPP endpoint failed with status %d: %s", resp.StatusCode, string(limitedBody)) + } + + if dest != nil { + if err := json.Unmarshal(body, dest); err != nil { + return fmt.Errorf("decoding response data from Apple VPP endpoint: %w", err) + } + } + + return nil +} + +func getBaseURL() string { + devURL := os.Getenv("FLEET_DEV_VPP_URL") + if devURL != "" { + return devURL + } + return "https://vpp.itunes.apple.com/mdm/v2" +} + +// addFilter adds a filter to the query values if it is not the zero value. +func addFilter(query url.Values, key string, value any) { + switch v := value.(type) { + case string: + if v != "" { + query.Add(key, v) + } + case *bool: + if v != nil { + query.Add(key, strconv.FormatBool(*v)) + } + case int32: + if v != 0 { + query.Add(key, fmt.Sprintf("%d", v)) + } + } +} diff --git a/server/mdm/apple/vpp/api_test.go b/server/mdm/apple/vpp/api_test.go new file mode 100644 index 0000000000..5421826dfd --- /dev/null +++ b/server/mdm/apple/vpp/api_test.go @@ -0,0 +1,244 @@ +package vpp + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupFakeServer(t *testing.T, handler http.HandlerFunc) { + server := httptest.NewServer(handler) + os.Setenv("FLEET_DEV_VPP_URL", server.URL) + t.Cleanup(server.Close) +} + +func TestGetConfig(t *testing.T) { + tests := []struct { + name string + token string + handler http.HandlerFunc + wantName string + expectedErrMsg string + }{ + { + name: "valid token", + token: "valid_token", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"locationName": "Test Location"}`) + }, + wantName: "Test Location", + expectedErrMsg: "", + }, + { + name: "invalid token", + token: "invalid_token", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintln(w, `{"errorNumber": 9622}`) + }, + wantName: "", + expectedErrMsg: "making request to Apple VPP endpoint: Apple VPP endpoint returned error: (error number: 9622)", + }, + { + name: "server error", + token: "valid_token", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, `Internal Server Error`) + }, + wantName: "", + expectedErrMsg: "calling Apple VPP endpoint failed with status 500: Internal Server Error\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupFakeServer(t, tt.handler) + + name, err := GetConfig(tt.token) + if tt.expectedErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrMsg) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantName, name) + }) + } +} + +func TestAssociateAssets(t *testing.T) { + tests := []struct { + name string + token string + params *AssociateAssetsRequest + handler http.HandlerFunc + expectedErrMsg string + }{ + { + name: "valid request", + token: "valid_token", + params: &AssociateAssetsRequest{ + Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, + SerialNumbers: []string{"SN12345"}, + }, + handler: func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/assets/associate", r.URL.Path) + require.Equal(t, "Bearer valid_token", r.Header.Get("Authorization")) + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var reqParams AssociateAssetsRequest + err = json.Unmarshal(body, &reqParams) + require.NoError(t, err) + + require.Equal(t, []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, reqParams.Assets) + require.Equal(t, []string{"SN12345"}, reqParams.SerialNumbers) + + w.WriteHeader(http.StatusOK) + }, + expectedErrMsg: "", + }, + { + name: "server error", + token: "valid_token", + params: &AssociateAssetsRequest{ + Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, + SerialNumbers: []string{"SN12345"}}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, `Internal Server Error`) + }, + expectedErrMsg: "calling Apple VPP endpoint failed with status 500: Internal Server Error\n", + }, + { + name: "client error", + token: "valid_token", + params: &AssociateAssetsRequest{ + Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, + SerialNumbers: []string{"SN12345"}}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, `{"errorInfo":{},"errorMessage":"Bad Request","errorNumber":400}`) + }, + expectedErrMsg: "making request to Apple VPP endpoint: Apple VPP endpoint returned error: Bad Request (error number: 400)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupFakeServer(t, tt.handler) + + err := AssociateAssets(tt.token, tt.params) + if tt.expectedErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGetAssets(t *testing.T) { + tests := []struct { + name string + token string + filter *AssetFilter + handler http.HandlerFunc + expectedAssets []Asset + expectedErrMsg string + }{ + { + name: "valid token and filters", + token: "valid_token", + filter: &AssetFilter{ + AdamID: "12345", + }, + handler: func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/assets", r.URL.Path) + require.Equal(t, "Bearer valid_token", r.Header.Get("Authorization")) + + query := r.URL.Query() + require.Equal(t, "12345", query.Get("adamId")) + + type resp struct { + Assets []Asset `json:"assets"` + } + assets := resp{ + Assets: []Asset{ + {AdamID: "12345", PricingParam: "STDQ"}, + {AdamID: "67890", PricingParam: "PLUS"}, + }, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(assets)) + }, + expectedAssets: []Asset{ + {AdamID: "12345", PricingParam: "STDQ"}, + {AdamID: "67890", PricingParam: "PLUS"}, + }, + expectedErrMsg: "", + }, + { + name: "server error", + token: "valid_token", + filter: nil, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, `Internal Server Error`) + }, + expectedAssets: nil, + expectedErrMsg: "calling Apple VPP endpoint failed with status 500: Internal Server Error\n", + }, + { + name: "client error", + token: "valid_token", + filter: nil, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, `{"errorInfo":{},"errorMessage":"Bad Request","errorNumber":400}`) + }, + expectedAssets: nil, + expectedErrMsg: "retrieving assets: Apple VPP endpoint returned error: Bad Request (error number: 400)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupFakeServer(t, tt.handler) + + assets, err := GetAssets(tt.token, tt.filter) + if tt.expectedErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrMsg) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedAssets, assets) + } + }) + } +} + +func TestGetBaseURL(t *testing.T) { + t.Run("Default URL", func(t *testing.T) { + os.Setenv("FLEET_DEV_VPP_URL", "") + require.Equal(t, "https://vpp.itunes.apple.com/mdm/v2", getBaseURL()) + }) + + t.Run("Custom URL", func(t *testing.T) { + customURL := "http://localhost:8000" + os.Setenv("FLEET_DEV_VPP_URL", customURL) + require.Equal(t, customURL, getBaseURL()) + }) +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 307183e53d..b093786080 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -1054,12 +1054,12 @@ func (s *integrationMDMTestSuite) uploadDataViaForm(endpoint, fieldName, fileNam func (s *integrationMDMTestSuite) TestMDMVPPToken() { t := s.T() // Invalid token - testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL + "?invalidToken" + t.Setenv("FLEET_DEV_VPP_URL", 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") + t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?serverError") + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "Apple VPP endpoint returned error: Internal server error (error number: 9603)") // Valid token orgName := "Fleet Device Management Inc." @@ -1067,7 +1067,7 @@ func (s *integrationMDMTestSuite) TestMDMVPPToken() { 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 + t.Setenv("FLEET_DEV_VPP_URL", 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 diff --git a/server/service/mdm.go b/server/service/mdm.go index fdd7731f74..6472b36e8e 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -31,6 +31,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/apple/vpp" "github.com/fleetdm/fleet/v4/server/mdm/assets" nanomdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/ptr" @@ -2600,15 +2601,18 @@ func (svc *Service) UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSee return ctxerr.Wrap(ctx, err, "reading VPP token") } - locName, tokenValid, err := getVPPConfig(string(tokenBytes)) + locName, err := vpp.GetConfig(string(tokenBytes)) if err != nil { + var vppErr *vpp.ErrorResponse + if errors.As(err, &vppErr) { + // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes + if vppErr.ErrorNumber == 9622 { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) + } + } 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") @@ -2634,61 +2638,6 @@ func (svc *Service) UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSee 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 //////////////////////////////////////////////////////////////////////////////// From 0945ef9889c3125fdec43a05d5cd43649891a41c Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 10 Jul 2024 09:08:08 -0400 Subject: [PATCH 05/38] Merge main and fix conflicts --- server/mock/datastore_mock.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 943ec2bedb..153d56b433 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -3045,11 +3045,11 @@ func (s *DataStore) GenerateHostStatusStatistics(ctx context.Context, filter fle return s.GenerateHostStatusStatisticsFunc(ctx, filter, now, platform, lowDiskSpace) } -func (s *DataStore) HostIDsByIdentifier(ctx context.Context, filter fleet.TeamFilter, hostIdentifiers []string) ([]uint, error) { +func (s *DataStore) HostIDsByIdentifier(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) { s.mu.Lock() s.HostIDsByIdentifierFuncInvoked = true s.mu.Unlock() - return s.HostIDsByIdentifierFunc(ctx, filter, hostIdentifiers) + return s.HostIDsByIdentifierFunc(ctx, filter, hostnames) } func (s *DataStore) HostIDsByOSID(ctx context.Context, osID uint, offset int, limit int) ([]uint, error) { From 845b524dcc3b5353ab9c50941b72271a37104ebb Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Wed, 10 Jul 2024 17:05:09 +0100 Subject: [PATCH 06/38] add/remove/disable vpp token in Fleet UI (#20127) relates to #19866 > NOTE: API integration work still needs to be done, which will happen in another PR. This adds the ability to add, remove, or disable a VPP token in the Fleet UI. This includes: **Vpp integration page with VPP card:** ![image](https://github.com/fleetdm/fleet/assets/1153709/99b1ca9b-8872-447f-a085-b5385a2b7f7e) ![image](https://github.com/fleetdm/fleet/assets/1153709/1cdb80a2-1afe-4739-994c-fe7430449f13) ![image](https://github.com/fleetdm/fleet/assets/1153709/79ec7927-f905-48c4-b1b9-42d4d6b41028) **VPP setup page with steps to set up VPP:** ![image](https://github.com/fleetdm/fleet/assets/1153709/dec203e4-01d3-4e1d-b493-be3772b72813) **VPP setup page with VPP info:** ![image](https://github.com/fleetdm/fleet/assets/1153709/afccba29-e97b-4937-8235-4706e39d9333) **Disable VPP modal:** ![image](https://github.com/fleetdm/fleet/assets/1153709/da4a2db3-7546-4f3b-8ec0-d77ad7bff19f) **renew Vpp modal:** ![image](https://github.com/fleetdm/fleet/assets/1153709/8224f466-6aae-43bd-a120-3de5f0c90064) - [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] Manual QA for all new/changed functionality --- .../issue-19866-add-remove-disable-vpp-in-ui | 1 + frontend/__mocks__/appleMdm.ts | 13 ++ frontend/__mocks__/configMock.ts | 86 ++++---- .../components/FileUploader/FileUploader.tsx | 1 + frontend/components/FileUploader/_styles.scss | 3 +- frontend/components/graphics/FileVpp.tsx | 59 ++++++ frontend/components/graphics/index.ts | 2 + frontend/interfaces/config.ts | 3 + .../IntegrationsPage/IntegrationNavItems.tsx | 15 +- .../AutomaticEnrollment.tsx | 3 +- .../cards/MdmSettings/index.ts | 1 + .../IntegrationsPage/cards/Vpp/Vpp.tests.tsx | 75 +++++++ .../admin/IntegrationsPage/cards/Vpp/Vpp.tsx | 135 +++++++++++++ .../Vpp/VppSetupPage/VppSetupPage.tests.tsx | 49 +++++ .../cards/Vpp/VppSetupPage/VppSetupPage.tsx | 187 ++++++++++++++++++ .../cards/Vpp/VppSetupPage/_styles.scss | 48 +++++ .../DisableVppModal/DisableVppModal.tsx | 66 +++++++ .../components/DisableVppModal/index.ts | 1 + .../RenewVppTokenModal/RenewVppTokenModal.tsx | 100 ++++++++++ .../RenewVppTokenModal/_styles.scss | 15 ++ .../components/RenewVppTokenModal/index.ts | 1 + .../VppSetupSteps/VppSetupSteps.tsx | 68 +++++++ .../components/VppSetupSteps/_styles.scss | 20 ++ .../components/VppSetupSteps/index.ts | 1 + .../cards/Vpp/VppSetupPage/index.ts | 1 + .../IntegrationsPage/cards/Vpp/_styles.scss | 47 +++++ .../admin/IntegrationsPage/cards/Vpp/index.ts | 1 + frontend/router/index.tsx | 2 + frontend/router/paths.ts | 2 + frontend/services/entities/mdm_apple.ts | 23 +++ frontend/test/handlers/apple_mdm.ts | 19 ++ frontend/utilities/endpoints.ts | 4 + 32 files changed, 1006 insertions(+), 46 deletions(-) create mode 100644 changes/issue-19866-add-remove-disable-vpp-in-ui create mode 100644 frontend/components/graphics/FileVpp.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/MdmSettings/index.ts create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tests.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tests.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/_styles.scss create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/index.ts create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/RenewVppTokenModal.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/_styles.scss create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/index.ts create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/VppSetupSteps.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/_styles.scss create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/index.ts create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/index.ts create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/_styles.scss create mode 100644 frontend/pages/admin/IntegrationsPage/cards/Vpp/index.ts create mode 100644 frontend/test/handlers/apple_mdm.ts diff --git a/changes/issue-19866-add-remove-disable-vpp-in-ui b/changes/issue-19866-add-remove-disable-vpp-in-ui new file mode 100644 index 0000000000..09000dbff2 --- /dev/null +++ b/changes/issue-19866-add-remove-disable-vpp-in-ui @@ -0,0 +1 @@ +- add ability to add/remove/disable vpp in the fleet UI. diff --git a/frontend/__mocks__/appleMdm.ts b/frontend/__mocks__/appleMdm.ts index 1ed7964056..c764cf67f2 100644 --- a/frontend/__mocks__/appleMdm.ts +++ b/frontend/__mocks__/appleMdm.ts @@ -1,4 +1,5 @@ import { IMdmApple } from "interfaces/mdm"; +import { IGetVppInfoResponse } from "services/entities/mdm_apple"; const DEFAULT_MDM_APPLE_MOCK: IMdmApple = { common_name: "APSP:12345", @@ -13,4 +14,16 @@ export const createMockMdmApple = ( return { ...DEFAULT_MDM_APPLE_MOCK, ...overrides }; }; +const DEFAULT_MDM_APPLE_VPP_INFO_MOCK: IGetVppInfoResponse = { + org_name: "test org", + renew_date: "2024-09-19T00:00:00Z", + location: "test location", +}; + +export const createMockVppInfo = ( + overrides?: Partial +): IGetVppInfoResponse => { + return { ...DEFAULT_MDM_APPLE_VPP_INFO_MOCK, ...overrides }; +}; + export default createMockMdmApple; diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index f15c09ce2b..28519b9613 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -1,4 +1,49 @@ -import { IConfig } from "interfaces/config"; +import { IConfig, IMdmConfig } from "interfaces/config"; + +const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = { + enable_disk_encryption: false, + windows_enabled_and_configured: true, + apple_bm_default_team: "Apples", + apple_bm_enabled_and_configured: true, + apple_bm_terms_expired: false, + enabled_and_configured: true, + macos_updates: { + minimum_version: "", + deadline: "", + }, + macos_settings: { + custom_settings: null, + enable_disk_encryption: false, + }, + macos_setup: { + bootstrap_package: "", + enable_end_user_authentication: false, + macos_setup_assistant: null, + enable_release_device_manually: false, + }, + macos_migration: { + enable: false, + mode: "", + webhook_url: "", + }, + windows_updates: { + deadline_days: null, + grace_period_days: null, + }, + end_user_authentication: { + entity_id: "", + issuer_uri: "", + metadata: "", + metadata_url: "", + idp_name: "", + }, +}; + +export const createMockMdmConfig = ( + overrides?: Partial +): IMdmConfig => { + return { ...DEFAULT_CONFIG_MDM_MOCK, ...overrides }; +}; const DEFAULT_CONFIG_MOCK: IConfig = { org_info: { @@ -136,44 +181,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { enable_software_inventory: true, }, fleet_desktop: { transparency_url: "https://fleetdm.com/transparency" }, - mdm: { - enable_disk_encryption: false, - windows_enabled_and_configured: true, - apple_bm_default_team: "Apples", - apple_bm_enabled_and_configured: true, - apple_bm_terms_expired: false, - enabled_and_configured: true, - macos_updates: { - minimum_version: "", - deadline: "", - }, - macos_settings: { - custom_settings: null, - enable_disk_encryption: false, - }, - macos_setup: { - bootstrap_package: "", - enable_end_user_authentication: false, - macos_setup_assistant: null, - enable_release_device_manually: false, - }, - macos_migration: { - enable: false, - mode: "", - webhook_url: "", - }, - windows_updates: { - deadline_days: null, - grace_period_days: null, - }, - end_user_authentication: { - entity_id: "", - issuer_uri: "", - metadata: "", - metadata_url: "", - idp_name: "", - }, - }, + mdm: createMockMdmConfig(), }; const createMockConfig = (overrides?: Partial): IConfig => { diff --git a/frontend/components/FileUploader/FileUploader.tsx b/frontend/components/FileUploader/FileUploader.tsx index 9c22930f44..a79955df28 100644 --- a/frontend/components/FileUploader/FileUploader.tsx +++ b/frontend/components/FileUploader/FileUploader.tsx @@ -20,6 +20,7 @@ type ISupportedGraphicNames = Extract< | "file-pkg" | "file-p7m" | "file-pem" + | "file-vpp" >; export const FileDetails = ({ diff --git a/frontend/components/FileUploader/_styles.scss b/frontend/components/FileUploader/_styles.scss index 2862466e17..b94ca3ef7c 100644 --- a/frontend/components/FileUploader/_styles.scss +++ b/frontend/components/FileUploader/_styles.scss @@ -2,7 +2,7 @@ display: flex; flex-direction: column; align-items: center; - border-radius: $border-radius; + border-radius: $border-radius-medium; background-color: $ui-fleet-blue-10; border: 1px solid $ui-fleet-black-10; padding: $pad-xlarge $pad-large; @@ -43,6 +43,7 @@ } &__message { margin: 0; + color: $ui-fleet-black-75; } &__additional-info { diff --git a/frontend/components/graphics/FileVpp.tsx b/frontend/components/graphics/FileVpp.tsx new file mode 100644 index 0000000000..35b0abaa5e --- /dev/null +++ b/frontend/components/graphics/FileVpp.tsx @@ -0,0 +1,59 @@ +import React from "react"; + +const FileVpp = () => { + return ( + + + + + + + + + + + + + + + + + ); +}; + +export default FileVpp; diff --git a/frontend/components/graphics/index.ts b/frontend/components/graphics/index.ts index 84e10a3711..cfcafe4562 100644 --- a/frontend/components/graphics/index.ts +++ b/frontend/components/graphics/index.ts @@ -12,6 +12,7 @@ import FilePdf from "./FilePdf"; import FilePkg from "./FilePkg"; import FileP7m from "./FileP7m"; import FilePem from "./FilePem"; +import FileVpp from "./FileVpp"; import EmptyHosts from "./EmptyHosts"; import EmptyTeams from "./EmptyTeams"; import EmptyPacks from "./EmptyPacks"; @@ -39,6 +40,7 @@ export const GRAPHIC_MAP = { "file-pkg": FilePkg, "file-p7m": FileP7m, "file-pem": FilePem, + "file-vpp": FileVpp, // Other graphics "collecting-results": CollectingResults, }; diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index f52cfd3925..e4118b83d1 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -37,6 +37,9 @@ interface ICustomSetting { export interface IMdmConfig { enable_disk_encryption: boolean; + /** `enabled_and_configured` only tells us if Apples MDM has been enabled and + configured correctly. The naming is slightly confusing but at one point we + only supported apple mdm, so thats why it's name the way it is. */ enabled_and_configured: boolean; apple_bm_default_team?: string; apple_bm_terms_expired: boolean; diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx index a3f8734da8..908974e119 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx @@ -2,9 +2,10 @@ import PATHS from "router/paths"; import { ISideNavItem } from "../components/SideNav/SideNav"; import Integrations from "./cards/Integrations"; -import Mdm from "./cards/MdmSettings/MdmSettings"; -import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment"; -import Calendars from "./cards/Calendars/Calendars"; +import MdmSettings from "./cards/MdmSettings"; +import AutomaticEnrollment from "./cards/AutomaticEnrollment"; +import Calendars from "./cards/Calendars"; +import Vpp from "./cards/Vpp"; const integrationSettingsNavItems: ISideNavItem[] = [ // TODO: types @@ -18,7 +19,7 @@ const integrationSettingsNavItems: ISideNavItem[] = [ title: "Mobile device management (MDM)", urlSection: "mdm", path: PATHS.ADMIN_INTEGRATIONS_MDM, - Card: Mdm, + Card: MdmSettings, }, { title: "Automatic enrollment", @@ -32,6 +33,12 @@ const integrationSettingsNavItems: ISideNavItem[] = [ path: PATHS.ADMIN_INTEGRATIONS_CALENDARS, Card: Calendars, }, + { + title: "Volume Purchasing Program (VPP)", + urlSection: "vpp", + path: PATHS.ADMIN_INTEGRATIONS_VPP, + Card: Vpp, + }, ]; export default integrationSettingsNavItems; diff --git a/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AutomaticEnrollment.tsx b/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AutomaticEnrollment.tsx index 59bfa24030..2040e0fdaa 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AutomaticEnrollment.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AutomaticEnrollment.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { useContext } 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"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/index.ts b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/index.ts new file mode 100644 index 0000000000..06899675d0 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/index.ts @@ -0,0 +1 @@ +export { default } from "./MdmSettings"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tests.tsx new file mode 100644 index 0000000000..49cb45e2d6 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tests.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import { createCustomRenderer, createMockRouter } from "test/test-utils"; +import mockServer from "test/mock-server"; +import { + defaultVppInfoHandler, + errorNoVppInfoHandler, +} from "test/handlers/apple_mdm"; +import createMockConfig, { createMockMdmConfig } from "__mocks__/configMock"; + +import Vpp from "./Vpp"; + +describe("Vpp Section", () => { + it("renders turn on apple mdm message when apple mdm is not turned on ", async () => { + mockServer.use(defaultVppInfoHandler); + + const render = createCustomRenderer({ + context: { + app: { + config: createMockConfig({ + mdm: createMockMdmConfig({ enabled_and_configured: false }), + }), + }, + }, + withBackendMock: true, + }); + + render(); + + expect( + await screen.findByRole("button", { name: "Turn on macOS MDM" }) + ).toBeInTheDocument(); + }); + + it("renders enable vpp when vpp is disabled", async () => { + mockServer.use(errorNoVppInfoHandler); + + const render = createCustomRenderer({ + context: { + app: { + config: createMockConfig({ + mdm: createMockMdmConfig({ enabled_and_configured: true }), + }), + }, + }, + withBackendMock: true, + }); + + render(); + + expect( + await screen.findByRole("button", { name: "Enable" }) + ).toBeInTheDocument(); + }); + + it("renders edit vpp when vpp is enabled", async () => { + mockServer.use(defaultVppInfoHandler); + + const render = createCustomRenderer({ + context: { + app: { + config: createMockConfig({ + mdm: createMockMdmConfig({ enabled_and_configured: true }), + }), + }, + }, + withBackendMock: true, + }); + render(); + expect( + await screen.findByRole("button", { name: "Edit" }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tsx new file mode 100644 index 0000000000..8813714ea4 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/Vpp.tsx @@ -0,0 +1,135 @@ +import React, { useContext } from "react"; +import { InjectedRouter } from "react-router"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; + +import PATHS from "router/paths"; +import { AppContext } from "context/app"; +import mdmAppleAPI, { IGetVppInfoResponse } from "services/entities/mdm_apple"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +import Card from "components/Card"; +import SectionHeader from "components/SectionHeader"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; + +const baseClass = "vpp"; + +interface IVppCardProps { + isAppleMdmOn: boolean; + isVppOn: boolean; + router: InjectedRouter; +} + +const VppCard = ({ isAppleMdmOn, isVppOn, router }: IVppCardProps) => { + const nagivateToMdm = () => { + router.push(PATHS.ADMIN_INTEGRATIONS_MDM); + }; + + const navigateToVppSetup = () => { + router.push(PATHS.ADMIN_INTEGRATIONS_VPP_SETUP); + }; + + const appleMdmDiabledContent = ( +
+
+

Volume Purchasing Program (VPP)

+

+ To enable Volume Purchasing Program (VPP) for macOS devices, first + turn on macOS MDM. +

+
+ +
+ ); + const isOnContent = ( +
+

+ + + Volume Purchasing Program (VPP) enabled. + +

+ +
+ ); + + const isOffContent = ( +
+
+

Volume Purchasing Program (VPP)

+

+ Install apps from Apple's App Store purchased through Apple + Business Manager. +

+
+ +
+ ); + + const renderCardContent = () => { + if (!isAppleMdmOn) { + return appleMdmDiabledContent; + } + + return isVppOn ? isOnContent : isOffContent; + }; + + return ( + + {renderCardContent()} + + ); +}; + +interface IVppProps { + router: InjectedRouter; +} + +const Vpp = ({ router }: IVppProps) => { + const { config } = useContext(AppContext); + + const { data: vppData, error: vppError, isLoading, isError } = useQuery< + IGetVppInfoResponse, + AxiosError + >("vppInfo", () => mdmAppleAPI.getVppInfo(), { + ...DEFAULT_USE_QUERY_OPTIONS, + retry: false, + }); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (isError && vppError?.status !== 404) { + return ; + } + + return ( + + ); + }; + + return ( +
+ + <>{renderContent()} +
+ ); +}; + +export default Vpp; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tests.tsx new file mode 100644 index 0000000000..e2159d9a79 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tests.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import { createCustomRenderer, createMockRouter } from "test/test-utils"; +import mockServer from "test/mock-server"; +import { + defaultVppInfoHandler, + errorNoVppInfoHandler, +} from "test/handlers/apple_mdm"; + +import VppSetupPage from "./VppSetupPage"; + +describe("VppSetupPage", () => { + it("renders the VPP setup steps content when VPP is not set up", async () => { + mockServer.use(errorNoVppInfoHandler); + + const render = createCustomRenderer({ + withBackendMock: true, + }); + + render(); + + // This is part of the setup steps content we expect to see. + expect(await screen.findByText(/Sign in to/g)).toBeInTheDocument(); + // This is the upload token UI we expect to see. + expect( + await screen.findByRole("button", { name: "Upload" }) + ).toBeInTheDocument(); + }); + + it("renders the VPP disable and renew content when VPP is set up", async () => { + mockServer.use(defaultVppInfoHandler); + + const render = createCustomRenderer({ + withBackendMock: true, + }); + + render(); + + expect(await screen.findByText("Organization name")).toBeInTheDocument(); + + expect( + await screen.findByRole("button", { name: "Disable" }) + ).toBeInTheDocument(); + expect( + await screen.findByRole("button", { name: "Renew token" }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx new file mode 100644 index 0000000000..0d0ce44069 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx @@ -0,0 +1,187 @@ +import React, { useContext, useState } from "react"; +import { InjectedRouter } from "react-router"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; + +import PATHS from "router/paths"; +import { NotificationContext } from "context/notification"; +import { getErrorReason } from "interfaces/errors"; +import mdmAppleAPI, { IGetVppInfoResponse } from "services/entities/mdm_apple"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import { readableDate } from "utilities/helpers"; + +import MainContent from "components/MainContent"; +import BackLink from "components/BackLink"; +import FileUploader from "components/FileUploader"; +import DataSet from "components/DataSet"; +import Button from "components/buttons/Button"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; + +import DisableVppModal from "./components/DisableVppModal"; +import VppSetupSteps from "./components/VppSetupSteps"; +import RenewVppTokenModal from "./components/RenewVppTokenModal"; + +const baseClass = "vpp-setup-page"; + +interface IVppSetupContentProps { + router: InjectedRouter; +} + +const VPPSetupContent = ({ router }: IVppSetupContentProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isUploading, setIsUploading] = useState(false); + + const uploadToken = async (files: FileList | null) => { + setIsUploading(true); + + const token = files?.[0]; + if (!token) { + setIsUploading(false); + renderFlash("error", "No token selected."); + return; + } + + try { + await mdmAppleAPI.uploadVppToken(token); + renderFlash( + "success", + "Volume Purchasing Program (VPP) integration enabled successfully." + ); + router.push(PATHS.ADMIN_INTEGRATIONS_VPP); + } catch (e) { + // TODO: error messages + const msg = getErrorReason(e, { reasonIncludes: "valid token" }); + if (msg) { + renderFlash("error", msg); + } else { + renderFlash("error", "Couldn't Upload. Please try again."); + } + } finally { + setIsUploading(false); + } + }; + + return ( +
+

+ Connect Fleet to your Apple Business Manager account to enable access to + purchased apps. +

+ + +
+ ); +}; + +interface IVppDisableOrRenewContentProps { + vppInfo: IGetVppInfoResponse; + onDisable: () => void; + onRenew: () => void; +} + +const VPPDisableOrRenewContent = ({ + vppInfo, + onDisable, + onRenew, +}: IVppDisableOrRenewContentProps) => { + return ( +
+
+ + + +
+
+ + +
+
+ ); +}; + +interface IVppSetupPageProps { + router: InjectedRouter; +} + +const VppSetupPage = ({ router }: IVppSetupPageProps) => { + const [showDisableModal, setShowDisableModal] = useState(false); + const [showRenewModal, setShowRenewModal] = useState(false); + + const { + data: vppData, + error: vppError, + isLoading, + isError, + refetch: refetchVppInfo, + } = useQuery( + "vppInfo", + () => mdmAppleAPI.getVppInfo(), + { + ...DEFAULT_USE_QUERY_OPTIONS, + retry: false, + } + ); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (isError && vppError?.status !== 404) { + return ; + } + + // 404 means there is no token, se we want to show the setup steps content + if (vppError?.status === 404) { + return ; + } + + return vppData ? ( + setShowDisableModal(true)} + onRenew={() => setShowRenewModal(true)} + /> + ) : null; + }; + + return ( + + <> + +

Volume Purchasing Program (VPP)

+ <>{renderContent()} + + {showDisableModal && ( + setShowDisableModal(false)} /> + )} + {showRenewModal && ( + setShowRenewModal(false)} + onTokenRenewed={refetchVppInfo} + /> + )} +
+ ); +}; + +export default VppSetupPage; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/_styles.scss new file mode 100644 index 0000000000..4c1efec145 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/_styles.scss @@ -0,0 +1,48 @@ +.vpp-setup-page { + &__back-to-vpp { + margin-bottom: $pad-xlarge; + } + + h1 { + margin-bottom: $pad-xxlarge; + font-size: $large; + } + + &__description { + font-size: $x-small; + margin: 0 0 $pad-large; + } + + &__file-uploader { + max-width: 784px; + margin-top: $pad-medium; + margin-left: $pad-medium; + + button { + margin-top: 0; + } + + &--loading { + label { + opacity: 0.5; + } + } + } + + &__button-wrap { + display: flex; + gap: $pad-medium; + } + + &__disable-renew-content { + display: flex; + flex-direction: column; + gap: $pad-large; + } + + &__info { + display: flex; + flex-direction: column; + gap: $pad-medium; + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx new file mode 100644 index 0000000000..028821381d --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx @@ -0,0 +1,66 @@ +import React, { useContext, useState } from "react"; + +import mdmAppleAPI from "services/entities/mdm_apple"; +import { NotificationContext } from "context/notification"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; + +const baseClass = "diable-vpp-modal"; + +interface IDisableVppModalProps { + onExit: () => void; +} + +const DisableVppModal = ({ onExit }: IDisableVppModalProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isDisabling, setIsDisabling] = useState(false); + + const onDisableVpp = async () => { + // TODO: API integration + try { + await mdmAppleAPI.disableVpp(); + renderFlash( + "success", + "Volume Purchasing Program (VPP) disabled successfully." + ); + } catch { + renderFlash( + "error", + "Couldn't disable Volume Purchasing Program (VPP). Please try again." + ); + } + + onExit(); + }; + + return ( + + <> +

+ Apps purchased in Apple Business Manager won't appear in Fleet. + Apps won't be uninstalled from hosts. If you want to enable + integration again, you'll have to upload a new content token. +

+
+ + +
+ +
+ ); +}; + +export default DisableVppModal; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/index.ts new file mode 100644 index 0000000000..d223bbdb91 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DisableVppModal"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/RenewVppTokenModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/RenewVppTokenModal.tsx new file mode 100644 index 0000000000..dafd2b4b93 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/RenewVppTokenModal.tsx @@ -0,0 +1,100 @@ +import React, { useContext, useState } from "react"; + +import mdmAppleAPI from "services/entities/mdm_apple"; +import { NotificationContext } from "context/notification"; +import { getErrorReason } from "interfaces/errors"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import FileUploader from "components/FileUploader"; +import { FileDetails } from "components/FileUploader/FileUploader"; + +import VppSetupSteps from "../VppSetupSteps"; + +const baseClass = "renew-vpp-token-modal"; + +interface IRenewVppTokenModalProps { + onExit: () => void; + onTokenRenewed: () => void; +} + +const RenewVppTokenModal = ({ + onExit, + onTokenRenewed, +}: IRenewVppTokenModalProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isRenewing, setIsRenewing] = useState(false); + const [tokenFile, setTokenFile] = useState(null); + + const onSelectFile = (files: FileList | null) => { + const file = files?.[0]; + if (file) { + setTokenFile(file); + } + }; + + const onRenewToken = async () => { + setIsRenewing(true); + + if (!tokenFile) { + setIsRenewing(false); + renderFlash("error", "No token selected."); + return; + } + + try { + await mdmAppleAPI.uploadVppToken(tokenFile); + renderFlash( + "success", + "Volume Purchasing Program (VPP) integration enabled successfully." + ); + onTokenRenewed(); + } catch (e) { + const msg = getErrorReason(e, { reasonIncludes: "valid token" }); + if (msg) { + renderFlash("error", msg); + } else { + renderFlash("error", "Couldn't Upload. Please try again."); + } + } + onExit(); + setIsRenewing(false); + }; + + return ( + + <> + + + ) + } + onFileUpload={onSelectFile} + /> +
+ +
+ +
+ ); +}; + +export default RenewVppTokenModal; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/_styles.scss new file mode 100644 index 0000000000..a848fb9c45 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/_styles.scss @@ -0,0 +1,15 @@ +.renew-vpp-token-modal { + &__file-uploader { + margin-top: $pad-medium; + + button { + margin-top: 0; + } + + &--loading { + label { + opacity: 0.5; + } + } + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/index.ts new file mode 100644 index 0000000000..8cb881457f --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/RenewVppTokenModal/index.ts @@ -0,0 +1 @@ +export { default } from "./RenewVppTokenModal"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/VppSetupSteps.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/VppSetupSteps.tsx new file mode 100644 index 0000000000..303003f20c --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/VppSetupSteps.tsx @@ -0,0 +1,68 @@ +import CustomLink from "components/CustomLink"; +import React from "react"; + +const baseClass = "vpp-setup-steps"; + +interface IVppSetupStepsProps { + /** This prop is used to display additional setup steps content. We have this + * because some places that use this component show additional content. + */ + extendendSteps?: boolean; +} + +const VppSetupSteps = ({ extendendSteps = false }: IVppSetupStepsProps) => { + return ( +
    +
  1. + 1. +

    + Sign in to{" "} + + {extendendSteps && ( + <> +
    + If your organization doesn't have an account, select{" "} + Sign up now. + + )} +

    +
  2. +
  3. + 2. +

    + Select your account name at the bottom left of the screen, then + select Preferences. +

    +
  4. +
  5. + 3. +

    + Select Payments and Billings in the menu. +

    +
  6. +
  7. + 4. +

    + Under the Content Tokens, download the token for the location + you want to use. + {extendendSteps && ( + <> +
    Each token is based on a location in Apple Business + Manager. + + )} +

    +
  8. +
  9. + 5. +

    Upload content token (.vpptoken file) below.

    +
  10. +
+ ); +}; + +export default VppSetupSteps; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/_styles.scss new file mode 100644 index 0000000000..23dd2748b9 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/_styles.scss @@ -0,0 +1,20 @@ +.vpp-setup-steps { + 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 { + margin: 0; + } + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/index.ts new file mode 100644 index 0000000000..94ab3ee836 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/VppSetupSteps/index.ts @@ -0,0 +1 @@ +export { default } from "./VppSetupSteps"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/index.ts new file mode 100644 index 0000000000..a591bb640a --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/index.ts @@ -0,0 +1 @@ +export { default } from "./VppSetupPage"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Vpp/_styles.scss new file mode 100644 index 0000000000..0da13eb9c3 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/_styles.scss @@ -0,0 +1,47 @@ +.vpp { + + // TODO: this is very similar to the Apple Mdm card, consider pulling out + // into a shared component if we do this pattern one more time. + &__card { + + 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; + } + } + } + + &__mdm-disabled-content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $pad-medium; + } + + &__vpp-on-content, &__vpp-off-content { + display: flex; + justify-content: space-between; + align-items: center; + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Vpp/index.ts new file mode 100644 index 0000000000..35854011a7 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/index.ts @@ -0,0 +1 @@ +export { default } from "./Vpp"; diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index 2d3c83a8d8..27931a0c49 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -61,6 +61,7 @@ import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMd 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 VppSetupPage from "pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage"; import HostQueryReport from "pages/hosts/details/HostQueryReport"; import SoftwarePage from "pages/SoftwarePage"; import SoftwareTitles from "pages/SoftwarePage/SoftwareTitles"; @@ -168,6 +169,7 @@ const routes = ( path="integrations/automatic-enrollment/windows" component={WindowsAutomaticEnrollmentPage} /> + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 0aedc5ee30..539188c561 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -38,6 +38,8 @@ export default { 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_INTEGRATIONS_VPP: `${URL_PREFIX}/settings/integrations/vpp`, + ADMIN_INTEGRATIONS_VPP_SETUP: `${URL_PREFIX}/settings/integrations/vpp/setup`, ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`, ADMIN_ORGANIZATION: `${URL_PREFIX}/settings/organization`, ADMIN_ORGANIZATION_INFO: `${URL_PREFIX}/settings/organization/info`, diff --git a/frontend/services/entities/mdm_apple.ts b/frontend/services/entities/mdm_apple.ts index 1d5b9babf5..a435643eb8 100644 --- a/frontend/services/entities/mdm_apple.ts +++ b/frontend/services/entities/mdm_apple.ts @@ -2,6 +2,12 @@ import sendRequest from "services"; import endpoints from "utilities/endpoints"; +export interface IGetVppInfoResponse { + org_name: string; + renew_date: string; + location: string; +} + export default { getAppleAPNInfo: () => { const { MDM_APPLE_PNS } = endpoints; @@ -25,4 +31,21 @@ export default { const { MDM_REQUEST_CSR } = endpoints; return sendRequest("GET", MDM_REQUEST_CSR); }, + + getVppInfo: (): Promise => { + const { MDM_APPLE_VPP } = endpoints; + return sendRequest("GET", MDM_APPLE_VPP); + }, + + uploadVppToken: (token: File) => { + const { MDM_APPLE_VPP_TOKEN } = endpoints; + const formData = new FormData(); + formData.append("token", token); + return sendRequest("POST", MDM_APPLE_VPP_TOKEN, formData); + }, + + disableVpp: () => { + const { MDM_APPLE_VPP_TOKEN } = endpoints; + return sendRequest("DELETE", MDM_APPLE_VPP_TOKEN); + }, }; diff --git a/frontend/test/handlers/apple_mdm.ts b/frontend/test/handlers/apple_mdm.ts new file mode 100644 index 0000000000..5f8be1c376 --- /dev/null +++ b/frontend/test/handlers/apple_mdm.ts @@ -0,0 +1,19 @@ +import { rest } from "msw"; + +import { createMockVppInfo } from "__mocks__/appleMdm"; +import { baseUrl } from "test/test-utils"; + +// eslint-disable-next-line import/prefer-default-export +export const defaultVppInfoHandler = rest.get( + baseUrl("/vpp"), + (req, res, context) => { + return res(context.json(createMockVppInfo())); + } +); + +export const errorNoVppInfoHandler = rest.get( + baseUrl("/vpp"), + (req, res, context) => { + return res(context.status(404)); + } +); diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index d096fd4a0a..37c43db2e4 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -85,6 +85,10 @@ export default { MDM_SUMMARY: `/${API_VERSION}/fleet/hosts/summary/mdm`, MDM_REQUEST_CSR: `/${API_VERSION}/fleet/mdm/apple/request_csr`, + // Apple VPP endpoints + MDM_APPLE_VPP: `/${API_VERSION}/fleet/vpp`, + MDM_APPLE_VPP_TOKEN: `/${API_VERSION}/fleet/mdm/apple/vpp_token`, + // MDM profile endpoints MDM_PROFILES: `/${API_VERSION}/fleet/mdm/profiles`, MDM_PROFILE: (id: string) => `/${API_VERSION}/fleet/mdm/profiles/${id}`, From 51e297996590864315459b1d4bf1f41035f3597e Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Wed, 10 Jul 2024 14:53:03 -0400 Subject: [PATCH 07/38] VPP GitOps Config (#20238) Currently covers the ability to sync and verify config with fleet server. Bulk API moved to its own ticket (#20278) while product decides its capabilities --- changes/19871-gitops-vpp-config | 1 + cmd/fleetctl/gitops_test.go | 2 +- .../testdata/expectedGetTeamsJson.json | 2 - .../testdata/expectedGetTeamsYaml.yml | 2 - .../testdata/gitops/team_config_no_paths.yml | 19 +-- ...m_software_installer_install_not_found.yml | 7 +- ...e_installer_invalid_self_service_value.yml | 5 +- .../gitops/team_software_installer_no_url.yml | 13 +- .../team_software_installer_not_found.yml | 3 +- ...tware_installer_post_install_not_found.yml | 11 +- ...staller_pre_condition_multiple_queries.yml | 15 +- ...ware_installer_pre_condition_not_found.yml | 11 +- .../team_software_installer_too_large.yml | 3 +- .../team_software_installer_unsupported.yml | 3 +- .../gitops/team_software_installer_valid.yml | 19 +-- .../macosSetupExpectedTeam1And2Empty.yml | 2 - .../macosSetupExpectedTeam1And2Set.yml | 2 - .../testdata/macosSetupExpectedTeam1Empty.yml | 1 - .../testdata/macosSetupExpectedTeam1Set.yml | 1 - ee/server/service/teams.go | 15 +- pkg/spec/gitops.go | 38 +++-- server/fleet/teams.go | 43 ++++-- server/service/client.go | 25 +-- server/service/integration_enterprise_test.go | 145 ++++++++++++++---- 24 files changed, 260 insertions(+), 128 deletions(-) create mode 100644 changes/19871-gitops-vpp-config diff --git a/changes/19871-gitops-vpp-config b/changes/19871-gitops-vpp-config new file mode 100644 index 0000000000..e9a02e0fa7 --- /dev/null +++ b/changes/19871-gitops-vpp-config @@ -0,0 +1 @@ +* Add support for VPP to gitops config diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index d59af1bd5a..42ab05dfad 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -1072,7 +1072,7 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"}, - {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.self_service of type bool"}, + {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.packages of type bool"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index 1af52bee61..ad84fe3d91 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -53,7 +53,6 @@ } }, "scripts": null, - "software": null, "user_count": 99, "host_count": 42 } @@ -129,7 +128,6 @@ } }, "scripts": null, - "software": null, "user_count": 87, "host_count": 43 } diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index f10577a3af..5b1809a7a9 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -30,7 +30,6 @@ spec: macos_setup_assistant: scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: team1 @@ -74,7 +73,6 @@ spec: enable_release_device_manually: false macos_setup_assistant: scripts: null - software: null webhook_settings: host_status_webhook: null name: team2 diff --git a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml index ee81d27fc4..785ba5d215 100644 --- a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml @@ -116,12 +116,13 @@ policies: resolution: There is no resolution for this policy. query: SELECT 1 FROM osquery_info WHERE start_time < 0; software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/query_ruby.yml - post_install_script: - path: lib/post_install_ruby.sh - - url: ${SOFTWARE_INSTALLER_URL}/other.deb - self_service: true + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml index c3837b3a0e..8c25d098a1 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_install_not_found.yml @@ -13,6 +13,7 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/notfound.sh + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml index b27a982703..fe50a971d0 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml @@ -13,5 +13,6 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt - self_service: "not a boolean" + packages: + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt + self_service: "not a boolean" diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml index 43bfa4babf..dfdce81879 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_no_url.yml @@ -13,9 +13,10 @@ controls: policies: queries: software: - - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/query_ruby.yml - post_install_script: - path: lib/post_install_ruby.sh + packages: + - install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml index ca657a5736..ff30037bd5 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_not_found.yml @@ -13,4 +13,5 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb + packages: + - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml index 4cc9fbcef7..cdd20768bf 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_post_install_not_found.yml @@ -13,8 +13,9 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - post_install_script: - path: lib/notfound.sh + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + post_install_script: + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml index 4b26e63e4e..a9e96544a2 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml @@ -13,10 +13,11 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/query_multiple.yml - post_install_script: - path: lib/post_install_ruby.sh + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_multiple.yml + post_install_script: + path: lib/post_install_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml index 681590d04d..162a9aecb9 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_pre_condition_not_found.yml @@ -13,8 +13,9 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/notfound.yml + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/notfound.yml diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml index 15d16e9e9a..6592a55212 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_too_large.yml @@ -13,4 +13,5 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb + packages: + - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml index 3f58009a03..b722970cf8 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_unsupported.yml @@ -13,4 +13,5 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt + packages: + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml index ceeb1a7415..e894112249 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml @@ -13,12 +13,13 @@ controls: policies: queries: software: - - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb - install_script: - path: lib/install_ruby.sh - pre_install_query: - path: lib/query_ruby.yml - post_install_script: - path: lib/post_install_ruby.sh - - url: ${SOFTWARE_INSTALLER_URL}/other.deb - self_service: true + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index b5a4c03e5c..4bb48ba46b 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -30,7 +30,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm1 @@ -65,7 +64,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index a0d15fddd7..66c058371e 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -30,7 +30,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm1 @@ -65,7 +64,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index 8a6762468c..fec4c85b36 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -30,7 +30,6 @@ spec: custom_settings: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm1 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml index 2aac4b1481..ae9b7c65b9 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml @@ -29,7 +29,6 @@ spec: grace_period_days: null scripts: null secrets: null - software: null webhook_settings: host_status_webhook: null name: tm1 diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index ebdb716a07..a2d1dd8bcd 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1018,6 +1018,7 @@ func (svc *Service) createTeamFromSpec( Integrations: fleet.TeamIntegrations{ GoogleCalendar: spec.Integrations.GoogleCalendar, }, + Software: spec.Software, }, Secrets: secrets, }) @@ -1168,8 +1169,18 @@ func (svc *Service) editTeamFromSpec( team.Config.Scripts = spec.Scripts } - if spec.Software.Set { - team.Config.Software = spec.Software + if spec.Software != nil { + if team.Config.Software == nil { + team.Config.Software = &fleet.TeamSpecSoftware{} + } + + if spec.Software.Packages.Set { + team.Config.Software.Packages = spec.Software.Packages + } + + if spec.Software.AppStoreApps.Set { + team.Config.Software.AppStoreApps = spec.Software.AppStoreApps + } } if secrets != nil { diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index a416c33787..80d282d969 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -55,7 +55,12 @@ type GitOps struct { Policies []*fleet.PolicySpec Queries []*fleet.QuerySpec // Software is only allowed on teams, not on global config. - Software []*fleet.TeamSpecSoftware + Software GitOpsSoftware +} + +type GitOpsSoftware struct { + Packages []*fleet.TeamSpecSoftwarePackage + AppStoreApps []*fleet.TeamSpecAppStoreApp } // GitOpsFromFile parses a GitOps yaml file. @@ -517,20 +522,33 @@ func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string } func parseSoftware(softwareRaw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { - var softwareInstallers []fleet.TeamSpecSoftware + var software fleet.TeamSpecSoftware if len(softwareRaw) > 0 { - if err := json.Unmarshal(softwareRaw, &softwareInstallers); err != nil { - return multierror.Append(multiError, fmt.Errorf("failed to unmarshal software: %v", err)) + if err := json.Unmarshal(softwareRaw, &software); err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to unmarshall software: %v", err)) } } - for _, item := range softwareInstallers { - item := item - if item.URL == "" { - multiError = multierror.Append(multiError, errors.New("software URL is required")) - continue + if software.AppStoreApps.Set { + for _, item := range software.AppStoreApps.Value { + item := item + if item.AppStoreID == "" { + multiError = multierror.Append(multiError, errors.New("software app store id required")) + continue + } + result.Software.AppStoreApps = append(result.Software.AppStoreApps, &item) } - result.Software = append(result.Software, &item) } + if software.Packages.Set { + for _, item := range software.Packages.Value { + item := item + if item.URL == "" { + multiError = multierror.Append(multiError, errors.New("software URL is required")) + continue + } + result.Software.Packages = append(result.Software.Packages, &item) + } + } + return multiError } diff --git a/server/fleet/teams.go b/server/fleet/teams.go index 45febe6d90..1e809da24f 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -140,14 +140,14 @@ func (t *Team) UnmarshalJSON(b []byte) error { type TeamConfig struct { // AgentOptions is the options for osquery and Orbit. - AgentOptions *json.RawMessage `json:"agent_options,omitempty"` - HostExpirySettings HostExpirySettings `json:"host_expiry_settings"` - WebhookSettings TeamWebhookSettings `json:"webhook_settings"` - Integrations TeamIntegrations `json:"integrations"` - Features Features `json:"features"` - MDM TeamMDM `json:"mdm"` - Scripts optjson.Slice[string] `json:"scripts,omitempty"` - Software optjson.Slice[TeamSpecSoftware] `json:"software,omitempty"` + AgentOptions *json.RawMessage `json:"agent_options,omitempty"` + HostExpirySettings HostExpirySettings `json:"host_expiry_settings"` + WebhookSettings TeamWebhookSettings `json:"webhook_settings"` + Integrations TeamIntegrations `json:"integrations"` + Features Features `json:"features"` + MDM TeamMDM `json:"mdm"` + Scripts optjson.Slice[string] `json:"scripts,omitempty"` + Software *TeamSpecSoftware `json:"software,omitempty"` } type TeamWebhookSettings struct { @@ -161,6 +161,15 @@ type TeamSpecSoftwareAsset struct { } type TeamSpecSoftware struct { + Packages optjson.Slice[TeamSpecSoftwarePackage] `json:"packages,omitempty"` + AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"` +} + +type TeamSpecAppStoreApp struct { + AppStoreID string `json:"app_store_id"` +} + +type TeamSpecSoftwarePackage struct { URL string `json:"url"` SelfService bool `json:"self_service"` PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` @@ -417,15 +426,15 @@ type TeamSpec struct { // If the agent_options key is present but empty in the YAML, will be set to // "null" (JSON null). Otherwise, if the key is present and set, it will be // set to the agent options JSON object. - AgentOptions json.RawMessage `json:"agent_options,omitempty"` // marshals as "null" if omitempty is not set - HostExpirySettings *HostExpirySettings `json:"host_expiry_settings,omitempty"` - Secrets *[]EnrollSecret `json:"secrets,omitempty"` - Features *json.RawMessage `json:"features"` - MDM TeamSpecMDM `json:"mdm"` - Scripts optjson.Slice[string] `json:"scripts"` - WebhookSettings TeamSpecWebhookSettings `json:"webhook_settings"` - Integrations TeamSpecIntegrations `json:"integrations"` - Software optjson.Slice[TeamSpecSoftware] `json:"software,omitempty"` + AgentOptions json.RawMessage `json:"agent_options,omitempty"` // marshals as "null" if omitempty is not set + HostExpirySettings *HostExpirySettings `json:"host_expiry_settings,omitempty"` + Secrets *[]EnrollSecret `json:"secrets,omitempty"` + Features *json.RawMessage `json:"features"` + MDM TeamSpecMDM `json:"mdm"` + Scripts optjson.Slice[string] `json:"scripts"` + WebhookSettings TeamSpecWebhookSettings `json:"webhook_settings"` + Integrations TeamSpecIntegrations `json:"integrations"` + Software *TeamSpecSoftware `json:"software,omitempty"` } type TeamSpecWebhookSettings struct { diff --git a/server/service/client.go b/server/service/client.go index 82fee91585..f792d1908c 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -606,9 +606,9 @@ func (c *Client) ApplyGroup( tmScriptsPayloads[k] = scriptPayloads } - tmSoftware := extractTmSpecsSoftware(specs.Teams) + tmSoftwarePackages := extractTmSpecsSoftware(specs.Teams) tmSoftwarePayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmScripts)) - for tmName, software := range tmSoftware { + for tmName, software := range tmSoftwarePackages { softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(software)) for i, si := range software { var qc string @@ -995,8 +995,8 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string]profi return m } -func extractTmSpecsSoftware(tmSpecs []json.RawMessage) map[string][]fleet.TeamSpecSoftware { - var m map[string][]fleet.TeamSpecSoftware +func extractTmSpecsSoftware(tmSpecs []json.RawMessage) map[string][]fleet.TeamSpecSoftwarePackage { + var m map[string][]fleet.TeamSpecSoftwarePackage for _, tm := range tmSpecs { var spec struct { Name string `json:"name"` @@ -1009,19 +1009,22 @@ func extractTmSpecsSoftware(tmSpecs []json.RawMessage) map[string][]fleet.TeamSp spec.Name = norm.NFC.String(spec.Name) if spec.Name != "" && len(spec.Software) > 0 { if m == nil { - m = make(map[string][]fleet.TeamSpecSoftware) + m = make(map[string][]fleet.TeamSpecSoftwarePackage) } - var software []fleet.TeamSpecSoftware + var software fleet.TeamSpecSoftware + var packages []fleet.TeamSpecSoftwarePackage if err := json.Unmarshal(spec.Software, &software); err != nil { // ignore, will fail in apply team specs call continue } - if software == nil { + if !software.Packages.Valid { // to be consistent with the AppConfig custom settings, set it to an // empty slice if the provided custom settings are present but empty. - software = []fleet.TeamSpecSoftware{} + packages = []fleet.TeamSpecSoftwarePackage{} + } else { + packages = software.Packages.Value } - m[spec.Name] = software + m[spec.Name] = packages } } return m @@ -1178,7 +1181,9 @@ func (c *Client) DoGitOps( team["features"] = features } team["scripts"] = scripts - team["software"] = config.Software + team["software"] = map[string]any{} + team["software"].(map[string]any)["app_store_apps"] = config.Software.AppStoreApps + team["software"].(map[string]any)["packages"] = config.Software.Packages team["secrets"] = config.TeamSettings["secrets"] team["webhook_settings"] = map[string]interface{}{} clearHostStatusWebhook := true diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 2ba8086263..5adadb8c02 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -9558,30 +9558,40 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { "specs": []any{ map[string]any{ "name": teamName, - "software": []map[string]any{ - { - "url": "http://foo.com", - "self_service": true, - "install_script": map[string]string{ - "path": "./foo/install-script.sh", + "software": map[string]any{ + "packages": []map[string]any{ + { + "url": "http://foo.com", + "self_service": true, + "install_script": map[string]string{ + "path": "./foo/install-script.sh", + }, + "post_install_script": map[string]string{ + "path": "./foo/post-install-script.sh", + }, + "pre_install_query": map[string]string{ + "path": "./foo/query.yaml", + }, }, - "post_install_script": map[string]string{ - "path": "./foo/post-install-script.sh", - }, - "pre_install_query": map[string]string{ - "path": "./foo/query.yaml", + { + "url": "http://bar.com", + "install_script": map[string]string{ + "path": "./bar/install-script.sh", + }, + "post_install_script": map[string]string{ + "path": "./bar/post-install-script.sh", + }, + "pre_install_query": map[string]string{ + "path": "./bar/query.yaml", + }, }, }, - { - "url": "http://bar.com", - "install_script": map[string]string{ - "path": "./bar/install-script.sh", + "app_store_apps": []map[string]any{ + { + "app_store_id": "1234", }, - "post_install_script": map[string]string{ - "path": "./bar/post-install-script.sh", - }, - "pre_install_query": map[string]string{ - "path": "./bar/query.yaml", + { + "app_store_id": "5678", }, }, }, @@ -9590,7 +9600,7 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { } s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) - wantSoftware := []fleet.TeamSpecSoftware{ + wantSoftwarePackages := []fleet.TeamSpecSoftwarePackage{ { URL: "http://foo.com", SelfService: true, @@ -9606,11 +9616,19 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { PreInstallQuery: fleet.TeamSpecSoftwareAsset{Path: "./bar/query.yaml"}, }, } + wantAppStoreApps := []fleet.TeamSpecAppStoreApp{ + { + AppStoreID: "1234", + }, + { + AppStoreID: "5678", + }, + } // retrieving the team returns the software var teamResp getTeamResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.Equal(t, wantSoftware, teamResp.Team.Config.Software.Value) + require.Equal(t, wantSoftwarePackages, teamResp.Team.Config.Software.Packages.Value) // apply without custom software specified, should not replace existing software teamSpecs = map[string]any{ @@ -9623,24 +9641,44 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.Equal(t, wantSoftware, teamResp.Team.Config.Software.Value) + require.Equal(t, wantSoftwarePackages, teamResp.Team.Config.Software.Packages.Value) + require.Equal(t, wantAppStoreApps, teamResp.Team.Config.Software.AppStoreApps.Value) // apply with explicitly empty custom software would clear the existing // software, but dry-run teamSpecs = map[string]any{ "specs": []any{ map[string]any{ - "name": teamName, - "software": nil, + "name": teamName, + "software": map[string]any{ + "packages": nil, + }, }, }, } s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true") teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.Equal(t, wantSoftware, teamResp.Team.Config.Software.Value) + require.Equal(t, wantSoftwarePackages, teamResp.Team.Config.Software.Packages.Value) - // apply with explicitly empty software clears the existing software + // apply with explicitly empty custom app store apps would clear the existing + // software, but dry-run + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "software": map[string]any{ + "app_store_apps": nil, + }, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true") + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Equal(t, wantAppStoreApps, teamResp.Team.Config.Software.AppStoreApps.Value) + + // apply with empty top-level software field, should not clear packages teamSpecs = map[string]any{ "specs": []any{ map[string]any{ @@ -9652,7 +9690,42 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.Empty(t, teamResp.Team.Config.Software.Value) + require.Equal(t, wantSoftwarePackages, teamResp.Team.Config.Software.Packages.Value) + require.Equal(t, wantAppStoreApps, teamResp.Team.Config.Software.AppStoreApps.Value) + + // apply with explicitly empty software packages clears the existing software, but not apps + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "software": map[string]any{ + "packages": nil, + }, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Empty(t, teamResp.Team.Config.Software.Packages.Value) + require.Equal(t, wantAppStoreApps, teamResp.Team.Config.Software.AppStoreApps.Value) + + // apply with explicitly empty software apps clears the existing apps + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "software": map[string]any{ + "app_store_apps": nil, + }, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Empty(t, teamResp.Team.Config.Software.Packages.Value) + require.Empty(t, teamResp.Team.Config.Software.AppStoreApps.Value) // patch with an invalid array returns an error teamSpecs = map[string]any{ @@ -9666,7 +9739,21 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) - require.Empty(t, teamResp.Team.Config.Software.Value) + require.Empty(t, teamResp.Team.Config.Software.Packages.Value) + + // patch with an invalid array returns an error + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "app_store_apps": []any{"foo", 1}, + }, + }, + } + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest) + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Empty(t, teamResp.Team.Config.Software.AppStoreApps.Value) } func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { From aa1645628d022dc6670ed3b3dc8f525b2077b323 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Thu, 11 Jul 2024 16:09:30 -0400 Subject: [PATCH 08/38] feat: get app store apps, add app store app to Fleet (#20362) > Related issue: #19867 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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 - [x] Manual QA for all new/changed functionality --- changes/19867-get-avail-apps | 1 + ee/server/service/vpp.go | 163 ++++++++++++++++++ .../tables/20240701113709_VPPDBUpdates.go | 10 +- server/datastore/mysql/schema.sql | 3 +- .../mysql/software_installers_test.go | 1 - server/datastore/mysql/software_titles.go | 26 +++ .../datastore/mysql/software_titles_test.go | 40 +++++ server/datastore/mysql/vpp.go | 127 ++++++++++++++ server/datastore/mysql/vpp_test.go | 71 ++++++++ server/fleet/datastore.go | 8 + server/fleet/service.go | 4 + server/fleet/vpp.go | 20 ++- server/mdm/apple/itunes/api.go | 107 ++++++++++++ server/mdm/apple/itunes/api_test.go | 21 +++ server/mdm/apple/vpp/api.go | 3 + server/mock/datastore_mock.go | 48 ++++++ server/service/handler.go | 4 + server/service/integration_enterprise_test.go | 97 ++++++++++- server/service/integration_mdm_test.go | 75 ++++---- server/service/software_installers_test.go | 28 +++ server/service/vpp.go | 73 ++++++++ server/service/vpp_test.go | 96 +++++++++++ 22 files changed, 969 insertions(+), 57 deletions(-) create mode 100644 changes/19867-get-avail-apps create mode 100644 ee/server/service/vpp.go create mode 100644 server/datastore/mysql/vpp.go create mode 100644 server/datastore/mysql/vpp_test.go create mode 100644 server/mdm/apple/itunes/api.go create mode 100644 server/mdm/apple/itunes/api_test.go create mode 100644 server/service/vpp.go create mode 100644 server/service/vpp_test.go diff --git a/changes/19867-get-avail-apps b/changes/19867-get-avail-apps new file mode 100644 index 0000000000..4ace068f95 --- /dev/null +++ b/changes/19867-get-avail-apps @@ -0,0 +1 @@ +- Adds functionality for the `GET /software/app_store_apps` and `POST /software/app_store_apps` endpoints. \ No newline at end of file diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go new file mode 100644 index 0000000000..ef2bb4a130 --- /dev/null +++ b/ee/server/service/vpp.go @@ -0,0 +1,163 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/itunes" + "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" +) + +func (svc *Service) getVPPToken(ctx context.Context) (string, error) { + configMap, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken}) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "fetching vpp token") + } + + var vppTokenData fleet.VPPTokenData + if err := json.Unmarshal(configMap[fleet.MDMAssetVPPToken].Value, &vppTokenData); err != nil { + return "", ctxerr.Wrap(ctx, err, "unmarshaling VPP token data") + } + + return base64.StdEncoding.EncodeToString([]byte(vppTokenData.Token)), nil +} + +func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet.VPPApp, error) { + if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, err + } + + vppToken, err := svc.getVPPToken(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "retrieving VPP token") + } + + assets, err := vpp.GetAssets(vppToken, nil) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "fetching Apple VPP assets") + } + + if len(assets) == 0 { + return []*fleet.VPPApp{}, nil + } + + var adamIDs []string + for _, a := range assets { + adamIDs = append(adamIDs, a.AdamID) + } + + assetMetadata, err := itunes.GetAssetMetadata(adamIDs, &itunes.AssetMetadataFilter{Entity: "desktopSoftware"}) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "fetching VPP asset metadata") + } + + assignedApps, err := svc.ds.GetAssignedVPPApps(ctx, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "retrieving assigned VPP apps") + } + + var apps []*fleet.VPPApp + var appsToUpdate []*fleet.VPPApp + for _, a := range assets { + m, ok := assetMetadata[a.AdamID] + if !ok { + // Then this adam_id belongs to a non-desktop app. + continue + } + + app := &fleet.VPPApp{ + AdamID: a.AdamID, + AvailableCount: a.AvailableCount, + BundleIdentifier: m.BundleID, + IconURL: m.ArtworkURL, + Name: m.TrackName, + LatestVersion: m.Version, + } + + if _, ok := assignedApps[a.AdamID]; ok { + // Then this is already assigned, so filter it out. + appsToUpdate = append(appsToUpdate, app) + continue + } + + apps = append(apps, app) + } + + if len(appsToUpdate) > 0 { + if err := svc.ds.BatchInsertVPPApps(ctx, appsToUpdate); err != nil { + return nil, ctxerr.Wrap(ctx, err, "updating existing VPP apps") + } + } + + return apps, nil +} + +func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, adamID string) error { + if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil { + return err + } + + var teamName string + if teamID != nil { + tm, err := svc.ds.Team(ctx, *teamID) + if fleet.IsNotFound(err) { + return fleet.NewInvalidArgumentError("team_id", fmt.Sprintf("team %d does not exist", *teamID)). + WithStatus(http.StatusNotFound) + } else if err != nil { + return ctxerr.Wrap(ctx, err, "checking if team exists") + } + + teamName = tm.Name + } + + vppToken, err := svc.getVPPToken(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving VPP token") + } + + assets, err := vpp.GetAssets(vppToken, &vpp.AssetFilter{AdamID: adamID}) + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving VPP asset") + } + + if len(assets) == 0 { + return ctxerr.New(ctx, fmt.Sprintf("Error: Couldn't add software. %s isn't available in Apple Business Manager. Please purchase license in Apple Business Manager and try again.", adamID)) + } + + asset := assets[0] + + assetMetadata, err := itunes.GetAssetMetadata([]string{asset.AdamID}, &itunes.AssetMetadataFilter{Entity: "desktopSoftware"}) + if err != nil { + return ctxerr.Wrap(ctx, err, "fetching VPP asset metadata") + } + + assetMD := assetMetadata[asset.AdamID] + + // Check if we've already added an installer for this app + exists, err := svc.ds.UploadedSoftwareExists(ctx, assetMD.BundleID, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking existence of VPP app installer") + } + + if exists { + return ctxerr.New(ctx, fmt.Sprintf("Error: Couldn't add software. %s already has software available for install on the %s team.", assetMD.TrackName, teamName)) + } + + app := &fleet.VPPApp{ + AdamID: asset.AdamID, + AvailableCount: asset.AvailableCount, + BundleIdentifier: assetMD.BundleID, + IconURL: assetMD.ArtworkURL, + Name: assetMD.TrackName, + } + if err := svc.ds.InsertVPPAppWithTeam(ctx, app, teamID); err != nil { + return ctxerr.Wrap(ctx, err, "writing VPP app to db") + } + + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go index de2cd6513e..9e815d377b 100644 --- a/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go +++ b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go @@ -29,6 +29,7 @@ CREATE TABLE vpp_apps ( bundle_identifier VARCHAR(255) NOT NULL DEFAULT '', icon_url VARCHAR(255) NOT NULL DEFAULT '', name VARCHAR(255) NOT NULL DEFAULT '', + latest_version VARCHAR(255) NOT NULL DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -48,8 +49,15 @@ CREATE TABLE vpp_apps ( _, err = tx.Exec(` CREATE TABLE vpp_apps_teams ( adam_id VARCHAR(16) NOT NULL, - team_id INT(10) UNSIGNED NOT NULL, + + -- team_id NULL is for no team (cannot use 0 with foreign key) + team_id INT(10) UNSIGNED NULL, + + -- this field is 0 for global, and the team_id otherwise, and is + -- used for the unique index/constraint (team_id cannot be used + -- as it allows NULL). global_or_team_id INT(10) NOT NULL DEFAULT 0, + FOREIGN KEY (adam_id) REFERENCES vpp_apps (adam_id) ON DELETE CASCADE, FOREIGN KEY (team_id) REFERENCES teams (id) ON DELETE CASCADE, UNIQUE KEY idx_global_or_team_id_adam_id (global_or_team_id, adam_id) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 5b3698f396..a326316bcd 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1674,6 +1674,7 @@ CREATE TABLE `vpp_apps` ( `bundle_identifier` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `icon_url` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `latest_version` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`adam_id`), @@ -1685,7 +1686,7 @@ CREATE TABLE `vpp_apps` ( /*!40101 SET character_set_client = utf8 */; CREATE TABLE `vpp_apps_teams` ( `adam_id` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL, - `team_id` int(10) unsigned NOT NULL, + `team_id` int(10) unsigned DEFAULT NULL, `global_or_team_id` int(10) NOT NULL DEFAULT '0', UNIQUE KEY `idx_global_or_team_id_adam_id` (`global_or_team_id`,`adam_id`), KEY `adam_id` (`adam_id`), diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 70505c693e..be7fbbb69f 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -160,7 +160,6 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { require.Equal(t, installerID3, exec2.InstallerID) require.Equal(t, "SELECT 3", exec2.PreInstallCondition) require.True(t, exec2.SelfService) - } func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 2364614534..61e8aedb32 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -430,3 +430,29 @@ func (ds *Datastore) SyncHostsSoftwareTitles(ctx context.Context, updatedAt time } return nil } + +func (ds *Datastore) UploadedSoftwareExists(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error) { + stmt := ` +SELECT + 1 +FROM + software_titles st JOIN software_installers si ON si.title_id = st.id +WHERE + st.bundle_identifier = ? AND si.global_or_team_id = ? + ` + var tmID uint + if teamID != nil { + tmID = *teamID + } + + var titleExists bool + if err := sqlx.GetContext(ctx, ds.reader(ctx), &titleExists, stmt, bundleIdentifier, tmID); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + + return false, ctxerr.Wrap(ctx, err, "checking if software installer exists") + } + + return titleExists, nil +} diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index cb837cb0a5..a0570411d2 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -28,6 +28,7 @@ func TestSoftwareTitles(t *testing.T) { {"TeamFilterSoftwareTitles", testTeamFilterSoftwareTitles}, {"ListSoftwareTitlesInstallersOnly", testListSoftwareTitlesInstallersOnly}, {"ListSoftwareTitlesAvailableForInstallFilter", testListSoftwareTitlesAvailableForInstallFilter}, + {"UploadedSoftwareExists", testUploadedSoftwareExists}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -796,3 +797,42 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore require.EqualValues(t, 2, counts) require.Len(t, titles, 2) } + +func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { + ctx := context.Background() + + tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team Foo"}) + require.NoError(t, err) + + installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "installer1", + Source: "apps", + InstallScript: "echo", + Filename: "installer1.pkg", + BundleIdentifier: "com.foo.installer1", + }) + require.NoError(t, err) + require.NotZero(t, installer1) + installer2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "installer2", + Source: "apps", + InstallScript: "echo", + Filename: "installer2.pkg", + TeamID: &tm.ID, + BundleIdentifier: "com.foo.installer2", + }) + require.NoError(t, err) + require.NotZero(t, installer2) + + exists, err := ds.UploadedSoftwareExists(ctx, "com.foo.installer1", nil) + require.NoError(t, err) + require.True(t, exists) + + exists, err = ds.UploadedSoftwareExists(ctx, "com.foo.installer2", nil) + require.NoError(t, err) + require.False(t, exists) + + exists, err = ds.UploadedSoftwareExists(ctx, "com.foo.installer2", &tm.ID) + require.NoError(t, err) + require.True(t, exists) +} diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go new file mode 100644 index 0000000000..da874c7be0 --- /dev/null +++ b/server/datastore/mysql/vpp.go @@ -0,0 +1,127 @@ +package mysql + +import ( + "context" + "fmt" + "strings" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" +) + +func (ds *Datastore) BatchInsertVPPApps(ctx context.Context, apps []*fleet.VPPApp) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if err := insertVPPApps(ctx, tx, apps); err != nil { + return ctxerr.Wrap(ctx, err, "BatchInsertVPPApps insertVPPApps transaction") + } + + return nil + }) +} + +func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, teamID *uint) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + titleID, err := insertSoftwareTitleForVPPApp(ctx, tx, app) + if err != nil { + return err + } + + app.TitleID = titleID + + if err := insertVPPApps(ctx, tx, []*fleet.VPPApp{app}); err != nil { + return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPApps transaction") + } + + if err := insertVPPAppTeams(ctx, tx, app.AdamID, teamID); err != nil { + return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPAppTeams transaction") + } + + return nil + }) +} + +func (ds *Datastore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[string]struct{}, error) { + stmt := ` +SELECT + adam_id +FROM + vpp_apps_teams vat +WHERE + vat.global_or_team_id = ? + ` + var tmID uint + if teamID != nil { + tmID = *teamID + } + + var results []string + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, tmID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get assigned VPP apps") + } + + appSet := make(map[string]struct{}) + for _, r := range results { + appSet[r] = struct{}{} + } + + return appSet, nil +} + +func insertVPPApps(ctx context.Context, tx sqlx.ExtContext, apps []*fleet.VPPApp) error { + stmt := ` +INSERT INTO vpp_apps + (adam_id, available_count, bundle_identifier, icon_url, name, latest_version, title_id) +VALUES +%s +ON DUPLICATE KEY UPDATE + updated_at = CURRENT_TIMESTAMP, + latest_version = VALUES(latest_version), + icon_url = VALUES(icon_url), + name = VALUES(name) + ` + var args []any + var insertVals strings.Builder + + for _, a := range apps { + insertVals.WriteString(`(?, ?, ?, ?, ?, ?, ?),`) + args = append(args, a.AdamID, a.AvailableCount, a.BundleIdentifier, a.IconURL, a.Name, a.LatestVersion, a.TitleID) + } + + stmt = fmt.Sprintf(stmt, strings.TrimSuffix(insertVals.String(), ",")) + + _, err := tx.ExecContext(ctx, stmt, args...) + + return ctxerr.Wrap(ctx, err, "insert VPP apps") +} + +func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, adamID string, teamID *uint) error { + stmt := ` +INSERT INTO vpp_apps_teams + (adam_id, global_or_team_id, team_id) +VALUES + (?, ?, ?) + ` + + var globalOrTmID uint + if teamID != nil { + globalOrTmID = *teamID + } + + _, err := tx.ExecContext(ctx, stmt, adamID, globalOrTmID, teamID) + + return ctxerr.Wrap(ctx, err, "writing vpp app team mapping to db") +} + +func insertSoftwareTitleForVPPApp(ctx context.Context, tx sqlx.ExtContext, app *fleet.VPPApp) (uint, error) { + stmt := `INSERT INTO software_titles (name, source, bundle_identifier, browser) VALUES (?, '', ?, '')` + + result, err := tx.ExecContext(ctx, stmt, app.Name, app.BundleIdentifier) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "writing vpp app software title") + } + + id, _ := result.LastInsertId() + + return uint(id), nil +} diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go new file mode 100644 index 0000000000..da21c05f17 --- /dev/null +++ b/server/datastore/mysql/vpp_test.go @@ -0,0 +1,71 @@ +package mysql + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestVPP(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"VPPApps", testVPPApps}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + c.fn(t, ds) + }) + } +} + +func testVPPApps(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Create a team + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "foobar"}) + require.NoError(t, err) + + // Insert some VPP apps for the team + app1 := &fleet.VPPApp{Name: "vpp_app_1", AdamID: "1", BundleIdentifier: "b1"} + app2 := &fleet.VPPApp{Name: "vpp_app_2", AdamID: "2", BundleIdentifier: "b2"} + err = ds.InsertVPPAppWithTeam(ctx, app1, &team.ID) + require.NoError(t, err) + + err = ds.InsertVPPAppWithTeam(ctx, app2, &team.ID) + require.NoError(t, err) + + // Insert some VPP apps for no team + appNoTeam1 := &fleet.VPPApp{Name: "vpp_no_team_app_1", AdamID: "3", BundleIdentifier: "b3"} + appNoTeam2 := &fleet.VPPApp{Name: "vpp_no_team_app_2", AdamID: "4", BundleIdentifier: "b4"} + err = ds.InsertVPPAppWithTeam(ctx, appNoTeam1, nil) + require.NoError(t, err) + err = ds.InsertVPPAppWithTeam(ctx, appNoTeam2, nil) + require.NoError(t, err) + + // Check that getting the assigned apps works + appSet, err := ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Equal(t, map[string]struct{}{app1.AdamID: {}, app2.AdamID: {}}, appSet) + + appSet, err = ds.GetAssignedVPPApps(ctx, nil) + require.NoError(t, err) + require.Equal(t, map[string]struct{}{appNoTeam1.AdamID: {}, appNoTeam2.AdamID: {}}, appSet) + + var appTitles []fleet.SoftwareTitle + err = sqlx.SelectContext(ctx, ds.reader(ctx), &appTitles, `SELECT name, bundle_identifier FROM software_titles WHERE bundle_identifier IN (?,?) ORDER BY bundle_identifier`, app1.BundleIdentifier, app2.BundleIdentifier) + require.NoError(t, err) + require.Len(t, appTitles, 2) + require.Equal(t, app1.BundleIdentifier, *appTitles[0].BundleIdentifier) + require.Equal(t, app2.BundleIdentifier, *appTitles[1].BundleIdentifier) + require.Equal(t, app1.Name, appTitles[0].Name) + require.Equal(t, app2.Name, appTitles[1].Name) +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 25712b9190..6ad24985d4 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -583,6 +583,10 @@ type Datastore interface { // attempt on the host. SetHostSoftwareInstallResult(ctx context.Context, result *HostSoftwareInstallResultPayload) error + // UploadedSoftwareExists checks if a software title with the given bundle identifier exists in + // the given team. + UploadedSoftwareExists(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error) + /////////////////////////////////////////////////////////////////////////////// // OperatingSystemsStore @@ -1568,6 +1572,10 @@ type Datastore interface { // HasSelfServiceSoftwareInstallers returns true if self-service software installers are available for the team or globally. HasSelfServiceSoftwareInstallers(ctx context.Context, platform string, teamID *uint) (bool, error) + + BatchInsertVPPApps(ctx context.Context, apps []*VPPApp) error + GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[string]struct{}, error) + InsertVPPAppWithTeam(ctx context.Context, app *VPPApp, teamID *uint) error } // MDMAppleStore wraps nanomdm's storage and adds methods to deal with diff --git a/server/fleet/service.go b/server/fleet/service.go index 7da740af41..808a4a8257 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -651,6 +651,10 @@ type Service interface { // HasSelfServiceSoftwareInstallers returns whether the host has self-service software installers HasSelfServiceSoftwareInstallers(ctx context.Context, host *Host) (bool, error) + GetAppStoreApps(ctx context.Context, teamID *uint) ([]*VPPApp, error) + + AddAppStoreApp(ctx context.Context, teamID *uint, adamID string) error + // ///////////////////////////////////////////////////////////////////////////// // Vulnerabilities diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index cef055a5e7..2f331f5cd8 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -8,7 +8,7 @@ import "time" type VPPApp struct { // AdamID is a unique identifier assigned to each app in // the App Store, this value is managed by Apple. - AdamID string `db:"adam_id"` + AdamID string `db:"adam_id" json:"app_store_id"` // AvailableCount keeps track of how many licenses are // available for the specific software, this value is // managed by Apple and tracked in the DB as a helper. @@ -16,17 +16,23 @@ type VPPApp struct { // TODO(roberto): could we omit this and rely on API errors // from Apple instead? seems safer unless we really need to // display this value in the API. - AvailableCount uint `db:"available_count"` + AvailableCount uint `db:"available_count" json:"available_count"` // BundleIdentifier is the unique bundle identifier of the // Application. - BundleIdentifier string `db:"bundle_identifier"` + BundleIdentifier string `db:"bundle_identifier" json:"bundle_identifier"` // IconURL is the URL of this App icon - IconURL string `db:"icon_url"` + IconURL string `db:"icon_url" json:"icon_url"` // Name is the user-facing name of this app. - Name string `db:"name"` + Name string `db:"name" json:"name"` + // LatestVersion is the latest version of this app. + LatestVersion string `db:"latest_version" json:"latest_version"` + // Added indicates whether or not this app has been added to Fleet. + Added bool `json:"added"` + TeamID *uint `db:"-" json:"-"` + TitleID uint `db:"title_id" json:"-"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + CreatedAt time.Time `db:"created_at" json:"-"` + UpdatedAt time.Time `db:"updated_at" json:"-"` } // AuthzType implements authz.AuthzTyper. diff --git a/server/mdm/apple/itunes/api.go b/server/mdm/apple/itunes/api.go new file mode 100644 index 0000000000..fafac6a82f --- /dev/null +++ b/server/mdm/apple/itunes/api.go @@ -0,0 +1,107 @@ +package itunes + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" +) + +type AssetMetadata struct { + BundleID string `json:"bundleId"` + ArtworkURL string `json:"artworkUrl512"` + Version string `json:"version"` + TrackName string `json:"trackName"` + TrackID uint `json:"trackId"` +} + +type AssetMetadataFilter struct { + Entity string +} + +// client is a package-level client (similar to http.DefaultClient) so it can +// be reused instead of created as needed, as the internal Transport typically +// has internal state (cached connections, etc) and it's safe for concurrent +// use. +var client = fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)) + +func GetAssetMetadata(adamIDs []string, filter *AssetMetadataFilter) (map[string]AssetMetadata, error) { + baseURL := getBaseURL() + reqURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("parsing base iTunes URL: %w", err) + } + + adamIDsParam := strings.Join(adamIDs, ",") + + if filter != nil { + query := url.Values{} + query.Add("id", adamIDsParam) + query.Add("entity", filter.Entity) + reqURL.RawQuery = query.Encode() + } + + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating request to Apple iTunes endpoint: %w", err) + } + + var bodyResp struct { + Results []AssetMetadata `json:"results"` + } + + if err = do(req, &bodyResp); err != nil { + return nil, fmt.Errorf("retrieving asset metadata: %w", err) + } + + metadata := make(map[string]AssetMetadata) + for _, a := range bodyResp.Results { + metadata[strconv.Itoa(int(a.TrackID))] = a + } + + return metadata, nil +} + +func do[T any](req *http.Request, dest *T) error { + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("making request to Apple iTunes endpoint: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response body from Apple iTunes endpoint: %w", err) + } + + if resp.StatusCode != http.StatusOK { + limitedBody := body + if len(limitedBody) > 1000 { + limitedBody = limitedBody[:1000] + } + return fmt.Errorf("calling Apple iTunes endpoint failed with status %d: %s", resp.StatusCode, string(limitedBody)) + } + + if dest != nil { + if err := json.Unmarshal(body, dest); err != nil { + return fmt.Errorf("decoding response data from Apple iTunes endpoint: %w", err) + } + } + + return nil +} + +func getBaseURL() string { + devURL := os.Getenv("FLEET_DEV_ITUNES_URL") + if devURL != "" { + return devURL + } + return "https://itunes.apple.com/lookup" +} diff --git a/server/mdm/apple/itunes/api_test.go b/server/mdm/apple/itunes/api_test.go new file mode 100644 index 0000000000..a43031fcea --- /dev/null +++ b/server/mdm/apple/itunes/api_test.go @@ -0,0 +1,21 @@ +package itunes + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetBaseURL(t *testing.T) { + t.Run("Default URL", func(t *testing.T) { + os.Setenv("FLEET_DEV_ITUNES_URL", "") + require.Equal(t, "https://itunes.apple.com/lookup", getBaseURL()) + }) + + t.Run("Custom URL", func(t *testing.T) { + customURL := "http://localhost:8000" + os.Setenv("FLEET_DEV_ITUNES_URL", customURL) + require.Equal(t, customURL, getBaseURL()) + }) +} diff --git a/server/mdm/apple/vpp/api.go b/server/mdm/apple/vpp/api.go index a0bd17f655..de4dbe4fe6 100644 --- a/server/mdm/apple/vpp/api.go +++ b/server/mdm/apple/vpp/api.go @@ -23,6 +23,9 @@ type Asset struct { // PricingParam is the quality of a product in the store. // Possible Values are `STDQ` and `PLUS` PricingParam string `json:"pricingParam"` + // AvailableCount is the number of available licenses for this app in the location specified by + // the VPP token. + AvailableCount uint `json:"availableCount"` } // ErrorResponse represents the response that contains the error that occurs. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 153d56b433..3d3fbdab90 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -425,6 +425,8 @@ type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, opts fleet type SetHostSoftwareInstallResultFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error +type UploadedSoftwareExistsFunc func(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error) + type GetHostOperatingSystemFunc func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) type ListOperatingSystemsFunc func(ctx context.Context) ([]fleet.OperatingSystem, error) @@ -987,6 +989,12 @@ type BatchSetSoftwareInstallersFunc func(ctx context.Context, tmID *uint, instal type HasSelfServiceSoftwareInstallersFunc func(ctx context.Context, platform string, teamID *uint) (bool, error) +type BatchInsertVPPAppsFunc func(ctx context.Context, apps []*fleet.VPPApp) error + +type GetAssignedVPPAppsFunc func(ctx context.Context, teamID *uint) (map[string]struct{}, error) + +type InsertVPPAppWithTeamFunc func(ctx context.Context, app *fleet.VPPApp, teamID *uint) error + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -1597,6 +1605,9 @@ type DataStore struct { SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFuncInvoked bool + UploadedSoftwareExistsFunc UploadedSoftwareExistsFunc + UploadedSoftwareExistsFuncInvoked bool + GetHostOperatingSystemFunc GetHostOperatingSystemFunc GetHostOperatingSystemFuncInvoked bool @@ -2440,6 +2451,15 @@ type DataStore struct { HasSelfServiceSoftwareInstallersFunc HasSelfServiceSoftwareInstallersFunc HasSelfServiceSoftwareInstallersFuncInvoked bool + BatchInsertVPPAppsFunc BatchInsertVPPAppsFunc + BatchInsertVPPAppsFuncInvoked bool + + GetAssignedVPPAppsFunc GetAssignedVPPAppsFunc + GetAssignedVPPAppsFuncInvoked bool + + InsertVPPAppWithTeamFunc InsertVPPAppWithTeamFunc + InsertVPPAppWithTeamFuncInvoked bool + mu sync.Mutex } @@ -3864,6 +3884,13 @@ func (s *DataStore) SetHostSoftwareInstallResult(ctx context.Context, result *fl return s.SetHostSoftwareInstallResultFunc(ctx, result) } +func (s *DataStore) UploadedSoftwareExists(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error) { + s.mu.Lock() + s.UploadedSoftwareExistsFuncInvoked = true + s.mu.Unlock() + return s.UploadedSoftwareExistsFunc(ctx, bundleIdentifier, teamID) +} + func (s *DataStore) GetHostOperatingSystem(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) { s.mu.Lock() s.GetHostOperatingSystemFuncInvoked = true @@ -5830,3 +5857,24 @@ func (s *DataStore) HasSelfServiceSoftwareInstallers(ctx context.Context, platfo s.mu.Unlock() return s.HasSelfServiceSoftwareInstallersFunc(ctx, platform, teamID) } + +func (s *DataStore) BatchInsertVPPApps(ctx context.Context, apps []*fleet.VPPApp) error { + s.mu.Lock() + s.BatchInsertVPPAppsFuncInvoked = true + s.mu.Unlock() + return s.BatchInsertVPPAppsFunc(ctx, apps) +} + +func (s *DataStore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[string]struct{}, error) { + s.mu.Lock() + s.GetAssignedVPPAppsFuncInvoked = true + s.mu.Unlock() + return s.GetAssignedVPPAppsFunc(ctx, teamID) +} + +func (s *DataStore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, teamID *uint) error { + s.mu.Lock() + s.InsertVPPAppWithTeamFuncInvoked = true + s.mu.Unlock() + return s.InsertVPPAppWithTeamFunc(ctx, app, teamID) +} diff --git a/server/service/handler.go b/server/service/handler.go index b15b1214b2..2071390583 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -376,6 +376,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/software/install/results/{install_uuid}", getSoftwareInstallResultsEndpoint, getSoftwareInstallResultsRequest{}) ue.POST("/api/_version_/fleet/software/batch", batchSetSoftwareInstallersEndpoint, batchSetSoftwareInstallersRequest{}) + // App store software + ue.GET("/api/_version_/fleet/software/app_store_apps", getAppStoreAppsEndpoint, getAppStoreAppsRequest{}) + ue.POST("/api/_version_/fleet/software/app_store_apps", addAppStoreAppEndpoint, addAppStoreAppRequest{}) + // Vulnerabilities ue.GET("/api/_version_/fleet/vulnerabilities", listVulnerabilitiesEndpoint, listVulnerabilitiesRequest{}) ue.GET("/api/_version_/fleet/vulnerabilities/{cve}", getVulnerabilityEndpoint, getVulnerabilityRequest{}) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 5adadb8c02..1d264a00da 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -10687,6 +10687,102 @@ func (s *integrationEnterpriseTestSuite) TestAutofillPoliciesAuthTeamUser() { } } +func (s *integrationMDMTestSuite) TestVPPApps() { + t := s.T() + // Invalid token + t.Setenv("FLEET_DEV_VPP_URL", 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 + t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?serverError") + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "Apple VPP endpoint returned error: Internal server error (error number: 9603)") + + // 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) + t.Setenv("FLEET_DEV_VPP_URL", 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) + + // Create a team + var newTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp) + team := newTeamResp.Team + + // Get list of VPP apps from "Apple" + // We're passing team 1 here, but we haven't added any app store apps to that team, so we get + // back all available apps in our VPP location. + var appResp getAppStoreAppsResponse + s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", strconv.Itoa(int(team.ID))) + require.NoError(t, appResp.Err) + require.Len(t, appResp.AppStoreApps, 2) + require.Equal(t, "App 1", appResp.AppStoreApps[0].Name) + require.Equal(t, "a-1", appResp.AppStoreApps[0].BundleIdentifier) + require.Equal(t, uint(12), appResp.AppStoreApps[0].AvailableCount) + require.Equal(t, "https://example.com/images/1", appResp.AppStoreApps[0].IconURL) + require.Equal(t, "1", appResp.AppStoreApps[0].AdamID) + require.Equal(t, "1.0.0", appResp.AppStoreApps[0].LatestVersion) + require.False(t, appResp.AppStoreApps[0].Added) + + require.Equal(t, "App 2", appResp.AppStoreApps[1].Name) + require.Equal(t, "b-2", appResp.AppStoreApps[1].BundleIdentifier) + require.Equal(t, uint(3), appResp.AppStoreApps[1].AvailableCount) + require.Equal(t, "https://example.com/images/2", appResp.AppStoreApps[1].IconURL) + require.Equal(t, "2", appResp.AppStoreApps[1].AdamID) + require.Equal(t, "2.0.0", appResp.AppStoreApps[1].LatestVersion) + require.False(t, appResp.AppStoreApps[1].Added) + + // Add an app store app to team 1 + var addAppResp addAppStoreAppResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: appResp.AppStoreApps[0].AdamID}, http.StatusOK, &addAppResp) + + // Add an app store app to non-existent team + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: ptr.Uint(9999), AppStoreID: appResp.AppStoreApps[0].AdamID}, http.StatusNotFound, &addAppResp) + + // Add an installer + // Verify that we are not able to add the VPP app for that same app whose installer we just added + + // Now we should be filtering out the app we added to team 1 + s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", strconv.Itoa(int(team.ID))) + require.NoError(t, appResp.Err) + require.Len(t, appResp.AppStoreApps, 1) + require.Equal(t, "App 2", appResp.AppStoreApps[0].Name) + require.Equal(t, "b-2", appResp.AppStoreApps[0].BundleIdentifier) + require.Equal(t, uint(3), appResp.AppStoreApps[0].AvailableCount) + require.Equal(t, "https://example.com/images/2", appResp.AppStoreApps[0].IconURL) + require.Equal(t, "2", appResp.AppStoreApps[0].AdamID) + require.Equal(t, "2.0.0", appResp.AppStoreApps[0].LatestVersion) + require.False(t, appResp.AppStoreApps[0].Added) + + // Delete VPP token 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) +} + // 1. software title uploaded doesn't match existing title // 2. host reports software with the same bundle identifier // 3. reconciler runs, doesn't create a new title @@ -11274,5 +11370,4 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID) require.NoError(t, err) assert.Empty(t, team1CalendarEvents) - } diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index a30e86bea9..b5df5e9c60 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -95,6 +95,7 @@ type integrationMDMTestSuite struct { logger kitlog.Logger scepChallenge string appleVPPConfigSrv *httptest.Server + appleITunesSrv *httptest.Server mockedDownloadFleetdmMeta fleetdbase.Metadata } @@ -313,6 +314,13 @@ func (s *integrationMDMTestSuite) SetupSuite() { })) s.appleVPPConfigSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "assets") { + // Then we're responding to GetAssets + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"assets": [{"adamId": "1", "pricingParam": "STDQ", "availableCount": 12}, {"adamId": "2", "pricingParam": "STDQ", "availableCount": 3}]}`)) + return + } + 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 @@ -331,7 +339,27 @@ func (s *integrationMDMTestSuite) SetupSuite() { _, _ = w.Write(resp) })) + + s.appleITunesSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // a map of apps we can respond with + db := map[string]string{ + "1": `{"bundleId": "a-1", "artworkUrl512": "https://example.com/images/1", "version": "1.0.0", "trackName": "App 1", "TrackID": 1}`, + "2": `{"bundleId": "b-2", "artworkUrl512": "https://example.com/images/2", "version": "2.0.0", "trackName": "App 2", "TrackID": 2}`, + } + + adamIDString := r.URL.Query().Get("id") + adamIDs := strings.Split(adamIDString, ",") + + var objs []string + for _, a := range adamIDs { + objs = append(objs, db[a]) + } + + _, _ = w.Write([]byte(fmt.Sprintf(`{"results": [%s]}`, strings.Join(objs, ",")))) + })) + s.T().Setenv("TEST_FLEETDM_API_URL", fleetdmSrv.URL) + s.T().Setenv("FLEET_DEV_ITUNES_URL", s.appleITunesSrv.URL) s.mockedDownloadFleetdmMeta = fleetdbase.Metadata{ MSIURL: fmt.Sprintf("https://download-testing.fleetdm.com/archive/stable/%s/fleetd-base.msi", uuid.NewString()), @@ -364,6 +392,7 @@ func (s *integrationMDMTestSuite) SetupSuite() { s.T().Cleanup(fleetdmSrv.Close) s.T().Cleanup(s.appleVPPConfigSrv.Close) + s.T().Cleanup(s.appleITunesSrv.Close) } func (s *integrationMDMTestSuite) TearDownSuite() { @@ -1061,52 +1090,6 @@ func (s *integrationMDMTestSuite) uploadDataViaForm(endpoint, fieldName, fileNam } } -func (s *integrationMDMTestSuite) TestMDMVPPToken() { - t := s.T() - // Invalid token - t.Setenv("FLEET_DEV_VPP_URL", 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 - t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?serverError") - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "Apple VPP endpoint returned error: Internal server error (error number: 9603)") - - // 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) - t.Setenv("FLEET_DEV_VPP_URL", 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() diff --git a/server/service/software_installers_test.go b/server/service/software_installers_test.go index 3023773cf7..22a672f76c 100644 --- a/server/service/software_installers_test.go +++ b/server/service/software_installers_test.go @@ -85,6 +85,14 @@ func TestSoftwareInstallersAuth(t *testing.T) { return nil, nil } + ds.TeamExistsFunc = func(ctx context.Context, teamID uint) (bool, error) { + return false, nil + } + + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + return map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, nil + } + _, err := svc.DownloadSoftwareInstaller(ctx, 1, tt.teamID) if tt.teamID == nil { require.Error(t, err) @@ -99,6 +107,26 @@ func TestSoftwareInstallersAuth(t *testing.T) { checkAuthErr(t, tt.shouldFailWrite, err) } + // Note: these calls always return an error because they're attempting to unmarshal a + // non-existent VPP token. + _, err = svc.GetAppStoreApps(ctx, tt.teamID) + if tt.teamID == nil { + require.Error(t, err) + } else { + if tt.shouldFailRead { + checkAuthErr(t, true, err) + } + } + + err = svc.AddAppStoreApp(ctx, tt.teamID, "123") + if tt.teamID == nil { + require.Error(t, err) + } else { + if tt.shouldFailWrite { + checkAuthErr(t, true, err) + } + } + // TODO: configure test with mock software installer store and add tests to check upload auth }) } diff --git a/server/service/vpp.go b/server/service/vpp.go new file mode 100644 index 0000000000..7ea6292ff8 --- /dev/null +++ b/server/service/vpp.go @@ -0,0 +1,73 @@ +package service + +import ( + "context" + + "github.com/fleetdm/fleet/v4/server/fleet" +) + +////////////////////////////////////////////////////////////////////////////// +// Get App Store apps +////////////////////////////////////////////////////////////////////////////// + +type getAppStoreAppsRequest struct { + TeamID uint `query:"team_id"` +} + +type getAppStoreAppsResponse struct { + AppStoreApps []*fleet.VPPApp `json:"app_store_apps"` + Err error `json:"error,omitempty"` +} + +func (r getAppStoreAppsResponse) error() error { return r.Err } + +func getAppStoreAppsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getAppStoreAppsRequest) + apps, err := svc.GetAppStoreApps(ctx, &req.TeamID) + if err != nil { + return &getAppStoreAppsResponse{Err: err}, nil + } + + return &getAppStoreAppsResponse{AppStoreApps: apps}, nil +} + +func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet.VPPApp, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +////////////////////////////////////////////////////////////////////////////// +// Add App Store apps +////////////////////////////////////////////////////////////////////////////// + +type addAppStoreAppRequest struct { + TeamID *uint `json:"team_id"` + AppStoreID string `json:"app_store_id"` +} + +type addAppStoreAppResponse struct { + Err error `json:"error,omitempty"` +} + +func (r addAppStoreAppResponse) error() error { return r.Err } + +func addAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*addAppStoreAppRequest) + err := svc.AddAppStoreApp(ctx, req.TeamID, req.AppStoreID) + if err != nil { + return &addAppStoreAppResponse{Err: err}, nil + } + + return &addAppStoreAppResponse{}, nil +} + +func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, adamID string) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} diff --git a/server/service/vpp_test.go b/server/service/vpp_test.go new file mode 100644 index 0000000000..d4efec90f4 --- /dev/null +++ b/server/service/vpp_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/stretchr/testify/require" +) + +func TestVPPAuth(t *testing.T) { + ds := new(mock.Store) + + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license}) + + testCases := []struct { + name string + user *fleet.User + teamID *uint + shouldFailRead bool + shouldFailWrite bool + }{ + {"no role no team", test.UserNoRoles, nil, true, true}, + {"no role team", test.UserNoRoles, ptr.Uint(1), true, true}, + {"global admin no team", test.UserAdmin, nil, false, false}, + {"global admin team", test.UserAdmin, ptr.Uint(1), false, false}, + {"global maintainer no team", test.UserMaintainer, nil, false, false}, + {"global maintainer team", test.UserMaintainer, ptr.Uint(1), false, false}, + {"global observer no team", test.UserObserver, nil, true, true}, + {"global observer team", test.UserObserver, ptr.Uint(1), true, true}, + {"global observer+ no team", test.UserObserverPlus, nil, true, true}, + {"global observer+ team", test.UserObserverPlus, ptr.Uint(1), true, true}, + {"global gitops no team", test.UserGitOps, nil, true, false}, + {"global gitops team", test.UserGitOps, ptr.Uint(1), true, false}, + {"team admin no team", test.UserTeamAdminTeam1, nil, true, true}, + {"team admin team", test.UserTeamAdminTeam1, ptr.Uint(1), false, false}, + {"team admin other team", test.UserTeamAdminTeam2, ptr.Uint(1), true, true}, + {"team maintainer no team", test.UserTeamMaintainerTeam1, nil, true, true}, + {"team maintainer team", test.UserTeamMaintainerTeam1, ptr.Uint(1), false, false}, + {"team maintainer other team", test.UserTeamMaintainerTeam2, ptr.Uint(1), true, true}, + {"team observer no team", test.UserTeamObserverTeam1, nil, true, true}, + {"team observer team", test.UserTeamObserverTeam1, ptr.Uint(1), true, true}, + {"team observer other team", test.UserTeamObserverTeam2, ptr.Uint(1), true, true}, + {"team observer+ no team", test.UserTeamObserverPlusTeam1, nil, true, true}, + {"team observer+ team", test.UserTeamObserverPlusTeam1, ptr.Uint(1), true, true}, + {"team observer+ other team", test.UserTeamObserverPlusTeam2, ptr.Uint(1), true, true}, + {"team gitops no team", test.UserTeamGitOpsTeam1, nil, true, true}, + {"team gitops team", test.UserTeamGitOpsTeam1, ptr.Uint(1), true, false}, + {"team gitops other team", test.UserTeamGitOpsTeam2, ptr.Uint(1), true, true}, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + + ds.TeamExistsFunc = func(ctx context.Context, teamID uint) (bool, error) { + return false, nil + } + + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + return map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, nil + } + + ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { + return &fleet.Team{ID: 1}, nil + } + + // Note: these calls always return an error because they're attempting to unmarshal a + // non-existent VPP token. + _, err := svc.GetAppStoreApps(ctx, tt.teamID) + if tt.teamID == nil { + require.Error(t, err) + } else { + if tt.shouldFailRead { + checkAuthErr(t, true, err) + } + } + + err = svc.AddAppStoreApp(ctx, tt.teamID, "123") + if tt.teamID == nil { + require.Error(t, err) + } else { + if tt.shouldFailWrite { + checkAuthErr(t, true, err) + } + } + }) + } +} From 464c248f30ebadd310db9d39d7d3ee4c7b95f65b Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 15 Jul 2024 15:06:30 -0400 Subject: [PATCH 09/38] VPP: List/Get software title endpoints to return VPP apps (#20445) --- ...lude-vpp-apps-in-software-titles-endpoints | 1 + cmd/fleetctl/get_test.go | 22 +- .../tables/20240701113709_VPPDBUpdates.go | 13 +- server/datastore/mysql/software.go | 4 +- server/datastore/mysql/software_installers.go | 2 +- server/datastore/mysql/software_titles.go | 35 +- .../datastore/mysql/software_titles_test.go | 345 +++++++++++++----- server/datastore/mysql/vpp.go | 101 ++++- server/datastore/mysql/vpp_test.go | 236 ++++++++++++ server/fleet/datastore.go | 20 +- server/fleet/software.go | 20 +- server/fleet/software_installer.go | 5 +- server/fleet/vpp.go | 21 ++ server/mock/datastore_mock.go | 24 ++ server/service/integration_enterprise_test.go | 19 +- server/service/software_titles.go | 40 +- 16 files changed, 744 insertions(+), 164 deletions(-) create mode 100644 changes/19880-include-vpp-apps-in-software-titles-endpoints diff --git a/changes/19880-include-vpp-apps-in-software-titles-endpoints b/changes/19880-include-vpp-apps-in-software-titles-endpoints new file mode 100644 index 0000000000..d75334c6c2 --- /dev/null +++ b/changes/19880-include-vpp-apps-in-software-titles-endpoints @@ -0,0 +1 @@ +* Added the associated VPP apps to the `GET /software/titles` and `GET /software/titles/:id` endpoints. diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 009fbbeb96..fa71e12380 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -684,11 +684,12 @@ func TestGetSoftwareTitles(t *testing.T) { apiVersion: "1" kind: software_title spec: -- hosts_count: 2 +- available_for_install: false + hosts_count: 2 + icon_url: null id: 0 name: foo self_service: false - software_package: null source: chrome_extensions versions: - id: 0 @@ -705,11 +706,12 @@ spec: vulnerabilities: - cve-123-456-003 versions_count: 3 -- hosts_count: 0 +- available_for_install: false + hosts_count: 0 + icon_url: null id: 0 name: bar self_service: false - software_package: null source: deb_packages versions: - id: 0 @@ -727,7 +729,9 @@ spec: "id": 0, "name": "foo", "source": "chrome_extensions", + "available_for_install": false, "hosts_count": 2, + "icon_url": null, "versions_count": 3, "versions": [ { @@ -753,24 +757,24 @@ spec: ] } ], - "self_service": false, - "software_package": null + "self_service": false }, { "id": 0, "name": "bar", "source": "deb_packages", + "available_for_install": false, "hosts_count": 0, + "icon_url": null, "versions_count": 1, "versions": [ { "id": 0, "version": "0.0.3", - "vulnerabilities": null + "vulnerabilities": null } ], - "self_service": false, - "software_package": null + "self_service": false } ] } diff --git a/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go index 9e815d377b..94830bbc2f 100644 --- a/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go +++ b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go @@ -15,7 +15,7 @@ func Up_20240701113709(tx *sql.Tx) error { -- This table is also used as a cache of the response from the "Get Assets" -- Apple endpoint as well as the FleetDM website endpoint which will return -- the app metadata. --- If an asset has an entry here and an entry in vpp_apps_teams, then it has +-- If an asset has an entry here and an entry in vpp_apps_teams, then it has -- been added to Fleet. CREATE TABLE vpp_apps ( adam_id VARCHAR(16) NOT NULL, @@ -49,13 +49,12 @@ CREATE TABLE vpp_apps ( _, err = tx.Exec(` CREATE TABLE vpp_apps_teams ( adam_id VARCHAR(16) NOT NULL, - -- team_id NULL is for no team (cannot use 0 with foreign key) team_id INT(10) UNSIGNED NULL, -- this field is 0 for global, and the team_id otherwise, and is - -- used for the unique index/constraint (team_id cannot be used - -- as it allows NULL). + -- used for the unique index/constraint (team_id cannot be used + -- as it allows NULL). global_or_team_id INT(10) NOT NULL DEFAULT 0, FOREIGN KEY (adam_id) REFERENCES vpp_apps (adam_id) ON DELETE CASCADE, @@ -72,7 +71,7 @@ CREATE TABLE vpp_apps_teams ( CREATE TABLE host_vpp_software_installs ( id int(10) unsigned NOT NULL AUTO_INCREMENT, host_id INT(10) UNSIGNED NOT NULL, - + -- This is the adam_id of the VPP software that's being installed adam_id VARCHAR(16) NOT NULL, @@ -82,8 +81,8 @@ CREATE TABLE host_vpp_software_installs ( -- This indicates whether or not this was a self-service install self_service TINYINT(1) NOT NULL DEFAULT FALSE, - - -- This is an ID for the event of "associating" the software with a host. + + -- This is an ID for the event of "associating" the software with a host. -- This value comes from the "eventId" field in the response here: -- https://developer.apple.com/documentation/devicemanagement/associate_assets associated_event_id VARCHAR(36), diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index b3c89b903d..56e4633e27 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -1675,7 +1675,9 @@ OR s.title_id != st.id; cleanupStmt := ` DELETE st FROM software_titles st LEFT JOIN software s ON s.title_id = st.id - WHERE s.title_id IS NULL AND NOT EXISTS (SELECT 1 FROM software_installers si WHERE si.title_id = st.id)` + WHERE s.title_id IS NULL AND + NOT EXISTS (SELECT 1 FROM software_installers si WHERE si.title_id = st.id) AND + NOT EXISTS (SELECT 1 FROM vpp_apps vap WHERE vap.title_id = st.id)` res, err = tx.ExecContext(ctx, cleanupStmt) if err != nil { diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 9f0e910405..59e96b5db6 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -248,7 +248,7 @@ SELECT %s FROM software_installers si - LEFT OUTER JOIN software_titles st ON st.id = si.title_id + JOIN software_titles st ON st.id = si.title_id %s WHERE si.title_id = ? AND si.global_or_team_id = ?`, diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 61e8aedb32..338a5689ec 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -31,21 +31,28 @@ SELECT st.name, st.source, st.browser, + st.bundle_identifier, COALESCE(SUM(sthc.hosts_count), 0) as hosts_count, - MAX(sthc.updated_at) as counts_updated_at + MAX(sthc.updated_at) as counts_updated_at, + COUNT(si.id) as software_installers_count, + COUNT(vat.adam_id) as vpp_apps_count FROM software_titles st LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND %s -WHERE st.id = ? -AND (sthc.hosts_count > 0 OR EXISTS (SELECT 1 FROM software_installers si WHERE si.title_id = st.id AND si.global_or_team_id = ?)) +LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ? +LEFT JOIN vpp_apps vap ON vap.title_id = st.id +LEFT JOIN vpp_apps_teams vat ON vat.global_or_team_id = ? AND vat.adam_id = vap.adam_id +WHERE st.id = ? AND + (sthc.hosts_count > 0 OR vat.adam_id IS NOT NULL OR si.id IS NOT NULL) GROUP BY st.id, st.name, st.source, - st.browser + st.browser, + st.bundle_identifier `, teamFilter, ) var title fleet.SoftwareTitle - if err := sqlx.GetContext(ctx, ds.reader(ctx), &title, selectSoftwareTitleStmt, id, tmID); err != nil { + if err := sqlx.GetContext(ctx, ds.reader(ctx), &title, selectSoftwareTitleStmt, tmID, tmID, id); err != nil { if err == sql.ErrNoRows { return nil, notFound("SoftwareTitle").WithID(id) } @@ -202,10 +209,14 @@ SELECT st.browser, MAX(COALESCE(sthc.hosts_count, 0)) as hosts_count, MAX(COALESCE(sthc.updated_at, date('0001-01-01 00:00:00'))) as counts_updated_at, - si.filename as software_package, - COALESCE(si.self_service, false) as self_service + COALESCE(si.self_service, false) as self_service, + -- this count will be 1 if an installer or VPP app is available, 0 otherwise + COUNT(COALESCE(si.id, vat.adam_id)) as available_for_install, + NULLIF(vap.icon_url, '') as icon_url FROM software_titles st LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ? +LEFT JOIN vpp_apps vap ON vap.title_id = st.id +LEFT JOIN vpp_apps_teams vat ON vat.global_or_team_id = ? AND vat.adam_id = vap.adam_id LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.team_id = ? -- placeholder for JOIN on software/software_cve %s @@ -213,16 +224,16 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND WHERE %s -- placeholder for filter based on software installed on hosts + software installers AND (%s) -GROUP BY st.id, software_package, self_service` +GROUP BY st.id, self_service, icon_url` cveJoinType := "LEFT" if opt.VulnerableOnly { cveJoinType = "INNER" } - args := []any{0, 0} + args := []any{0, 0, 0} if opt.TeamID != nil { - args[0], args[1] = *opt.TeamID, *opt.TeamID + args[0], args[1], args[2] = *opt.TeamID, *opt.TeamID, *opt.TeamID } additionalWhere := "TRUE" @@ -246,9 +257,9 @@ GROUP BY st.id, software_package, self_service` args = append(args, match, match) } - // default to "a software installer exists", and see next condition. + // default to "a software installer or VPP app exists", and see next condition. defaultFilter := ` - si.id IS NOT NULL + (si.id IS NOT NULL OR vat.adam_id IS NOT NULL) ` // add software installed for hosts if any of this is true: diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index a0570411d2..f29f6aeced 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -2,7 +2,10 @@ package mysql import ( "context" + "crypto/rand" "database/sql" + "encoding/base64" + "io" "sort" "testing" "time" @@ -314,6 +317,9 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { require.NoError(t, err) _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false) require.NoError(t, err) + // create a VPP app not installed anywhere + vpp1, _ := createVPPApp(t, ds, nil, "vpp1", "com.app.vpp1") + require.NotEmpty(t, vpp1) require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) @@ -325,27 +331,49 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { OrderDirection: fleet.OrderDescending, }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) - require.Len(t, titles, 9) - require.Equal(t, "bar", titles[0].Name) - require.Equal(t, "deb_packages", titles[0].Source) - require.Equal(t, "foo", titles[1].Name) - require.Equal(t, "chrome_extensions", titles[1].Source) - require.Equal(t, "foo", titles[2].Name) - require.Equal(t, "deb_packages", titles[2].Source) - require.Equal(t, "bar", titles[3].Name) - require.Equal(t, "apps", titles[3].Source) - require.Equal(t, "baz", titles[4].Name) - require.Equal(t, "chrome_extensions", titles[4].Source) - require.Equal(t, "chrome", titles[4].Browser) - require.Equal(t, "baz", titles[5].Name) - require.Equal(t, "chrome_extensions", titles[5].Source) - require.Equal(t, "edge", titles[5].Browser) - require.Equal(t, "foo", titles[6].Name) - require.Equal(t, "rpm_packages", titles[6].Source) - require.Equal(t, "installer1", titles[7].Name) - require.Equal(t, "apps", titles[7].Source) - require.Equal(t, "installer2", titles[8].Name) - require.Equal(t, "apps", titles[8].Source) + require.Len(t, titles, 10) + i := 0 + require.Equal(t, "bar", titles[i].Name) + require.Equal(t, "deb_packages", titles[i].Source) + require.False(t, titles[i].AvailableForInstall) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + require.False(t, titles[i].AvailableForInstall) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "deb_packages", titles[i].Source) + require.False(t, titles[i].AvailableForInstall) + i++ + require.Equal(t, "bar", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + require.False(t, titles[i].AvailableForInstall) + i++ + require.Equal(t, "baz", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + require.Equal(t, "chrome", titles[i].Browser) + require.False(t, titles[i].AvailableForInstall) + i++ + require.Equal(t, "baz", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + require.Equal(t, "edge", titles[i].Browser) + require.False(t, titles[i].AvailableForInstall) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "rpm_packages", titles[i].Source) + require.False(t, titles[i].AvailableForInstall) + i++ + require.Equal(t, "installer1", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + require.True(t, titles[i].AvailableForInstall) + i++ + require.Equal(t, "installer2", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + require.True(t, titles[i].AvailableForInstall) + i++ + require.Equal(t, "vpp1", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + require.True(t, titles[i].AvailableForInstall) // primary sort is "hosts_count ASC", followed by "name ASC, source ASC, browser ASC" titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ @@ -353,27 +381,39 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { OrderDirection: fleet.OrderAscending, }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) - require.Len(t, titles, 9) - require.Equal(t, "installer1", titles[0].Name) - require.Equal(t, "apps", titles[0].Source) - require.Equal(t, "installer2", titles[1].Name) - require.Equal(t, "apps", titles[1].Source) - require.Equal(t, "bar", titles[2].Name) - require.Equal(t, "apps", titles[2].Source) - require.Equal(t, "baz", titles[3].Name) - require.Equal(t, "chrome_extensions", titles[3].Source) - require.Equal(t, "chrome", titles[3].Browser) - require.Equal(t, "baz", titles[4].Name) - require.Equal(t, "chrome_extensions", titles[4].Source) - require.Equal(t, "edge", titles[4].Browser) - require.Equal(t, "foo", titles[5].Name) - require.Equal(t, "rpm_packages", titles[5].Source) - require.Equal(t, "bar", titles[6].Name) - require.Equal(t, "deb_packages", titles[6].Source) - require.Equal(t, "foo", titles[7].Name) - require.Equal(t, "chrome_extensions", titles[7].Source) - require.Equal(t, "foo", titles[8].Name) - require.Equal(t, "deb_packages", titles[8].Source) + require.Len(t, titles, 10) + i = 0 + require.Equal(t, "installer1", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "installer2", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "vpp1", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "bar", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "baz", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + require.Equal(t, "chrome", titles[i].Browser) + i++ + require.Equal(t, "baz", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + require.Equal(t, "edge", titles[i].Browser) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "rpm_packages", titles[i].Source) + i++ + require.Equal(t, "bar", titles[i].Name) + require.Equal(t, "deb_packages", titles[i].Source) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "deb_packages", titles[i].Source) // primary sort is "name ASC", followed by "host_count DESC, source ASC, browser ASC" titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ @@ -381,27 +421,39 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { OrderDirection: fleet.OrderAscending, }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) - require.Len(t, titles, 9) - require.Equal(t, "bar", titles[0].Name) - require.Equal(t, "deb_packages", titles[0].Source) - require.Equal(t, "bar", titles[1].Name) - require.Equal(t, "apps", titles[1].Source) - require.Equal(t, "baz", titles[2].Name) - require.Equal(t, "chrome_extensions", titles[2].Source) - require.Equal(t, "chrome", titles[2].Browser) - require.Equal(t, "baz", titles[3].Name) - require.Equal(t, "chrome_extensions", titles[3].Source) - require.Equal(t, "edge", titles[3].Browser) - require.Equal(t, "foo", titles[4].Name) - require.Equal(t, "chrome_extensions", titles[4].Source) - require.Equal(t, "foo", titles[5].Name) - require.Equal(t, "deb_packages", titles[5].Source) - require.Equal(t, "foo", titles[6].Name) - require.Equal(t, "rpm_packages", titles[6].Source) - require.Equal(t, "installer1", titles[7].Name) - require.Equal(t, "apps", titles[7].Source) - require.Equal(t, "installer2", titles[8].Name) - require.Equal(t, "apps", titles[8].Source) + require.Len(t, titles, 10) + i = 0 + require.Equal(t, "bar", titles[i].Name) + require.Equal(t, "deb_packages", titles[i].Source) + i++ + require.Equal(t, "bar", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "baz", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + require.Equal(t, "chrome", titles[i].Browser) + i++ + require.Equal(t, "baz", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + require.Equal(t, "edge", titles[i].Browser) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "deb_packages", titles[i].Source) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "rpm_packages", titles[i].Source) + i++ + require.Equal(t, "installer1", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "installer2", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "vpp1", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) // primary sort is "name DESC", followed by "host_count DESC, source ASC, browser ASC" titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ @@ -409,27 +461,39 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { OrderDirection: fleet.OrderDescending, }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) - require.Len(t, titles, 9) - require.Equal(t, "installer2", titles[0].Name) - require.Equal(t, "apps", titles[0].Source) - require.Equal(t, "installer1", titles[1].Name) - require.Equal(t, "apps", titles[1].Source) - require.Equal(t, "foo", titles[2].Name) - require.Equal(t, "chrome_extensions", titles[2].Source) - require.Equal(t, "foo", titles[3].Name) - require.Equal(t, "deb_packages", titles[3].Source) - require.Equal(t, "foo", titles[4].Name) - require.Equal(t, "rpm_packages", titles[4].Source) - require.Equal(t, "baz", titles[5].Name) - require.Equal(t, "chrome_extensions", titles[5].Source) - require.Equal(t, "chrome", titles[5].Browser) - require.Equal(t, "baz", titles[6].Name) - require.Equal(t, "chrome_extensions", titles[6].Source) - require.Equal(t, "edge", titles[6].Browser) - require.Equal(t, "bar", titles[7].Name) - require.Equal(t, "deb_packages", titles[7].Source) - require.Equal(t, "bar", titles[8].Name) - require.Equal(t, "apps", titles[8].Source) + require.Len(t, titles, 10) + i = 0 + require.Equal(t, "vpp1", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "installer2", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "installer1", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "deb_packages", titles[i].Source) + i++ + require.Equal(t, "foo", titles[i].Name) + require.Equal(t, "rpm_packages", titles[i].Source) + i++ + require.Equal(t, "baz", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + require.Equal(t, "chrome", titles[i].Browser) + i++ + require.Equal(t, "baz", titles[i].Name) + require.Equal(t, "chrome_extensions", titles[i].Source) + require.Equal(t, "edge", titles[i].Browser) + i++ + require.Equal(t, "bar", titles[i].Name) + require.Equal(t, "deb_packages", titles[i].Source) + i++ + require.Equal(t, "bar", titles[i].Name) + require.Equal(t, "apps", titles[i].Source) // using a match query titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ @@ -541,6 +605,9 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { }) require.NoError(t, err) require.NotZero(t, installer2) + // create a VPP app for team2 + vpp2, _ := createVPPApp(t, ds, &team2.ID, "vpp2", "com.app.vpp2") + require.NotEmpty(t, vpp2) require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) @@ -564,11 +631,15 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "chrome_extensions", titles[1].Source) require.Equal(t, uint(1), titles[0].VersionsCount) assert.Equal(t, uint(1), titles[0].HostsCount) + require.False(t, titles[0].AvailableForInstall) require.Equal(t, uint(2), titles[1].VersionsCount) assert.Equal(t, uint(2), titles[1].HostsCount) + require.False(t, titles[1].AvailableForInstall) title, err := ds.SoftwareTitleByID(context.Background(), titles[0].ID, nil, globalTeamFilter) require.NoError(t, err) + require.Zero(t, title.SoftwareInstallersCount) + require.Zero(t, title.VPPAppsCount) // ListSoftwareTitles does not populate version host counts, so we do that manually titles[0].Versions[0].HostsCount = ptr.Uint(1) assert.Equal(t, titles[0], fleet.SoftwareTitleListResult{ID: title.ID, Name: title.Name, Source: title.Source, Browser: title.Browser, HostsCount: title.HostsCount, VersionsCount: title.VersionsCount, Versions: title.Versions, CountsUpdatedAt: title.CountsUpdatedAt}) @@ -580,6 +651,8 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { // Testing with team filter -- this team does contain this software title title, err = ds.SoftwareTitleByID(context.Background(), titles[1].ID, &team1.ID, globalTeamFilter) require.NoError(t, err) + require.Zero(t, title.SoftwareInstallersCount) + require.Zero(t, title.VPPAppsCount) assert.Equal(t, uint(1), title.HostsCount) assert.Equal(t, uint(1), title.VersionsCount) require.Len(t, title.Versions, 1) @@ -600,7 +673,9 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "installer1", titles[1].Name) require.Equal(t, "apps", titles[1].Source) require.Equal(t, uint(1), titles[0].VersionsCount) + require.False(t, titles[0].AvailableForInstall) require.Equal(t, uint(0), titles[1].VersionsCount) + require.True(t, titles[1].AvailableForInstall) // Testing with team filter -- this team does contain this software title title, err = ds.SoftwareTitleByID(context.Background(), titles[0].ID, &team1.ID, team1TeamFilter) @@ -614,19 +689,26 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { User: userTeam2Admin, IncludeObserver: true, }) - // installer2 is associated with team 2 + // installer2 and vpp2 is associated with team 2 require.NoError(t, err) - require.Len(t, titles, 3) - require.Equal(t, 3, count) + require.Len(t, titles, 4) + require.Equal(t, 4, count) require.Equal(t, "bar", titles[0].Name) require.Equal(t, "deb_packages", titles[0].Source) require.Equal(t, "foo", titles[1].Name) require.Equal(t, "chrome_extensions", titles[1].Source) require.Equal(t, "installer2", titles[2].Name) require.Equal(t, "apps", titles[2].Source) + require.Equal(t, "vpp2", titles[3].Name) + require.Equal(t, "apps", titles[3].Source) require.Equal(t, uint(1), titles[0].VersionsCount) require.Equal(t, uint(1), titles[1].VersionsCount) require.Equal(t, uint(0), titles[2].VersionsCount) + require.Equal(t, uint(0), titles[3].VersionsCount) + require.False(t, titles[0].AvailableForInstall) + require.False(t, titles[1].AvailableForInstall) + require.True(t, titles[2].AvailableForInstall) + require.True(t, titles[3].AvailableForInstall) // Testing the team 1 user with self-service only titles, _, _, err = ds.ListSoftwareTitles( @@ -638,6 +720,11 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "installer1", titles[0].Name) require.Equal(t, "apps", titles[0].Source) + title, err = ds.SoftwareTitleByID(context.Background(), titles[0].ID, &team1.ID, team1TeamFilter) + require.NoError(t, err) + require.Equal(t, 1, title.SoftwareInstallersCount) + require.Zero(t, title.VPPAppsCount) + // Testing the team 2 user with self-service only titles, _, _, err = ds.ListSoftwareTitles(context.Background(), fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{}, SelfServiceOnly: true, TeamID: &team2.ID}, fleet.TeamFilter{ User: userTeam2Admin, @@ -671,29 +758,32 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { }) require.NoError(t, err) require.NotZero(t, installer2) + // create a VPP app not installed on a host + vpp1, _ := createVPPApp(t, ds, nil, "vpp1", "com.app.vpp1") + require.NotEmpty(t, vpp1) titles, counts, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ OrderKey: "name", OrderDirection: fleet.OrderAscending, }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) - require.EqualValues(t, 2, counts) - require.Len(t, titles, 2) + require.EqualValues(t, 3, counts) + require.Len(t, titles, 3) require.Equal(t, "installer1", titles[0].Name) require.Equal(t, "apps", titles[0].Source) require.Equal(t, "installer2", titles[1].Name) require.Equal(t, "apps", titles[1].Source) + require.Equal(t, "vpp1", titles[2].Name) + require.Equal(t, "apps", titles[2].Source) require.True(t, titles[0].CountsUpdatedAt.IsZero()) require.True(t, titles[1].CountsUpdatedAt.IsZero()) - require.NotNil(t, titles[0].SoftwarePackage) - require.Equal(t, "installer1.pkg", *titles[0].SoftwarePackage) - require.NotNil(t, titles[1].SoftwarePackage) - require.Equal(t, "installer2.pkg", *titles[1].SoftwarePackage) + require.True(t, titles[2].CountsUpdatedAt.IsZero()) require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + // match installer1 name titles, counts, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ OrderKey: "name", OrderDirection: fleet.OrderAscending, @@ -729,14 +819,15 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, ) require.NoError(t, err) - require.EqualValues(t, 2, counts) - require.Len(t, titles, 2) + require.EqualValues(t, 3, counts) + require.Len(t, titles, 3) require.True(t, titles[0].CountsUpdatedAt.IsZero()) } func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore) { ctx := context.Background() + // create a couple software installers installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer1", Source: "apps", @@ -754,6 +845,12 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore require.NoError(t, err) require.NotZero(t, installer2) + // create a couple VPP apps + vpp1, _ := createVPPApp(t, ds, nil, "vpp1", "com.example.vpp1") + require.NotEmpty(t, vpp1) + vpp2, _ := createVPPApp(t, ds, nil, "vpp2", "com.example.vpp2") + require.NotEmpty(t, vpp2) + host := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now()) software := []fleet.Software{ {Name: "foo", Version: "0.0.1", Source: "chrome_extensions"}, @@ -778,8 +875,13 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, ) require.NoError(t, err) - require.EqualValues(t, 4, counts) - require.Len(t, titles, 4) + require.EqualValues(t, 6, counts) + require.Len(t, titles, 6) + names := make([]string, 0, len(titles)) + for _, title := range titles { + names = append(names, title.Name) + } + require.ElementsMatch(t, []string{"bar", "foo", "installer1", "installer2", "vpp1", "vpp2"}, names) // with filter returns only available for install titles, counts, _, err = ds.ListSoftwareTitles( @@ -794,8 +896,53 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, ) require.NoError(t, err) - require.EqualValues(t, 2, counts) - require.Len(t, titles, 2) + require.EqualValues(t, 4, counts) + require.Len(t, titles, 4) + + names = make([]string, 0, len(titles)) + for _, title := range titles { + names = append(names, title.Name) + } + require.ElementsMatch(t, []string{"installer1", "installer2", "vpp1", "vpp2"}, names) +} + +// creates the entry in vpp_apps and vpp_apps_teams, linking to a software +// title, creating it if necessary. The source column is always "apps". Returns +// the adam_id (auto-generated) and the software title id. +// TODO: temporary, until datastore methods are available for VPP apps. +func createVPPApp(t *testing.T, ds *Datastore, teamID *uint, name, bundle string) (string, uint) { + ctx := context.Background() + + rawBytes := make([]byte, 10) + _, err := io.ReadFull(rand.Reader, rawBytes) + require.NoError(t, err) + adamID := base64.RawStdEncoding.EncodeToString(rawBytes) + + titleID, err := ds.getOrGenerateSoftwareInstallerTitleID(ctx, &fleet.UploadSoftwareInstallerPayload{Title: name, Source: "apps", BundleIdentifier: bundle}) + require.NoError(t, err) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `INSERT INTO vpp_apps (adam_id, available_count, title_id, name, bundle_identifier) VALUES (?, ?, ?, ?, ?)`, + adamID, 1, titleID, name, bundle) + return err + }) + + createVPPAppTeamOnly(t, ds, teamID, adamID) + return adamID, titleID +} + +func createVPPAppTeamOnly(t *testing.T, ds *Datastore, teamID *uint, adamID string) { + ctx := context.Background() + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + var globalOrTeamID uint + if teamID != nil { + globalOrTeamID = *teamID + } + _, err := q.ExecContext(ctx, `INSERT INTO vpp_apps_teams (adam_id, team_id, global_or_team_id) VALUES (?, ?, ?)`, + adamID, teamID, globalOrTeamID) + return err + }) } func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index da874c7be0..7f6bad7949 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "database/sql" "fmt" "strings" @@ -10,6 +11,102 @@ import ( "github.com/jmoiron/sqlx" ) +func (ds *Datastore) GetVPPAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPAppStoreApp, error) { + const query = ` +SELECT + vap.adam_id, + vap.name, + vap.latest_version, + NULLIF(vap.icon_url, '') AS icon_url +FROM + vpp_apps vap + INNER JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id +WHERE + vap.title_id = ? AND + vat.global_or_team_id = ?` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + var app fleet.VPPAppStoreApp + err := sqlx.GetContext(ctx, ds.reader(ctx), &app, query, titleID, tmID) + if err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("VPPApp"), "get VPP app metadata") + } + return nil, ctxerr.Wrap(ctx, err, "get VPP app metadata") + } + + return &app, nil +} + +func (ds *Datastore) GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *uint, adamID string) (*fleet.VPPAppStatusSummary, error) { + var dest fleet.VPPAppStatusSummary + + const stmt = ` +SELECT + COALESCE(SUM( IF(status = :software_status_pending, 1, 0)), 0) AS pending, + COALESCE(SUM( IF(status = :software_status_failed, 1, 0)), 0) AS failed, + COALESCE(SUM( IF(status = :software_status_installed, 1, 0)), 0) AS installed +FROM ( +SELECT + CASE + WHEN ncr.status = :mdm_status_acknowledged THEN + :software_status_installed + WHEN ncr.status = :mdm_status_error OR ncr.status = :mdm_status_format_error THEN + :software_status_failed + ELSE + :software_status_pending + END as status +FROM + host_vpp_software_installs hvsi +INNER JOIN + hosts h ON hvsi.host_id = h.id +LEFT OUTER JOIN + nano_command_results ncr ON ncr.id = h.uuid AND ncr.command_uuid = hvsi.command_uuid +WHERE + hvsi.adam_id = :adam_id AND + (h.team_id = :team_id OR (h.team_id IS NULL AND :team_id = 0)) AND + hvsi.id IN ( + SELECT + max(hvsi2.id) -- ensure we use only the most recently created install attempt for each host + FROM + host_vpp_software_installs hvsi2 + WHERE + hvsi2.adam_id = :adam_id + GROUP BY + hvsi2.host_id + ) +) s` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + query, args, err := sqlx.Named(stmt, map[string]interface{}{ + "adam_id": adamID, + "team_id": tmID, + "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, + "mdm_status_error": fleet.MDMAppleStatusError, + "mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError, + "software_status_pending": fleet.SoftwareInstallerPending, + "software_status_failed": fleet.SoftwareInstallerFailed, + "software_status_installed": fleet.SoftwareInstallerInstalled, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get summary host vpp installs: named query") + } + + err = sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, args...) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get summary host vpp install status") + } + return &dest, nil +} + func (ds *Datastore) BatchInsertVPPApps(ctx context.Context, apps []*fleet.VPPApp) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { if err := insertVPPApps(ctx, tx, apps); err != nil { @@ -43,9 +140,9 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp func (ds *Datastore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[string]struct{}, error) { stmt := ` -SELECT +SELECT adam_id -FROM +FROM vpp_apps_teams vat WHERE vat.global_or_team_id = ? diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index da21c05f17..75ac98f134 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -16,6 +18,8 @@ func TestVPP(t *testing.T) { name string fn func(t *testing.T, ds *Datastore) }{ + {"VPPAppMetadata", testVPPAppMetadata}, + {"VPPAppStatus", testVPPAppStatus}, {"VPPApps", testVPPApps}, } @@ -27,6 +31,238 @@ func TestVPP(t *testing.T) { } } +func testVPPAppMetadata(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create teams + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) + require.NoError(t, err) + require.NotNil(t, team1) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) + require.NoError(t, err) + require.NotNil(t, team2) + + // get for non-existing title + meta, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, 1) + require.Error(t, err) + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) + require.Nil(t, meta) + + // create no-team app + vpp1, titleID1 := createVPPApp(t, ds, nil, "vpp1", "com.app.vpp1") + + // get no-team app + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, titleID1) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp1", AppStoreID: vpp1}, meta) + + // create team1 app + vpp2, titleID2 := createVPPApp(t, ds, &team1.ID, "vpp2", "com.app.vpp2") + + // get it for team 1 + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, titleID2) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", AppStoreID: vpp2}, meta) + + // get it for team 2, does not exist + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team2.ID, titleID2) + require.Error(t, err) + require.ErrorAs(t, err, &nfe) + require.Nil(t, meta) + + // create the same app for team2 + createVPPAppTeamOnly(t, ds, &team2.ID, vpp2) + + // get it for team 1 and team 2, both work + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, titleID2) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", AppStoreID: vpp2}, meta) + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team2.ID, titleID2) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", AppStoreID: vpp2}, meta) + + // create another no-team app + vpp3, titleID3 := createVPPApp(t, ds, nil, "vpp3", "com.app.vpp3") + + // get it for team 2, does not exist + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team2.ID, titleID3) + require.Error(t, err) + require.ErrorAs(t, err, &nfe) + require.Nil(t, meta) + + // get it for no-team + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, titleID3) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp3", AppStoreID: vpp3}, meta) + + // delete the software title + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "DELETE FROM software_titles WHERE id = ?", titleID3) + return err + }) + + // cannot be returned anymore (deleting the title breaks the relationship) + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, titleID3) + require.Error(t, err) + require.ErrorAs(t, err, &nfe) + require.Nil(t, meta) +} + +func testVPPAppStatus(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create a team + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) + require.NoError(t, err) + require.NotNil(t, team1) + + // create some apps, one for no-team, one for team1, and one in both + vpp1, _ := createVPPApp(t, ds, nil, "vpp1", "com.app.vpp1") + vpp2, _ := createVPPApp(t, ds, &team1.ID, "vpp2", "com.app.vpp2") + vpp3, _ := createVPPApp(t, ds, nil, "vpp3", "com.app.vpp3") + createVPPAppTeamOnly(t, ds, &team1.ID, vpp3) + + // for now they all return zeroes + summary, err := ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 0}, summary) + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, &team1.ID, vpp2) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 0}, summary) + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp3) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 0}, summary) + + // create a few enrolled hosts + h1, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-test-1", + OsqueryHostID: ptr.String("osquery-macos-1"), + NodeKey: ptr.String("node-key-macos-1"), + UUID: uuid.NewString(), + Platform: "darwin", + HardwareSerial: "654321a", + }) + require.NoError(t, err) + nanoEnroll(t, ds, h1, false) + + h2, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-test-2", + OsqueryHostID: ptr.String("osquery-macos-2"), + NodeKey: ptr.String("node-key-macos-2"), + UUID: uuid.NewString(), + Platform: "darwin", + HardwareSerial: "654321b", + }) + require.NoError(t, err) + nanoEnroll(t, ds, h2, false) + + h3, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-test-3", + OsqueryHostID: ptr.String("osquery-macos-3"), + NodeKey: ptr.String("node-key-macos-3"), + UUID: uuid.NewString(), + Platform: "darwin", + HardwareSerial: "654321c", + }) + require.NoError(t, err) + nanoEnroll(t, ds, h3, false) + + // move h3 to team1 + err = ds.AddHostsToTeam(ctx, &team1.ID, []uint{h3.ID}) + require.NoError(t, err) + + // simulate an install request of vpp1 on h1 + cmd1 := createVPPAppInstallRequest(t, ds, h1, vpp1) + + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 1, Failed: 0, Installed: 0}, summary) + + // record a failed result + createVPPAppInstallResult(t, ds, h1, cmd1, fleet.MDMAppleStatusError) + + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 1, Installed: 0}, summary) + + // create a new request for h1 that supercedes the failed on, and a request + // for h2 with a successful result. + cmd2 := createVPPAppInstallRequest(t, ds, h1, vpp1) + cmd3 := createVPPAppInstallRequest(t, ds, h2, vpp1) + createVPPAppInstallResult(t, ds, h2, cmd3, fleet.MDMAppleStatusAcknowledged) + + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 1, Failed: 0, Installed: 1}, summary) + + // mark the pending request as successful too + createVPPAppInstallResult(t, ds, h1, cmd2, fleet.MDMAppleStatusAcknowledged) + + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 2}, summary) + + // requesting for a team (the VPP app is not on any team) returns all zeroes + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, &team1.ID, vpp1) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 0}, summary) + + // simulate a successful request for team app vpp2 on h3 + cmd4 := createVPPAppInstallRequest(t, ds, h3, vpp2) + createVPPAppInstallResult(t, ds, h3, cmd4, fleet.MDMAppleStatusAcknowledged) + + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, &team1.ID, vpp2) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 1}, summary) + + // simulate a successful, failed and pending request for app vpp3 on team + // (h3) and no team (h1, h2) + cmd5 := createVPPAppInstallRequest(t, ds, h3, vpp3) + createVPPAppInstallResult(t, ds, h3, cmd5, fleet.MDMAppleStatusAcknowledged) + cmd6 := createVPPAppInstallRequest(t, ds, h1, vpp3) + createVPPAppInstallResult(t, ds, h1, cmd6, fleet.MDMAppleStatusCommandFormatError) + createVPPAppInstallRequest(t, ds, h2, vpp3) + + // for no team, it sees the failed and pending counts + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp3) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 1, Failed: 1, Installed: 0}, summary) + + // for the team, it sees the successful count + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, &team1.ID, vpp3) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 1}, summary) +} + +// simulates creating the VPP app install request on the host, returns the command UUID. +func createVPPAppInstallRequest(t *testing.T, ds *Datastore, host *fleet.Host, adamID string) string { + ctx := context.Background() + + cmdUUID := uuid.NewString() + appleCmd := createRawAppleCmd("ProfileList", cmdUUID) + commander, _ := createMDMAppleCommanderAndStorage(t, ds) + err := commander.EnqueueCommand(ctx, []string{host.UUID}, appleCmd) + require.NoError(t, err) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid) VALUES (?, ?, ?)`, + host.ID, adamID, cmdUUID) + return err + }) + return cmdUUID +} + +func createVPPAppInstallResult(t *testing.T, ds *Datastore, host *fleet.Host, cmdUUID string, status string) { + ctx := context.Background() + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `INSERT INTO nano_command_results (id, command_uuid, status, result) VALUES (?, ?, ?, ' 0 { + meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") } - meta.Status = summary + if meta != nil { + summary, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, meta.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installer status summary") + } + meta.Status = summary + } + software.SoftwarePackage = meta + } + + // add VPP app data if needed + if software.VPPAppsCount > 0 { + meta, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, id) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get VPP app metadata") + } + if meta != nil { + summary, err := svc.ds.GetSummaryHostVPPAppInstalls(ctx, teamID, meta.AppStoreID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get VPP app status summary") + } + meta.Status = summary + } + software.AppStoreApp = meta } - software.SoftwarePackage = meta } return software, nil From 5d2e40bc8bbf6ee3b9f18d3fb1d3fb5bf3cf0007 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Tue, 16 Jul 2024 10:51:08 -0400 Subject: [PATCH 10/38] feat: backend for VPP related global activities (#20484) > Related issue: #19870 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/19870-vpp-activities-backend | 1 + docs/Using Fleet/Audit-logs.md | 79 +++++++++++++- ee/server/service/vpp.go | 11 ++ server/fleet/activities.go | 101 +++++++++++++++++- server/service/integration_enterprise_test.go | 4 + server/service/mdm.go | 16 ++- server/service/mdm_test.go | 4 + 7 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 changes/19870-vpp-activities-backend diff --git a/changes/19870-vpp-activities-backend b/changes/19870-vpp-activities-backend new file mode 100644 index 0000000000..115f92e1fd --- /dev/null +++ b/changes/19870-vpp-activities-backend @@ -0,0 +1 @@ +- Adds global activity support for VPP related activities. \ No newline at end of file diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index ab16e6970d..cbffea6455 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -1158,7 +1158,7 @@ Generated when a software installer is deleted from Fleet. This activity contains the following fields: - "software_title": Name of the software. - "software_package": Filename of the installer. -- "team_name": Name of the team to which this software was added. `null if it was added to no team. +- "team_name": Name of the team to which this software was added. `null` if it was added to no team. - "team_id": The ID of the team to which this software was added. `null` if it was added to no team. - "self_service": Whether the software was available for installation by the end user. @@ -1174,6 +1174,83 @@ This activity contains the following fields: } ``` +## vpp_enabled + +Generated when the VPP feature is enabled in Fleet. + + + +## vpp_disabled + +Generated when the VPP feature is disabled in Fleet. + + + +## added_app_store_app + +Generated when an App Store app is added to Fleet. + +This activity contains the following fields: +- "software_title": Name of the App Store app. +- "app_store_id": ID of the app on the Apple App Store. +- "team_name": Name of the team to which this App Store app was added, or `null` if it was added to no team. +- "team_id": ID of the team to which this App Store app was added, or `null`if it was added to no team. + +#### Example + +```json +{ + "software_title": "Logic Pro", + "app_store_id": "1234567", + "team_name": "Workstations", + "team_id": 1 +} +``` + +## deleted_app_store_app + +Generated when an App Store app is deleted from Fleet. + +This activity contains the following fields: +- "software_title": Name of the App Store app. +- "app_store_id": ID of the app on the Apple App Store. +- "team_name": Name of the team from which this App Store app was deleted, or `null` if it was deleted from no team. +- "team_id": ID of the team from which this App Store app was deleted, or `null`if it was deleted from no team. + +#### Example + +```json +{ + "software_title": "Logic Pro", + "app_store_id": "1234567", + "team_name": "Workstations", + "team_id": 1 +} +``` + +## installed_app_store_app + +Generated when an App Store app is installed on a device. + +This activity contains the following fields: +- host_id: ID of the host on which the app was installed. +- host_display_name: Display name of the host. +- software_title: Name of the App Store app. +- app_store_id: ID of the app on the Apple App Store. +- command_uuid: UUID of the MDM command used to install the app. + +#### Example + +```json +{ + "host_id": 42, + "host_display_name": "Anna's MacBook Pro", + "software_title": "Logic Pro", + "app_store_id": "1234567", + "command_uuid": "98765432-1234-1234-1234-1234567890ab" +} +``` + diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index ef2bb4a130..eb3ba1ad1b 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" + "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/apple/itunes" @@ -159,5 +160,15 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, adamID str return ctxerr.Wrap(ctx, err, "writing VPP app to db") } + act := fleet.ActivityAddedAppStoreApp{ + AppStoreID: app.AdamID, + TeamName: &teamName, + SoftwareTitle: app.Name, + TeamID: teamID, + } + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for add app store app") + } + return nil } diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 56d312faac..849a78953b 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -99,6 +99,11 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeInstalledSoftware{}, ActivityTypeAddedSoftware{}, ActivityTypeDeletedSoftware{}, + ActivityEnabledVPP{}, + ActivityDisabledVPP{}, + ActivityAddedAppStoreApp{}, + ActivityDeletedAppStoreApp{}, + ActivityInstalledAppStoreApp{}, } type ActivityDetails interface { @@ -1507,7 +1512,7 @@ func (a ActivityTypeDeletedSoftware) Documentation() (string, string, string) { return `Generated when a software installer is deleted from Fleet.`, `This activity contains the following fields: - "software_title": Name of the software. - "software_package": Filename of the installer. -- "team_name": Name of the team to which this software was added.` + " `null " + `if it was added to no team. +- "team_name": Name of the team to which this software was added.` + " `null` " + `if it was added to no team. - "team_id": The ID of the team to which this software was added.` + " `null` " + `if it was added to no team. - "self_service": Whether the software was available for installation by the end user.`, `{ "software_title": "Falcon.app", @@ -1598,3 +1603,97 @@ func LogRoleChangeActivities( } return nil } + +type ActivityEnabledVPP struct{} + +func (a ActivityEnabledVPP) ActivityName() string { + return "vpp_enabled" +} + +func (a ActivityEnabledVPP) Documentation() (activity string, details string, detailsExample string) { + return "Generated when the VPP feature is enabled in Fleet.", "", "" +} + +type ActivityDisabledVPP struct{} + +func (a ActivityDisabledVPP) ActivityName() string { + return "vpp_disabled" +} + +func (a ActivityDisabledVPP) Documentation() (activity string, details string, detailsExample string) { + return "Generated when the VPP feature is disabled in Fleet.", "", "" +} + +type ActivityAddedAppStoreApp struct { + SoftwareTitle string `json:"software_title"` + AppStoreID string `json:"app_store_id"` + TeamName *string `json:"team_name"` + TeamID *uint `json:"team_id"` +} + +func (a ActivityAddedAppStoreApp) ActivityName() string { + return "added_app_store_app" +} + +func (a ActivityAddedAppStoreApp) Documentation() (activity string, details string, detailsExample string) { + return "Generated when an App Store app is added to Fleet.", `This activity contains the following fields: +- "software_title": Name of the App Store app. +- "app_store_id": ID of the app on the Apple App Store. +- "team_name": Name of the team to which this App Store app was added, or ` + "`null`" + ` if it was added to no team. +- "team_id": ID of the team to which this App Store app was added, or ` + "`null`" + `if it was added to no team.`, `{ + "software_title": "Logic Pro", + "app_store_id": "1234567", + "team_name": "Workstations", + "team_id": 1 +}` +} + +type ActivityDeletedAppStoreApp struct { + SoftwareTitle string `json:"software_title"` + AppStoreID string `json:"app_store_id"` + TeamName string `json:"team_name"` +} + +func (a ActivityDeletedAppStoreApp) ActivityName() string { + return "deleted_app_store_app" +} + +func (a ActivityDeletedAppStoreApp) Documentation() (activity string, details string, detailsExample string) { + return "Generated when an App Store app is deleted from Fleet.", `This activity contains the following fields: +- "software_title": Name of the App Store app. +- "app_store_id": ID of the app on the Apple App Store. +- "team_name": Name of the team from which this App Store app was deleted, or ` + "`null`" + ` if it was deleted from no team. +- "team_id": ID of the team from which this App Store app was deleted, or ` + "`null`" + `if it was deleted from no team.`, `{ + "software_title": "Logic Pro", + "app_store_id": "1234567", + "team_name": "Workstations", + "team_id": 1 +}` +} + +type ActivityInstalledAppStoreApp struct { + HostID int `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + SoftwareTitle string `json:"software_title"` + AppStoreID int `json:"app_store_id"` + CommandUUID string `json:"command_uuid"` +} + +func (a ActivityInstalledAppStoreApp) ActivityName() string { + return "installed_app_store_app" +} + +func (a ActivityInstalledAppStoreApp) Documentation() (string, string, string) { + return "Generated when an App Store app is installed on a device.", `This activity contains the following fields: +- host_id: ID of the host on which the app was installed. +- host_display_name: Display name of the host. +- software_title: Name of the App Store app. +- app_store_id: ID of the app on the Apple App Store. +- command_uuid: UUID of the MDM command used to install the app.`, `{ + "host_id": 42, + "host_display_name": "Anna's MacBook Pro", + "software_title": "Logic Pro", + "app_store_id": "1234567", + "command_uuid": "98765432-1234-1234-1234-1234567890ab" +}` +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 60def3cf75..a25be64736 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -10702,6 +10702,8 @@ func (s *integrationMDMTestSuite) TestVPPApps() { t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL) s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") + s.lastActivityMatches(fleet.ActivityEnabledVPP{}.ActivityName(), "", 0) + // Get the token var resp getMDMAppleVPPTokenResponse s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusOK, &resp) @@ -10755,6 +10757,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { // Add an app store app to team 1 var addAppResp addAppStoreAppResponse s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: appResp.AppStoreApps[0].AdamID}, http.StatusOK, &addAppResp) + s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, appResp.AppStoreApps[0].Name, appResp.AppStoreApps[0].AdamID, team.ID), 0) // Add an app store app to non-existent team s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: ptr.Uint(9999), AppStoreID: appResp.AppStoreApps[0].AdamID}, http.StatusNotFound, &addAppResp) @@ -10777,6 +10780,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { // Delete VPP token 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) + s.lastActivityMatches(fleet.ActivityDisabledVPP{}.ActivityName(), "", 0) } // 1. software title uploaded doesn't match existing title diff --git a/server/service/mdm.go b/server/service/mdm.go index f9d21b8fa9..545df4e6d5 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2646,6 +2646,11 @@ func (svc *Service) UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSee return ctxerr.Wrap(ctx, err, "writing VPP token to db") } + act := fleet.ActivityEnabledVPP{} + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for upload VPP token") + } + return nil } @@ -2729,5 +2734,14 @@ func (svc *Service) DeleteMDMAppleVPPToken(ctx context.Context) error { return err } - return svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken}) + if err := svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken}); err != nil { + return ctxerr.Wrap(ctx, err, "delete VPP token") + } + + act := fleet.ActivityDisabledVPP{} + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for delete VPP token") + } + + return nil } diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 41941020e9..92e3b3db31 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -119,6 +119,10 @@ func TestMDMAppleAuthorization(t *testing.T) { return nil } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error { + return nil + } + ds.DeleteMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) error { return nil } // use a custom implementation of checkAuthErr as the service call will fail From 5ea213e8756d49455e79e3df7429680ce17f772b Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Tue, 16 Jul 2024 13:16:00 -0300 Subject: [PATCH 11/38] improve VPP API error handling (#20446) reading the [docs][1] I realized we're missing some recommendations for error management. the docs also note that certain operations like assignments happen asynchronously and you must subscribe to events to get those errors. this part wasn't estimated nor considered. [1]: https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests --- server/mdm/apple/vpp/api.go | 37 ++++++++++++++++++- server/mdm/apple/vpp/api_test.go | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/server/mdm/apple/vpp/api.go b/server/mdm/apple/vpp/api.go index de4dbe4fe6..80d48808c6 100644 --- a/server/mdm/apple/vpp/api.go +++ b/server/mdm/apple/vpp/api.go @@ -12,6 +12,7 @@ import ( "time" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/pkg/retry" ) // Asset is a product in the store. @@ -196,11 +197,45 @@ func do[T any](req *http.Request, token string, dest *T) error { return fmt.Errorf("reading response body from Apple VPP endpoint: %w", err) } + // For HTTP 5xx server error responses, a Retry-After header indicates + // how long the client must wait before making additional requests. + // + // https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3742679 + retryAfter := resp.Header.Get("Retry-After") + if resp.StatusCode == http.StatusInternalServerError && retryAfter != "" { + seconds, err := strconv.ParseInt(retryAfter, 10, 0) + if err != nil { + return fmt.Errorf("parsing retry-after header: %w", err) + } + + ticker := time.NewTicker(time.Duration(seconds) * time.Second) + defer ticker.Stop() + <-ticker.C + return do(req, token, dest) + } + // 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 errResp ErrorResponse if err := json.Unmarshal(body, &errResp); err == nil && (errResp.ErrorMessage != "" || errResp.ErrorNumber != 0) { - return &errResp + switch errResp.ErrorNumber { + // 9646: There are too many requests for the current + // Organization and the request has been rejected, either due + // to high server volume or an MDM issue. Use an + // incremental/exponential backoff strategy to retry the + // request until successful. + // + // https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3783126 + case 9646: + return retry.Do( + func() error { return do(req, token, dest) }, + retry.WithBackoffMultiplier(3), + retry.WithInterval(5*time.Second), + retry.WithMaxAttempts(3), + ) + default: + return &errResp + } } if resp.StatusCode != http.StatusOK { diff --git a/server/mdm/apple/vpp/api_test.go b/server/mdm/apple/vpp/api_test.go index 5421826dfd..c3fda13c3d 100644 --- a/server/mdm/apple/vpp/api_test.go +++ b/server/mdm/apple/vpp/api_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -230,6 +231,68 @@ func TestGetAssets(t *testing.T) { } } +func TestDoRetryAfter(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantCalls int + wantErr bool + }{ + { + name: "no retry-after header", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }, + wantCalls: 1, + wantErr: true, + }, + { + name: "invalid retry-after header", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Retry-After", "foo") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }, + wantCalls: 1, + wantErr: true, + }, + { + name: "three retries", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Retry-After", "1") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }, + wantCalls: 3, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls int + setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls < tt.wantCalls { + tt.handler(w, r) + return + } + }) + + start := time.Now() + req, err := http.NewRequest(http.MethodGet, os.Getenv("FLEET_DEV_VPP_URL"), nil) + require.NoError(t, err) + err = do[any](req, "test-token", nil) + require.NoError(t, err) + require.Equal(t, tt.wantCalls, calls) + require.WithinRange(t, time.Now(), start, start.Add(time.Duration(tt.wantCalls)*time.Second)) + }) + } +} + func TestGetBaseURL(t *testing.T) { t.Run("Default URL", func(t *testing.T) { os.Setenv("FLEET_DEV_VPP_URL", "") From 60ced95c8a0c39a08cb3a371a1632481a9444860 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:16:57 -0500 Subject: [PATCH 12/38] Add UI for VPP activities (#20493) --- .../AppInstallDetails/AppInstallDetails.tsx | 154 ++++++++++++++++++ .../AppInstallDetails/_styles.scss | 22 +++ .../InstallDetails/AppInstallDetails/index.ts | 1 + .../SoftwareInstallDetails.tsx | 35 ++-- .../SoftwareInstallDetails/_styles.scss | 0 .../SoftwareInstallDetails/index.ts | 0 .../InstallDetails/constants.ts | 39 +++++ frontend/interfaces/activity.ts | 13 +- frontend/interfaces/mdm.ts | 19 +++ frontend/interfaces/software.ts | 24 +++ .../cards/ActivityFeed/ActivityFeed.tsx | 18 +- .../ActivityItem/ActivityItem.tsx | 77 ++++++++- .../HostDetailsPage/HostDetailsPage.tsx | 24 ++- .../hosts/details/cards/Activity/Activity.tsx | 1 - .../details/cards/Activity/ActivityConfig.tsx | 2 + .../InstalledSoftwareActivityItem.tsx | 24 +-- .../SoftwareDetailsModal.tsx | 2 +- frontend/services/entities/mdm.ts | 22 +++ frontend/utilities/endpoints.ts | 2 + 19 files changed, 419 insertions(+), 60 deletions(-) create mode 100644 frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx create mode 100644 frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/_styles.scss create mode 100644 frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/index.ts rename frontend/{pages/SoftwarePage/components => components/ActivityDetails/InstallDetails}/SoftwareInstallDetails/SoftwareInstallDetails.tsx (78%) rename frontend/{pages/SoftwarePage/components => components/ActivityDetails/InstallDetails}/SoftwareInstallDetails/_styles.scss (100%) rename frontend/{pages/SoftwarePage/components => components/ActivityDetails/InstallDetails}/SoftwareInstallDetails/index.ts (100%) create mode 100644 frontend/components/ActivityDetails/InstallDetails/constants.ts diff --git a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx new file mode 100644 index 0000000000..25a10d979d --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/AppInstallDetails.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { useQuery } from "react-query"; + +import { + SoftwareInstallStatus, + getInstallStatusPredicate, +} from "interfaces/software"; +import mdmApi from "services/entities/mdm"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import Textarea from "components/Textarea"; +import DataError from "components/DataError/DataError"; +import Spinner from "components/Spinner/Spinner"; +import { IMdmCommandResult } from "interfaces/mdm"; +import { IActivityDetails } from "interfaces/activity"; + +import { IconNames } from "components/icons"; +import { + getInstallDetailsStatusPredicate, + INSTALL_DETAILS_STATUS_ICONS, +} from "../constants"; + +const baseClass = "app-install-details"; + +export type IAppInstallDetails = Pick< + IActivityDetails, + | "host_id" + | "command_uuid" + | "host_display_name" + | "software_title" + | "app_store_id" + | "status" +>; + +export const AppInstallDetails = ({ + status, + command_uuid = "", + host_display_name = "", + software_title = "", +}: IAppInstallDetails) => { + const { data: result, isLoading, isError } = useQuery< + IMdmCommandResult, + Error + >( + ["mdm_command_results", command_uuid], + async () => { + return mdmApi.getCommandResults(command_uuid).then((response) => { + const results = response.results?.[0]; + if (!results) { + return Promise.reject(new Error("No data returned")); + } + return { + ...results, + payload: atob(results.payload), + result: atob(results.result), + }; + }); + }, + { + refetchOnWindowFocus: false, + staleTime: 3000, + } + ); + + if (isLoading) { + return ; + } else if (isError) { + return ; + } else if (!result) { + // FIXME: Find a better solution for this. + return ; + } + + // Note: We need to reconcile status values from two different sources. From props, we + // get the status from the activity item details (which can be "failed", "pending", or + // "installed"). From the command results API response, we also receive the raw status + // from the MDM protocol, e.g., "NotNow" or "Acknowledged". We need to display some special + // messaging for the "NotNow" status, which otherwise would be treated as "pending". + const isStatusNotNow = result.status === "NotNow"; + let iconName: IconNames; + let predicate: string; + let subordinate: string; + if (isStatusNotNow) { + iconName = INSTALL_DETAILS_STATUS_ICONS.pending; + predicate = "tried to install"; + subordinate = + " but couldn’t because the host was locked or was running on battery power while in Power Nap. Fleet will try again"; + } else { + iconName = INSTALL_DETAILS_STATUS_ICONS[status as SoftwareInstallStatus]; + predicate = getInstallDetailsStatusPredicate(status); + subordinate = status === "pending" ? " when it comes online" : ""; + } + + const showCommandResponse = isStatusNotNow || status !== "pending"; + + return ( + <> +
+
+ {!!iconName && } + + Fleet {predicate} {software_title} on{" "} + {host_display_name} + {subordinate}. + +
+
+ Request payload: + +
+ {showCommandResponse && ( +
+ The response from {host_display_name}: + +
+ )} +
+ + ); +}; + +export const AppInstallDetailsModal = ({ + details, + onCancel, +}: { + details: IAppInstallDetails; + onCancel: () => void; +}) => { + return ( + + <> +
+ +
+
+ +
+ +
+ ); +}; diff --git a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/_styles.scss new file mode 100644 index 0000000000..3d2f7d57d4 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/_styles.scss @@ -0,0 +1,22 @@ +.app-install-details { + .modal__content { + margin-top: $pad-xlarge; + } + &__status-message { + display: flex; + align-items: center; + gap: $pad-small; + margin: 0; + .icon { + padding-top: 3px; + align-self: flex-start; + } + } + &__script-output { + padding-top: $pad-xlarge; + .textarea { + margin-top: $pad-medium; + overflow-wrap: break-word; + } + } +} diff --git a/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/index.ts b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/index.ts new file mode 100644 index 0000000000..509d370bb9 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/AppInstallDetails/index.ts @@ -0,0 +1 @@ +export { AppInstallDetails, AppInstallDetailsModal } from "./AppInstallDetails"; diff --git a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/SoftwareInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx similarity index 78% rename from frontend/pages/SoftwarePage/components/SoftwareInstallDetails/SoftwareInstallDetails.tsx rename to frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx index ca735dcf5a..27480cd8e5 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/SoftwareInstallDetails.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx @@ -4,7 +4,6 @@ import { useQuery } from "react-query"; import { ISoftwareInstallResult, ISoftwareInstallResults, - SoftwareInstallStatus, } from "interfaces/software"; import softwareAPI from "services/entities/software"; @@ -14,22 +13,14 @@ import Icon from "components/Icon"; import Textarea from "components/Textarea"; import DataError from "components/DataError/DataError"; import Spinner from "components/Spinner/Spinner"; -import { IconNames } from "components/icons"; +import { + INSTALL_DETAILS_STATUS_ICONS, + SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS, + getInstallDetailsStatusPredicate, +} from "../constants"; const baseClass = "software-install-details"; -const STATUS_ICONS: Record = { - pending: "pending-outline", - installed: "success-outline", - failed: "error-outline", -} as const; - -const STATUS_PREDICATES: Record = { - pending: "will install", - installed: "installed", - failed: "failed to install", -} as const; - const StatusMessage = ({ result: { host_display_name, software_package, software_title, status }, }: { @@ -37,32 +28,26 @@ const StatusMessage = ({ }) => { return (
- + - Fleet {STATUS_PREDICATES[status]} {software_title} ( - {software_package}) on {host_display_name} + Fleet {getInstallDetailsStatusPredicate(status)} {software_title}{" "} + ({software_package}) on {host_display_name} {status === "pending" ? " when it comes online" : ""}.
); }; -const OUTPUT_DISPLAY_LABELS = { - pre_install_query_output: "Pre-install condition", - output: "Software install output", - post_install_script_output: "Post-install script output", -} as const; - const Output = ({ displayKey, result, }: { - displayKey: keyof typeof OUTPUT_DISPLAY_LABELS; + displayKey: keyof typeof SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS; result: ISoftwareInstallResult; }) => { return (
- {OUTPUT_DISPLAY_LABELS[displayKey]}: + {SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS[displayKey]}: diff --git a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss similarity index 100% rename from frontend/pages/SoftwarePage/components/SoftwareInstallDetails/_styles.scss rename to frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss diff --git a/frontend/pages/SoftwarePage/components/SoftwareInstallDetails/index.ts b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/index.ts similarity index 100% rename from frontend/pages/SoftwarePage/components/SoftwareInstallDetails/index.ts rename to frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/index.ts diff --git a/frontend/components/ActivityDetails/InstallDetails/constants.ts b/frontend/components/ActivityDetails/InstallDetails/constants.ts new file mode 100644 index 0000000000..b3cd75cf79 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/constants.ts @@ -0,0 +1,39 @@ +import { IconNames } from "components/icons"; +import { SoftwareInstallStatus } from "interfaces/software"; + +export const INSTALL_DETAILS_STATUS_ICONS: Record< + SoftwareInstallStatus, + IconNames +> = { + pending: "pending-outline", + installed: "success-outline", + failed: "error-outline", +} as const; + +const INSTALL_DETAILS_STATUS_PREDICATES: Record< + SoftwareInstallStatus, + string +> = { + pending: "will install", + installed: "installed", + failed: "failed to install", +} as const; + +export const getInstallDetailsStatusPredicate = ( + status: string | undefined +) => { + if (!status) { + return INSTALL_DETAILS_STATUS_PREDICATES.pending; + } + return ( + INSTALL_DETAILS_STATUS_PREDICATES[ + status.toLowerCase() as SoftwareInstallStatus + ] || INSTALL_DETAILS_STATUS_PREDICATES.pending + ); +}; + +export const SOFTWARE_INSTALL_OUTPUT_DISPLAY_LABELS = { + pre_install_query_output: "Pre-install condition", + output: "Software install output", + post_install_script_output: "Post-install script output", +} as const; diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 06732e90ef..01878a9c0f 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -74,6 +74,11 @@ export enum ActivityType { AddedSoftware = "added_software", DeletedSoftware = "deleted_software", InstalledSoftware = "installed_software", + EnabledVpp = "enabled_vpp", + DisabledVpp = "disabled_vpp", + AddedAppStoreApp = "added_app_store_app", + DeletedAppStoreApp = "deleted_app_store_app", + InstalledAppStoreApp = "installed_app_store_app", } // This is a subset of ActivityType that are shown only for the host past activities @@ -81,12 +86,14 @@ export type IHostPastActivityType = | ActivityType.RanScript | ActivityType.LockedHost | ActivityType.UnlockedHost - | ActivityType.InstalledSoftware; + | ActivityType.InstalledSoftware + | ActivityType.InstalledAppStoreApp; // This is a subset of ActivityType that are shown only for the host upcoming activities export type IHostUpcomingActivityType = | ActivityType.RanScript - | ActivityType.InstalledSoftware; + | ActivityType.InstalledSoftware + | ActivityType.InstalledAppStoreApp; export interface IActivity { created_at: string; @@ -153,4 +160,6 @@ export interface IActivityDetails { status?: string; install_uuid?: string; self_service?: boolean; + command_uuid?: string; + app_store_id?: number; } diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts index 3a75fc8258..496cc1c8b9 100644 --- a/frontend/interfaces/mdm.ts +++ b/frontend/interfaces/mdm.ts @@ -160,3 +160,22 @@ export enum BootstrapPackageStatus { PENDING = "pending", FAILED = "failed", } + +/** + * IMdmCommandResult is the shape of an mdm command result object + * returned by the Fleet API. + */ +export interface IMdmCommandResult { + host_uuid: string; + command_uuid: string; + /** Status is the status of the command. It can be one of Acknowledged, Error, or NotNow for + // Apple, or 200, 400, etc for Windows. */ + status: string; + updated_at: string; + request_type: string; + hostname: string; + /** Payload is a base64-encoded string containing the MDM command request */ + payload: string; + /** Result is a base64-enconded string containing the MDM command response */ + result: string; +} diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 91e45130bc..23cbcc1384 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -1,5 +1,8 @@ import { startCase } from "lodash"; import PropTypes from "prop-types"; + +import { IconNames } from "components/icons"; + import vulnerabilityInterface from "./vulnerability"; export default PropTypes.shape({ @@ -241,3 +244,24 @@ export type IDeviceSoftware = Omit< version: string; }; }; +const INSTALL_STATUS_PREDICATES: Record = { + failed: "failed to install", + installed: "installed", + pending: "told Fleet to install", +} as const; + +export const getInstallStatusPredicate = (status: string | undefined) => { + if (!status) { + return INSTALL_STATUS_PREDICATES.pending; + } + return ( + INSTALL_STATUS_PREDICATES[status.toLowerCase() as SoftwareInstallStatus] || + INSTALL_STATUS_PREDICATES.pending + ); +}; + +export const INSTALL_STATUS_ICONS: Record = { + pending: "pending-outline", + installed: "success-outline", + failed: "error-outline", +} as const; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index b51946d0f3..3a5d520d27 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -16,7 +16,8 @@ import Spinner from "components/Spinner"; // @ts-ignore import FleetIcon from "components/icons/FleetIcon"; -import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails"; +import { AppInstallDetailsModal } from "components/ActivityDetails/InstallDetails/AppInstallDetails"; +import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails"; import ActivityItem from "./ActivityItem"; import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal"; @@ -37,6 +38,10 @@ const ActivityFeed = ({ const [showShowQueryModal, setShowShowQueryModal] = useState(false); const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false); const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState(""); + const [ + appInstallDetails, + setAppInstallDetails, + ] = useState(null); const queryShown = useRef(""); const queryImpact = useRef(undefined); const scriptExecutionId = useRef(""); @@ -97,10 +102,11 @@ const ActivityFeed = ({ setShowScriptDetailsModal(true); break; case ActivityType.InstalledSoftware: - // installUuid.current = details.install_uuid ?? ""; - // console.log("installUuid.current", installUuid.current); setInstalledSoftwareUuid(details.install_uuid ?? ""); break; + case ActivityType.InstalledAppStoreApp: + setAppInstallDetails(details); + break; default: break; } @@ -197,6 +203,12 @@ const ActivityFeed = ({ onCancel={() => setInstalledSoftwareUuid("")} /> )} + {appInstallDetails && ( + setAppInstallDetails(null)} + /> + )}
); }; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index d8deaf0fe7..9919fe65f3 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -3,6 +3,7 @@ import { find, lowerCase, noop } from "lodash"; import { formatDistanceToNowStrict } from "date-fns"; import { ActivityType, IActivity, IActivityDetails } from "interfaces/activity"; +import { getInstallStatusPredicate } from "interfaces/software"; import { addGravatarUrlToResource, formatScriptNameForActivityItem, @@ -16,7 +17,6 @@ import Icon from "components/Icon"; import ReactTooltip from "react-tooltip"; import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; import { COLORS } from "styles/var/colors"; -import { getSoftwareInstallStatusPredicate } from "pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem"; const baseClass = "activity-item"; @@ -875,20 +875,22 @@ const TAGGED_TEMPLATES = { host_display_name: hostName, software_title: title, status, - install_uuid, } = details; + const showSoftwarePackage = + !!details.software_package && + activity.type === ActivityType.InstalledSoftware; + return ( <> {" "} - {getSoftwareInstallStatusPredicate(status)} {title} software on{" "} + {getInstallStatusPredicate(status)} {title} + {showSoftwarePackage && ` (${details.software_package})`} on{" "} {hostName}.{" "} + + + + ); +}; + +export default AppStoreVpp; diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss new file mode 100644 index 0000000000..15c64a9aea --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss @@ -0,0 +1,53 @@ +.app-store-vpp { + margin-top: $pad-large; + + &__description { + margin: $pad-medium 0; + } + + &__list-container { + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius-medium; + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + } + + &__list-item { + padding: $pad-small $pad-medium; + border-bottom: 1px solid $ui-fleet-black-10; + + &:last-child { + border-bottom: none; + } + } + + &__app-info { + display: flex; + align-items: center; + gap: $pad-small; + } + + &__no-software { + padding: $pad-xxlarge 48px; + text-align: center; + font-size: $x-small; + } + + &__no-software-title { + font-weight: $bold; + margin: 0; + } + + &__no-software-description { + margin-top: $pad-small; + color: $ui-fleet-black-75; + } + + &__error { + margin: $pad-xxlarge 0; + } +} diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/index.ts b/frontend/pages/SoftwarePage/components/AppStoreVpp/index.ts new file mode 100644 index 0000000000..b0dbd383b2 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/index.ts @@ -0,0 +1 @@ +export { default } from "./AppStoreVpp"; diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index bf18d8c47d..2dd8c25dc1 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -24,6 +24,7 @@ interface ISoftwareDetailsSummaryProps { name?: string; source?: string; versions?: number; + iconUrl?: string; } const SoftwareDetailsSummary = ({ @@ -34,10 +35,11 @@ const SoftwareDetailsSummary = ({ name, source, versions, + iconUrl, }: ISoftwareDetailsSummaryProps) => { return (
- +

{title}

diff --git a/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx b/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx index 8452a0f92b..3143eb32ba 100644 --- a/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx +++ b/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx @@ -5,7 +5,7 @@ import TooltipWrapper from "components/TooltipWrapper"; const generateText = (versions: T[] | null) => { if (!versions) { - return ; + return ; } const text = versions.length !== 1 ? `${versions.length} versions` : versions[0].version; diff --git a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx index 2fd45edfbd..be607717e7 100644 --- a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx +++ b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx @@ -12,15 +12,13 @@ const baseClass = "vulnerabilities-cell"; const generateCell = ( vulnerabilities: ISoftwareVulnerability[] | string[] | null ) => { - if (vulnerabilities === null) { - return ; + if (vulnerabilities === null || vulnerabilities.length === 0) { + return ; } let text = ""; let italicize = true; - if (vulnerabilities.length === 0) { - text = "---"; - } else if (vulnerabilities.length === 1) { + if (vulnerabilities.length === 1) { italicize = false; text = typeof vulnerabilities[0] === "string" diff --git a/frontend/pages/SoftwarePage/components/icons/AppStore.tsx b/frontend/pages/SoftwarePage/components/icons/AppStore.tsx new file mode 100644 index 0000000000..42632e584a --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/AppStore.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const AppStore = (props: SVGProps) => ( + + + + +); +export default AppStore; diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx index 2e93fc9272..e7c29fec98 100644 --- a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx +++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx @@ -1,34 +1,57 @@ import React from "react"; +import classnames from "classnames"; + import getMatchedSoftwareIcon from "../"; const baseClass = "software-icon"; +type SoftwareIconSizes = "small" | "medium" | "large" | "xlarge"; + interface ISoftwareIconProps { name?: string; source?: string; size?: SoftwareIconSizes; + /** Accepts an image url to display for a the software icon image. */ + url?: string; } -const SOFTWARE_ICON_SIZES: Record = { - medium: "24", - meduim_large: "64", // TODO: rename this to large and update large to xlarge - large: "96", -} as const; - -type SoftwareIconSizes = keyof typeof SOFTWARE_ICON_SIZES; +const SOFTWARE_ICON_SIZES: Record = { + small: "24", + medium: "40", + large: "64", + xlarge: "96", +}; const SoftwareIcon = ({ name = "", source = "", - size = "medium", + size = "small", + url, }: ISoftwareIconProps) => { + const classNames = classnames(baseClass, `${baseClass}__${size}`); + + // If we are given a url to render as the icon, we need to render it + // differently than the svg icons. We will use an img tag instead with the + // src set to the url. + if (url) { + const imgClasses = classnames( + `${baseClass}__software-img`, + `${baseClass}__software-img-${size}` + ); + return ( +
+ +
+ ); + } + const MatchedIcon = getMatchedSoftwareIcon({ name, source }); return ( ); }; diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss index 328a564a1e..ee0b9e5412 100644 --- a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss +++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss @@ -1,5 +1,40 @@ .software-icon { flex-shrink: 0; border: 1px solid $ui-fleet-black-10; - border-radius: 8px; + + &__small { + border-radius: $border-radius; + } + + &__medium { + border-radius: $border-radius-xlarge; + } + + &__xlarge { + border-radius: $border-radius-xxlarge; + } + + &__software-img { + display: block; + } + + // we use this selector to give higher specifity than the selector + // ".core-wrapper a img" which is in /styles/global/_styles.scss + // TODO: we should change ".core-wrapper a img" selector in that file + // as it too generic and can affect other parts of the app. + > img.software-icon__software-img-small { + width: 22px; + height: 22px; + border-radius: $border-radius-small; + padding: 1px; + margin-left: 0; + } + + >img.software-icon__software-img-xlarge { + width: 88px; + height: 88px; + border-radius: $border-radius-xxlarge; + padding: $pad-xsmall; + margin-left: 0; + } } diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts index 0d30e286cd..e3649d7eda 100644 --- a/frontend/pages/SoftwarePage/components/icons/index.ts +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -20,6 +20,7 @@ import Zoom from "./Zoom"; import ChromeOS from "./ChromeOS"; import LinuxOS from "./LinuxOS"; import Falcon from "./Falcon"; +import AppStore from "./AppStore"; // Maps all known Linux platforms to the LinuxOS icon const LINUX_OS_NAME_TO_ICON_MAP = HOST_LINUX_PLATFORMS.reduce( @@ -31,6 +32,7 @@ const LINUX_OS_NAME_TO_ICON_MAP = HOST_LINUX_PLATFORMS.reduce( // icon for them, keys refer to application names, and are intended to be fuzzy // matched in the application logic. const SOFTWARE_NAME_TO_ICON_MAP = { + appStore: AppStore, "adobe acrobat reader": AcrobatReader, "microsoft excel": Excel, falcon: Falcon, diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx index 028821381d..272d7bbfb7 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/components/DisableVppModal/DisableVppModal.tsx @@ -43,7 +43,7 @@ const DisableVppModal = ({ onExit }: IDisableVppModalProps) => { <>

Apps purchased in Apple Business Manager won't appear in Fleet. - Apps won't be uninstalled from hosts. If you want to enable + Apps won't be uninstalled from hosts. If you want to enable VPP integration again, you'll have to upload a new content token.

diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx index d04e235e66..18bf75f47f 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx @@ -56,7 +56,7 @@ const InstallerInfo = ({ software }: IInstallerInfoProps) => { return (
- +
diff --git a/frontend/services/entities/mdm_apple.ts b/frontend/services/entities/mdm_apple.ts index a435643eb8..1314ca6d33 100644 --- a/frontend/services/entities/mdm_apple.ts +++ b/frontend/services/entities/mdm_apple.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import sendRequest from "services"; import endpoints from "utilities/endpoints"; @@ -8,6 +7,18 @@ export interface IGetVppInfoResponse { location: string; } +export interface IVppApp { + name: string; + icon_url: string; + latest_version: string; + app_store_id: number; + added: boolean; +} + +interface IGetVppAppsResponse { + app_store_apps: IVppApp[]; +} + export default { getAppleAPNInfo: () => { const { MDM_APPLE_PNS } = endpoints; @@ -48,4 +59,18 @@ export default { const { MDM_APPLE_VPP_TOKEN } = endpoints; return sendRequest("DELETE", MDM_APPLE_VPP_TOKEN); }, + + getVppApps: (teamId: number): Promise => { + const { MDM_APPLE_VPP_APPS } = endpoints; + const path = `${MDM_APPLE_VPP_APPS}?team_id=${teamId}`; + return sendRequest("GET", path); + }, + + addVppApp: (teamId: number, appStoreId: number) => { + const { MDM_APPLE_VPP_APPS } = endpoints; + return sendRequest("POST", MDM_APPLE_VPP_APPS, { + app_store_id: appStoreId, + team_id: teamId, + }); + }, }; diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 372b8b5268..1ebfcbd655 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -6,15 +6,20 @@ import { ISoftwareResponse, ISoftwareCountResponse, ISoftwareVersion, - ISoftwareTitleWithPackageDetail, - ISoftwareTitleWithPackageName, + ISoftwareTitle, + ISoftwareTitleDetails, } from "interfaces/software"; import { buildQueryStringFromParams, convertParamsToSnakeCase, } from "utilities/url"; - -import { IAddSoftwareFormData } from "pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm"; +import { IAddSoftwareFormData } from "pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm"; +import { + createMockAppStoreApp, + createMockSoftware, + createMockSoftwareTitleDetails, + createMockSoftwareTitleResponse, +} from "__mocks__/softwareMock"; export interface ISoftwareApiParams { page?: number; @@ -31,7 +36,7 @@ export interface ISoftwareApiParams { export interface ISoftwareTitlesResponse { counts_updated_at: string | null; count: number; - software_titles: ISoftwareTitleWithPackageName[]; + software_titles: ISoftwareTitle[]; meta: { has_next_results: boolean; has_previous_results: boolean; @@ -49,7 +54,7 @@ export interface ISoftwareVersionsResponse { } export interface ISoftwareTitleResponse { - software_title: ISoftwareTitleWithPackageDetail; + software_title: ISoftwareTitleDetails; } export interface ISoftwareVersionResponse { @@ -223,8 +228,10 @@ export default { }, deleteSoftwarePackage: (softwareId: number, teamId: number) => { - const { SOFTWARE_PACKAGE } = endpoints; - const path = `${SOFTWARE_PACKAGE(softwareId)}?team_id=${teamId}`; + const { SOFTWARE_AVAILABLE_FOR_INSTALL } = endpoints; + const path = `${SOFTWARE_AVAILABLE_FOR_INSTALL( + softwareId + )}?team_id=${teamId}`; return sendRequest("DELETE", path); }, diff --git a/frontend/styles/var/_global.scss b/frontend/styles/var/_global.scss index 74e758e2db..f439ba4b97 100644 --- a/frontend/styles/var/_global.scss +++ b/frontend/styles/var/_global.scss @@ -1,4 +1,5 @@ // border radius +$border-radius-small: 3px; $border-radius: 4px; $border-radius-medium: 6px; $border-radius-large: 8px; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 2b698b36be..cc2d46ca16 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -82,6 +82,7 @@ export default { 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`, + MDM_APPLE_VPP_APPS: `/${API_VERSION}/fleet/software/app_store_apps`, MDM_SUMMARY: `/${API_VERSION}/fleet/hosts/summary/mdm`, MDM_REQUEST_CSR: `/${API_VERSION}/fleet/mdm/apple/request_csr`, @@ -153,6 +154,8 @@ export default { `/${API_VERSION}/fleet/software/install/results/${uuid}`, SOFTWARE_PACKAGE_INSTALL: (id: number) => `/${API_VERSION}/fleet/software/packages/${id}`, + SOFTWARE_AVAILABLE_FOR_INSTALL: (id: number) => + `/${API_VERSION}/fleet/software/${id}/available_for_install`, // AI endpoints AUTOFILL_POLICY: `/${API_VERSION}/fleet/autofill/policy`, From 730ac90cf15f92c28eb9ddd30a13859b07dbfcdb Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Thu, 18 Jul 2024 17:04:15 +0100 Subject: [PATCH 19/38] update software title page with new API response (#20566) relates to #20536 quick update to the software titles page to integrate with new API changes. - [x] Manual QA for all new/changed functionality --- frontend/__mocks__/softwareMock.ts | 80 +++++++++---------- frontend/interfaces/software.ts | 6 +- .../SoftwareTitlesTableConfig.tsx | 74 ++++++++++++----- 3 files changed, 95 insertions(+), 65 deletions(-) diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 591dc65670..2846b18dde 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -45,44 +45,6 @@ export const createMockSoftwareTitleVersion = ( return { ...DEFAULT_SOFTWARE_TITLE_VERSION_MOCK, ...overrides }; }; -const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = { - id: 1, - name: "mock software 1.app", - available_for_install: false, - icon_url: "", - versions_count: 1, - source: "apps", - hosts_count: 1, - browser: "chrome", - versions: [createMockSoftwareTitleVersion()], - self_service: false, -}; - -export const createMockSoftwareTitle = ( - overrides?: Partial -): ISoftwareTitle => { - return { - ...DEFAULT_SOFTWARE_TITLE_MOCK, - ...overrides, - }; -}; - -const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = { - counts_updated_at: "2020-01-01T00:00:00.000Z", - count: 1, - software_titles: [createMockSoftwareTitle()], - meta: { - has_next_results: false, - has_previous_results: false, - }, -}; - -export const createMockSoftwareTitlesReponse = ( - overrides?: Partial -): ISoftwareTitlesResponse => { - return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides }; -}; - const DEFAULT_SOFTWARE_VULNERABILITY_MOCK = { cve: "CVE-2020-0001", details_link: "https://test.com", @@ -191,7 +153,7 @@ export const createMockSoftwareVersionResponse = ( return { ...DEFAULT_SOFTWARE_VERSION_RESPONSE, ...overrides }; }; -const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = { +const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { name: "TestPackage-1.2.3.pkg", version: "1.2.3", uploaded_at: "2020-01-01T00:00:00.000Z", @@ -200,6 +162,7 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = { post_install_script: "sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123", self_service: false, + icon_url: null, status: { installed: 1, pending: 2, @@ -210,5 +173,42 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = { export const createMockSoftwarePackage = ( overrides?: Partial ) => { - return { ...DEFAULT_SOFTWAREPACKAGE_MOCK, ...overrides }; + return { ...DEFAULT_SOFTWARE_PACKAGE_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = { + id: 1, + name: "mock software 1.app", + versions_count: 1, + source: "apps", + hosts_count: 1, + browser: "chrome", + versions: [createMockSoftwareTitleVersion()], + software_package: createMockSoftwarePackage(), + app_store_app: null, +}; + +export const createMockSoftwareTitle = ( + overrides?: Partial +): ISoftwareTitle => { + return { + ...DEFAULT_SOFTWARE_TITLE_MOCK, + ...overrides, + }; +}; + +const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = { + counts_updated_at: "2020-01-01T00:00:00.000Z", + count: 1, + software_titles: [createMockSoftwareTitle()], + meta: { + has_next_results: false, + has_previous_results: false, + }, +}; + +export const createMockSoftwareTitlesReponse = ( + overrides?: Partial +): ISoftwareTitlesResponse => { + return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides }; }; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index d6c4a12020..b5bdeb3095 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -61,6 +61,7 @@ export interface ISoftwarePackage { pre_install_query?: string; post_install_script?: string; self_service: boolean; + icon_url: string | null; status: { installed: number; pending: number; @@ -88,13 +89,12 @@ export interface IAppStoreApp { export interface ISoftwareTitle { id: number; name: string; - icon_url: string | null; versions_count: number; source: string; hosts_count: number; versions: ISoftwareTitleVersion[] | null; - available_for_install: boolean; - self_service: boolean; + software_package: ISoftwarePackage | null; + app_store_app: IAppStoreApp | null; browser?: string; } diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx index 27c27f9909..0e303c918c 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx @@ -2,7 +2,13 @@ import React from "react"; import { CellProps, Column } from "react-table"; import { InjectedRouter } from "react-router"; -import { ISoftwareTitle, formatSoftwareType } from "interfaces/software"; +import { + IAppStoreApp, + ISoftware, + ISoftwarePackage, + ISoftwareTitle, + formatSoftwareType, +} from "interfaces/software"; import PATHS from "router/paths"; import { buildQueryStringFromParams } from "utilities/url"; @@ -51,6 +57,41 @@ export const getVulnerabilities = < return vulnerabilities; }; +/** + * Gets the data needed to render the software name cell. + */ +const getSoftwareNameCellData = ( + softwareTitle: ISoftwareTitle, + teamId?: number +) => { + const teamQueryParam = buildQueryStringFromParams({ team_id: teamId }); + const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS( + softwareTitle.id.toString() + )}?${teamQueryParam}`; + + const { software_package, app_store_app } = softwareTitle; + let hasPackage = false; + let isSelfService = false; + let iconUrl: string | null = null; + if (software_package) { + hasPackage = true; + isSelfService = software_package.self_service; + } else if (app_store_app) { + hasPackage = true; + isSelfService = false; + iconUrl = app_store_app.icon_url; + } + + return { + name: softwareTitle.name, + source: softwareTitle.source, + path: softwareTitleDetailsPath, + hasPackage: hasPackage && !!teamId, + isSelfService, + iconUrl, + }; +}; + const generateTableHeaders = ( router: InjectedRouter, teamId?: number @@ -63,31 +104,20 @@ const generateTableHeaders = ( disableSortBy: false, accessor: "name", Cell: (cellProps: ITableStringCellProps) => { - const { - id, - name, - source, - available_for_install, - self_service, - icon_url, - } = cellProps.row.original; - - const teamQueryParam = buildQueryStringFromParams({ team_id: teamId }); - const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS( - id.toString() - )}?${teamQueryParam}`; - - const hasPackage = available_for_install && !!teamId; // teamId is required for package installation + const nameCellData = getSoftwareNameCellData( + cellProps.row.original, + teamId + ); return ( ); }, From b8b03b1e5ace3f21aa666c2c69e2992e928284c1 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Thu, 18 Jul 2024 17:33:07 -0400 Subject: [PATCH 20/38] VPP: update list software titles/list host's software response payloads (#20553) #20536 # Checklist for submitter - [ ] 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. - [ ] Added/updated tests --------- Co-authored-by: Roberto Dip Co-authored-by: Roberto Dip --- cmd/fleetctl/get_test.go | 20 +-- server/datastore/mysql/software.go | 38 +++-- server/datastore/mysql/software_test.go | 157 ++++++++---------- server/datastore/mysql/software_titles.go | 79 +++++++-- .../datastore/mysql/software_titles_test.go | 54 ++++-- server/fleet/software.go | 16 +- server/fleet/software_installer.go | 20 +-- server/fleet/vpp.go | 5 +- server/service/integration_enterprise_test.go | 59 +++---- 9 files changed, 234 insertions(+), 214 deletions(-) diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index fa71e12380..2dfe11d4a6 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -684,12 +684,11 @@ func TestGetSoftwareTitles(t *testing.T) { apiVersion: "1" kind: software_title spec: -- available_for_install: false +- app_store_app: null hosts_count: 2 - icon_url: null id: 0 name: foo - self_service: false + software_package: null source: chrome_extensions versions: - id: 0 @@ -706,12 +705,11 @@ spec: vulnerabilities: - cve-123-456-003 versions_count: 3 -- available_for_install: false +- app_store_app: null hosts_count: 0 - icon_url: null id: 0 name: bar - self_service: false + software_package: null source: deb_packages versions: - id: 0 @@ -729,9 +727,7 @@ spec: "id": 0, "name": "foo", "source": "chrome_extensions", - "available_for_install": false, "hosts_count": 2, - "icon_url": null, "versions_count": 3, "versions": [ { @@ -757,15 +753,14 @@ spec: ] } ], - "self_service": false + "software_package": null, + "app_store_app": null }, { "id": 0, "name": "bar", "source": "deb_packages", - "available_for_install": false, "hosts_count": 0, - "icon_url": null, "versions_count": 1, "versions": [ { @@ -774,7 +769,8 @@ spec: "vulnerabilities": null } ], - "self_service": false + "software_package": null, + "app_store_app": null } ] } diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 78bfbf9210..81c11a7e65 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -2047,14 +2047,14 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id st.id, st.name, st.source, - -- will be NULL for VPP apps for now, self-service not supported yet - si.self_service as self_service, - -- this count will be 1 if an installer or VPP app is available, 0 otherwise - IF(COALESCE(si.id, vap.adam_id) IS NOT NULL, 1, 0) as available_for_install, + si.self_service as package_self_service, si.filename as package_name, si.version as package_version, + -- in a future iteration, will be supported for VPP apps + NULL as vpp_app_self_service, vap.adam_id as vpp_app_adam_id, vap.latest_version as vpp_app_version, + NULLIF(vap.icon_url, '') as vpp_app_icon_url, COALESCE(hsi.created_at, hvsi.created_at) as last_install_installed_at, COALESCE(hsi.execution_id, hvsi.command_uuid) as last_install_install_uuid, -- get either the softare installer status or the vpp app status @@ -2110,13 +2110,14 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id st.id, st.name, st.source, - -- will be NULL for VPP apps for now, self-service not supported yet - si.self_service as self_service, - 1 as available_for_install, + si.self_service as package_self_service, si.filename as package_name, si.version as package_version, + -- in a future iteration, will be supported for VPP apps + NULL as vpp_app_self_service, vap.adam_id as vpp_app_adam_id, vap.latest_version as vpp_app_version, + NULLIF(vap.icon_url, '') as vpp_app_icon_url, NULL as last_install_installed_at, NULL as last_install_install_uuid, NULL as status @@ -2171,12 +2172,13 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id id, name, source, - self_service, - available_for_install, + package_self_service, package_name, package_version, + vpp_app_self_service, vpp_app_adam_id, vpp_app_version, + vpp_app_icon_url, last_install_installed_at, last_install_install_uuid, status @@ -2241,10 +2243,13 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id fleet.HostSoftwareWithInstaller LastInstallInstalledAt *time.Time `db:"last_install_installed_at"` LastInstallInstallUUID *string `db:"last_install_install_uuid"` + PackageSelfService *bool `db:"package_self_service"` PackageName *string `db:"package_name"` PackageVersion *string `db:"package_version"` + VPPAppSelfService *bool `db:"vpp_app_self_service"` VPPAppAdamID *string `db:"vpp_app_adam_id"` VPPAppVersion *string `db:"vpp_app_version"` + VPPAppIconURL *string `db:"vpp_app_icon_url"` } var hostSoftwareList []*hostSoftware if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostSoftwareList, stmt, args...); err != nil { @@ -2272,9 +2277,10 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id if hs.PackageVersion != nil { version = *hs.PackageVersion } - hs.SoftwarePackage = &fleet.HostSoftwarePackageOrApp{ - Name: *hs.PackageName, - Version: version, + hs.SoftwarePackage = &fleet.SoftwarePackageOrApp{ + Name: *hs.PackageName, + Version: version, + SelfService: hs.PackageSelfService, } } @@ -2284,9 +2290,11 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id if hs.VPPAppVersion != nil { version = *hs.VPPAppVersion } - hs.AppStoreApp = &fleet.HostSoftwarePackageOrApp{ - AppStoreID: *hs.VPPAppAdamID, - Version: version, + hs.AppStoreApp = &fleet.SoftwarePackageOrApp{ + AppStoreID: *hs.VPPAppAdamID, + Version: version, + SelfService: hs.VPPAppSelfService, + IconURL: hs.VPPAppIconURL, } } diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index 3bf7ce99a6..3ccb27f9be 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -3288,7 +3288,6 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { require.True(t, ok) require.Equal(t, e.Name, g.Name) require.Equal(t, e.Source, g.Source) - require.Equal(t, e.AvailableForInstall, g.AvailableForInstall, g.Name+g.Source) require.Equal(t, e.SoftwarePackage, g.SoftwarePackage) require.Equal(t, e.AppStoreApp, g.AppStoreApp) require.Len(t, g.InstalledVersions, len(e.InstalledVersions)) @@ -3465,36 +3464,30 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { // swi1Pending uses software title id of "b" expected[byNSV[b].Name+byNSV[b].Source] = fleet.HostSoftwareWithInstaller{ - Name: "b", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}, - AvailableForInstall: true, - SoftwarePackage: &fleet.HostSoftwarePackageOrApp{Name: "installer-0.pkg", Version: "v0.0.0"}, - SelfService: ptr.Bool(true), + Name: "b", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerPending), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}, + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-0.pkg", Version: "v0.0.0", SelfService: ptr.Bool(true)}, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}}, }, } i0 := fleet.HostSoftwareWithInstaller{ - Name: "i0", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerInstalled), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid2"}, - SelfService: ptr.Bool(true), - AvailableForInstall: true, - SoftwarePackage: &fleet.HostSoftwarePackageOrApp{Name: "installer-1.pkg", Version: "v1.0.0"}, + Name: "i0", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerInstalled), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid2"}, + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-1.pkg", Version: "v1.0.0", SelfService: ptr.Bool(true)}, } expected[i0.Name+i0.Source] = i0 i1 := fleet.HostSoftwareWithInstaller{ - Name: "i1", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerFailed), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid3"}, - SelfService: ptr.Bool(false), - AvailableForInstall: true, - SoftwarePackage: &fleet.HostSoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0"}, + Name: "i1", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerFailed), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid3"}, + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", SelfService: ptr.Bool(false)}, } expected[i1.Name+i1.Source] = i1 @@ -3507,24 +3500,20 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { // request with available software i2 := fleet.HostSoftwareWithInstaller{ - Name: "i2", - Source: "apps", - Status: nil, - LastInstall: nil, - AvailableForInstall: true, - SoftwarePackage: &fleet.HostSoftwarePackageOrApp{Name: "installer-3.pkg", Version: "v3.0.0"}, - SelfService: ptr.Bool(false), + Name: "i2", + Source: "apps", + Status: nil, + LastInstall: nil, + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-3.pkg", Version: "v3.0.0", SelfService: ptr.Bool(false)}, } expected[i2.Name+i2.Source] = i2 i3 := fleet.HostSoftwareWithInstaller{ - Name: "i3", - Source: "apps", - Status: nil, - LastInstall: nil, - AvailableForInstall: true, - SoftwarePackage: &fleet.HostSoftwarePackageOrApp{Name: "installer-4.pkg", Version: "v4.0.0"}, - SelfService: ptr.Bool(false), + Name: "i3", + Source: "apps", + Status: nil, + LastInstall: nil, + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-4.pkg", Version: "v4.0.0", SelfService: ptr.Bool(false)}, } expected[i3.Name+i3.Source] = i3 @@ -3568,25 +3557,21 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { }) expected[byNSV[b].Name+byNSV[b].Source] = fleet.HostSoftwareWithInstaller{ - Name: "b", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerFailed), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}, - AvailableForInstall: true, - SoftwarePackage: &fleet.HostSoftwarePackageOrApp{Name: "installer-0.pkg", Version: "v0.0.0"}, - SelfService: ptr.Bool(true), + Name: "b", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerFailed), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}, + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-0.pkg", Version: "v0.0.0", SelfService: ptr.Bool(true)}, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}}, }, } expected[i1.Name+i1.Source] = fleet.HostSoftwareWithInstaller{ - Name: "i1", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid4"}, - SelfService: ptr.Bool(false), - AvailableForInstall: true, - SoftwarePackage: &fleet.HostSoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0"}, + Name: "i1", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerPending), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid4"}, + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", SelfService: ptr.Bool(false)}, } // request without available software @@ -3677,22 +3662,18 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { require.NotEmpty(t, vpp1TmCmdUUID) expected["vpp1apps"] = fleet.HostSoftwareWithInstaller{ - Name: "vpp1", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerInstalled), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: vpp1CmdUUID}, - SelfService: nil, - AvailableForInstall: true, - AppStoreApp: &fleet.HostSoftwarePackageOrApp{AppStoreID: vpp1}, + Name: "vpp1", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerInstalled), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: vpp1CmdUUID}, + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1}, } expected["vpp2apps"] = fleet.HostSoftwareWithInstaller{ - Name: "vpp2", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: vpp2bCmdUUID}, - SelfService: nil, - AvailableForInstall: true, - AppStoreApp: &fleet.HostSoftwarePackageOrApp{AppStoreID: vpp2}, + Name: "vpp2", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerPending), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: vpp2bCmdUUID}, + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2}, } opts.IncludeAvailableForInstall = false @@ -3703,13 +3684,11 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { compareResults(expected, sw, true, i3.Name+i3.Source, i2.Name+i2.Source) // i3 is for team, i2 is available (excluded) expected["vpp3apps"] = fleet.HostSoftwareWithInstaller{ - Name: "vpp3", - Source: "apps", - Status: nil, - LastInstall: nil, - SelfService: nil, - AvailableForInstall: true, - AppStoreApp: &fleet.HostSoftwarePackageOrApp{AppStoreID: vpp3}, + Name: "vpp3", + Source: "apps", + Status: nil, + LastInstall: nil, + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3}, } opts.IncludeAvailableForInstall = true opts.ListOptions.PerPage = 20 @@ -3726,13 +3705,11 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { compareResults(map[string]fleet.HostSoftwareWithInstaller{ i3.Name + i3.Source: expected[i3.Name+i3.Source], "vpp1apps": { - Name: "vpp1", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: vpp1TmCmdUUID}, - SelfService: nil, - AvailableForInstall: true, - AppStoreApp: &fleet.HostSoftwarePackageOrApp{AppStoreID: vpp1}, + Name: "vpp1", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerPending), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: vpp1TmCmdUUID}, + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1}, }, }, sw, true) @@ -3750,22 +3727,18 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { {Version: otherSoftware[1].Version}, }}, "i1apps": { - Name: "i1", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI1UUID}, - SelfService: ptr.Bool(false), - AvailableForInstall: true, - SoftwarePackage: &fleet.HostSoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0"}, + Name: "i1", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerPending), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI1UUID}, + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", SelfService: ptr.Bool(false)}, }, "i2apps": { - Name: "i2", - Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), - LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI2UUID}, - SelfService: ptr.Bool(false), - AvailableForInstall: true, - SoftwarePackage: &fleet.HostSoftwarePackageOrApp{Name: "installer-3.pkg", Version: "v3.0.0"}, + Name: "i2", + Source: "apps", + Status: expectStatus(fleet.SoftwareInstallerPending), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI2UUID}, + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-3.pkg", Version: "v3.0.0", SelfService: ptr.Bool(false)}, }, } compareResults(expectedOther, sw, true) diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 338a5689ec..04a2198074 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -100,12 +100,22 @@ func (ds *Datastore) ListSoftwareTitles( getTitlesCountStmt := fmt.Sprintf(`SELECT COUNT(DISTINCT s.id) FROM (%s) AS s`, getTitlesStmt) // grab titles that match the list options - var titles []fleet.SoftwareTitleListResult + type softwareTitle struct { + fleet.SoftwareTitleListResult + PackageSelfService *bool `db:"package_self_service"` + PackageName *string `db:"package_name"` + PackageVersion *string `db:"package_version"` + VPPAppSelfService *bool `db:"vpp_app_self_service"` + VPPAppAdamID *string `db:"vpp_app_adam_id"` + VPPAppVersion *string `db:"vpp_app_version"` + VPPAppIconURL *string `db:"vpp_app_icon_url"` + } + var softwareList []*softwareTitle getTitlesStmt, args = appendListOptionsWithCursorToSQL(getTitlesStmt, args, &opt.ListOptions) // appendListOptionsWithCursorToSQL doesn't support multicolumn sort, so // we need to add it here getTitlesStmt = spliceSecondaryOrderBySoftwareTitlesSQL(getTitlesStmt, opt.ListOptions) - if err := sqlx.SelectContext(ctx, dbReader, &titles, getTitlesStmt, args...); err != nil { + if err := sqlx.SelectContext(ctx, dbReader, &softwareList, getTitlesStmt, args...); err != nil { return nil, 0, nil, ctxerr.Wrap(ctx, err, "select software titles") } @@ -117,15 +127,42 @@ func (ds *Datastore) ListSoftwareTitles( // if we don't have any matching titles, there's no point trying to // find matching versions. Early return - if len(titles) == 0 { - return titles, counts, &fleet.PaginationMetadata{}, nil + if len(softwareList) == 0 { + return nil, counts, &fleet.PaginationMetadata{}, nil } // grab all the IDs to find matching versions below - titleIDs := make([]uint, len(titles)) + titleIDs := make([]uint, len(softwareList)) // build an index to quickly access a title by it's ID - titleIndex := make(map[uint]int, len(titles)) - for i, title := range titles { + titleIndex := make(map[uint]int, len(softwareList)) + for i, title := range softwareList { + // promote the package name and version to the proper destination fields + if title.PackageName != nil { + var version string + if title.PackageVersion != nil { + version = *title.PackageVersion + } + title.SoftwarePackage = &fleet.SoftwarePackageOrApp{ + Name: *title.PackageName, + Version: version, + SelfService: title.PackageSelfService, + } + } + + // promote the VPP app id and version to the proper destination fields + if title.VPPAppAdamID != nil { + var version string + if title.VPPAppVersion != nil { + version = *title.VPPAppVersion + } + title.AppStoreApp = &fleet.SoftwarePackageOrApp{ + AppStoreID: *title.VPPAppAdamID, + Version: version, + SelfService: title.VPPAppSelfService, + IconURL: title.VPPAppIconURL, + } + } + titleIDs[i] = title.ID titleIndex[title.ID] = i } @@ -151,20 +188,26 @@ func (ds *Datastore) ListSoftwareTitles( // append matching versions to titles for _, version := range versions { if i, ok := titleIndex[version.TitleID]; ok { - titles[i].VersionsCount++ - titles[i].Versions = append(titles[i].Versions, version) + softwareList[i].VersionsCount++ + softwareList[i].Versions = append(softwareList[i].Versions, version) } } var metaData *fleet.PaginationMetadata if opt.ListOptions.IncludeMetadata { metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.ListOptions.Page > 0} - if len(titles) > int(opt.ListOptions.PerPage) { + if len(softwareList) > int(opt.ListOptions.PerPage) { metaData.HasNextResults = true - titles = titles[:len(titles)-1] + softwareList = softwareList[:len(softwareList)-1] } } + titles := make([]fleet.SoftwareTitleListResult, 0, len(softwareList)) + for _, st := range softwareList { + st := st + titles = append(titles, st.SoftwareTitleListResult) + } + return titles, counts, metaData, nil } @@ -209,10 +252,14 @@ SELECT st.browser, MAX(COALESCE(sthc.hosts_count, 0)) as hosts_count, MAX(COALESCE(sthc.updated_at, date('0001-01-01 00:00:00'))) as counts_updated_at, - COALESCE(si.self_service, false) as self_service, - -- this count will be 1 if an installer or VPP app is available, 0 otherwise - COUNT(COALESCE(si.id, vat.adam_id)) as available_for_install, - NULLIF(vap.icon_url, '') as icon_url + si.self_service as package_self_service, + si.filename as package_name, + si.version as package_version, + -- in a future iteration, will be supported for VPP apps + 0 as vpp_app_self_service, + vat.adam_id as vpp_app_adam_id, + vap.latest_version as vpp_app_version, + vap.icon_url as vpp_app_icon_url FROM software_titles st LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ? LEFT JOIN vpp_apps vap ON vap.title_id = st.id @@ -224,7 +271,7 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND WHERE %s -- placeholder for filter based on software installed on hosts + software installers AND (%s) -GROUP BY st.id, self_service, icon_url` +GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url` cveJoinType := "LEFT" if opt.VulnerableOnly { diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index f29f6aeced..2e85ea854d 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -335,45 +335,55 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { i := 0 require.Equal(t, "bar", titles[i].Name) require.Equal(t, "deb_packages", titles[i].Source) - require.False(t, titles[i].AvailableForInstall) + require.Nil(t, titles[i].SoftwarePackage) + require.Nil(t, titles[i].AppStoreApp) i++ require.Equal(t, "foo", titles[i].Name) require.Equal(t, "chrome_extensions", titles[i].Source) - require.False(t, titles[i].AvailableForInstall) + require.Nil(t, titles[i].SoftwarePackage) + require.Nil(t, titles[i].AppStoreApp) i++ require.Equal(t, "foo", titles[i].Name) require.Equal(t, "deb_packages", titles[i].Source) - require.False(t, titles[i].AvailableForInstall) + require.Nil(t, titles[i].SoftwarePackage) + require.Nil(t, titles[i].AppStoreApp) i++ require.Equal(t, "bar", titles[i].Name) require.Equal(t, "apps", titles[i].Source) - require.False(t, titles[i].AvailableForInstall) + require.Nil(t, titles[i].SoftwarePackage) + require.Nil(t, titles[i].AppStoreApp) i++ require.Equal(t, "baz", titles[i].Name) require.Equal(t, "chrome_extensions", titles[i].Source) require.Equal(t, "chrome", titles[i].Browser) - require.False(t, titles[i].AvailableForInstall) + require.Nil(t, titles[i].SoftwarePackage) + require.Nil(t, titles[i].AppStoreApp) i++ require.Equal(t, "baz", titles[i].Name) require.Equal(t, "chrome_extensions", titles[i].Source) require.Equal(t, "edge", titles[i].Browser) - require.False(t, titles[i].AvailableForInstall) + require.Nil(t, titles[i].SoftwarePackage) + require.Nil(t, titles[i].AppStoreApp) i++ require.Equal(t, "foo", titles[i].Name) require.Equal(t, "rpm_packages", titles[i].Source) - require.False(t, titles[i].AvailableForInstall) + require.Nil(t, titles[i].SoftwarePackage) + require.Nil(t, titles[i].AppStoreApp) i++ require.Equal(t, "installer1", titles[i].Name) require.Equal(t, "apps", titles[i].Source) - require.True(t, titles[i].AvailableForInstall) + require.NotNil(t, titles[i].SoftwarePackage) + require.Nil(t, titles[i].AppStoreApp) i++ require.Equal(t, "installer2", titles[i].Name) require.Equal(t, "apps", titles[i].Source) - require.True(t, titles[i].AvailableForInstall) + require.NotNil(t, titles[i].SoftwarePackage) + require.Nil(t, titles[i].AppStoreApp) i++ require.Equal(t, "vpp1", titles[i].Name) require.Equal(t, "apps", titles[i].Source) - require.True(t, titles[i].AvailableForInstall) + require.Nil(t, titles[i].SoftwarePackage) + require.NotNil(t, titles[i].AppStoreApp) // primary sort is "hosts_count ASC", followed by "name ASC, source ASC, browser ASC" titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ @@ -631,10 +641,12 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "chrome_extensions", titles[1].Source) require.Equal(t, uint(1), titles[0].VersionsCount) assert.Equal(t, uint(1), titles[0].HostsCount) - require.False(t, titles[0].AvailableForInstall) + require.Nil(t, titles[0].SoftwarePackage) + require.Nil(t, titles[0].AppStoreApp) require.Equal(t, uint(2), titles[1].VersionsCount) assert.Equal(t, uint(2), titles[1].HostsCount) - require.False(t, titles[1].AvailableForInstall) + require.Nil(t, titles[1].SoftwarePackage) + require.Nil(t, titles[1].AppStoreApp) title, err := ds.SoftwareTitleByID(context.Background(), titles[0].ID, nil, globalTeamFilter) require.NoError(t, err) @@ -673,9 +685,11 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "installer1", titles[1].Name) require.Equal(t, "apps", titles[1].Source) require.Equal(t, uint(1), titles[0].VersionsCount) - require.False(t, titles[0].AvailableForInstall) + require.Nil(t, titles[0].SoftwarePackage) + require.Nil(t, titles[0].AppStoreApp) require.Equal(t, uint(0), titles[1].VersionsCount) - require.True(t, titles[1].AvailableForInstall) + require.NotNil(t, titles[1].SoftwarePackage) + require.Nil(t, titles[1].AppStoreApp) // Testing with team filter -- this team does contain this software title title, err = ds.SoftwareTitleByID(context.Background(), titles[0].ID, &team1.ID, team1TeamFilter) @@ -705,10 +719,14 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, uint(1), titles[1].VersionsCount) require.Equal(t, uint(0), titles[2].VersionsCount) require.Equal(t, uint(0), titles[3].VersionsCount) - require.False(t, titles[0].AvailableForInstall) - require.False(t, titles[1].AvailableForInstall) - require.True(t, titles[2].AvailableForInstall) - require.True(t, titles[3].AvailableForInstall) + require.Nil(t, titles[0].SoftwarePackage) + require.Nil(t, titles[0].AppStoreApp) + require.Nil(t, titles[1].SoftwarePackage) + require.Nil(t, titles[1].AppStoreApp) + require.NotNil(t, titles[2].SoftwarePackage) + require.Nil(t, titles[2].AppStoreApp) + require.Nil(t, titles[3].SoftwarePackage) + require.NotNil(t, titles[3].AppStoreApp) // Testing the team 1 user with self-service only titles, _, _, err = ds.ListSoftwareTitles( diff --git a/server/fleet/software.go b/server/fleet/software.go index e084ba4ad8..2184e984c6 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -202,14 +202,14 @@ type SoftwareTitleListResult struct { // CountsUpdatedAt is the timestamp when the hosts count // was last updated for that software title CountsUpdatedAt *time.Time `json:"-" db:"counts_updated_at"` - // SelfService indicates if the end user can initiate the installation - SelfService bool `json:"self_service" db:"self_service"` - // AvailableForInstall is true if the software title has an installer or a - // VPP App available to install it, false otherwise. - AvailableForInstall bool `json:"available_for_install" db:"available_for_install"` - // IconURL is the VPP App icon URL. It is nil for non-VPP Apps or if no icon - // is available. - IconURL *string `json:"icon_url" db:"icon_url"` + + // SoftwarePackage provides software installer package information, it is + // only present if a software installer is available for the software title. + SoftwarePackage *SoftwarePackageOrApp `json:"software_package"` + + // AppStoreApp provides VPP app information, it is only present if a VPP app + // is available for the software title. + AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"` } type SoftwareTitleListOptions struct { diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 5ef8314b11..dd5330f0d0 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -310,32 +310,30 @@ type HostSoftwareWithInstaller struct { ID uint `json:"id" db:"id"` Name string `json:"name" db:"name"` Source string `json:"source" db:"source"` - SelfService *bool `json:"self_service,omitempty" db:"self_service"` Status *SoftwareInstallerStatus `json:"status" db:"status"` LastInstall *HostSoftwareInstall `json:"last_install"` InstalledVersions []*HostSoftwareInstalledVersion `json:"installed_versions"` - // AvailableForInstall is true if a software installer or a VPP app is - // available to install this software. - AvailableForInstall bool `json:"available_for_install" db:"available_for_install"` - // SoftwarePackage provides software installer package information, it is // only present if a software installer is available for the software title. - SoftwarePackage *HostSoftwarePackageOrApp `json:"software_package"` + SoftwarePackage *SoftwarePackageOrApp `json:"software_package"` // AppStoreApp provides VPP app information, it is only present if a VPP app // is available for the software title. - AppStoreApp *HostSoftwarePackageOrApp `json:"app_store_app"` + AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"` } -// HostSoftwarePackageOrApp provides information about a software installer +// SoftwarePackageOrApp provides information about a software installer // package or a VPP app. -type HostSoftwarePackageOrApp struct { +type SoftwarePackageOrApp struct { // AppStoreID is only present for VPP apps. AppStoreID string `json:"app_store_id,omitempty"` // Name is only present for software installer packages. - Name string `json:"name,omitempty"` - Version string `json:"version"` + Name string `json:"name,omitempty"` + + Version string `json:"version"` + SelfService *bool `json:"self_service,omitempty"` + IconURL *string `json:"icon_url"` } // HostSoftwareInstall represents installation of software on a host from a diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index 3973cc97e7..3e76cf9f73 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -40,9 +40,8 @@ func (v *VPPApp) AuthzType() string { return "installable_entity" } -// TODO(mna): It might be possible to merge this with the VPPApp struct above, -// but since it will evolve via the other PRs implemented in parallel, I'll -// create a distinct struct and we'll see at integration time. +// VPPAppStoreApp contains the field required by the get software title +// endpoint to represent an App Store app (VPP app). type VPPAppStoreApp struct { AppStoreID string `db:"adam_id" json:"app_store_id"` Name string `db:"name" json:"name"` diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index d0c259973a..c2d6c1cc2b 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -7257,7 +7257,6 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { {Version: "0.0.1", Vulnerabilities: nil}, {Version: "0.0.3", Vulnerabilities: nil}, }, - SelfService: false, }, { Name: "bar", @@ -7267,7 +7266,6 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { Versions: []fleet.SoftwareVersion{ {Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}}, }, - SelfService: false, }, }, resp.SoftwareTitles) @@ -7773,7 +7771,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { ) require.Len(t, resp.SoftwareTitles, 1) - require.True(t, resp.SoftwareTitles[0].AvailableForInstall) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) // Upload an installer for the same software but different arch to a different team payloadRubyTm2 := &fleet.UploadSoftwareInstallerPayload{ @@ -7793,7 +7791,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { "team_id", fmt.Sprintf("%d", team1.ID), ) require.Len(t, resp.SoftwareTitles, 1) - require.True(t, resp.SoftwareTitles[0].AvailableForInstall) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) // software installer not returned with self-service only (not marked as such) resp = listSoftwareTitlesResponse{} @@ -7810,8 +7808,9 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "self_service", "1", "query", "ruby", "team_id", fmt.Sprint(team1.ID)) require.Len(t, resp.SoftwareTitles, 1) - require.True(t, resp.SoftwareTitles[0].AvailableForInstall) - require.True(t, resp.SoftwareTitles[0].SelfService) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage.SelfService) + require.True(t, *resp.SoftwareTitles[0].SoftwarePackage.SelfService) // no team but self-service returns the emacs software (technically impossible via the UI) resp = listSoftwareTitlesResponse{} @@ -7823,8 +7822,9 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { ) require.Len(t, resp.SoftwareTitles, 1) - require.True(t, resp.SoftwareTitles[0].AvailableForInstall) - require.True(t, resp.SoftwareTitles[0].SelfService) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage.SelfService) + require.True(t, *resp.SoftwareTitles[0].SoftwarePackage.SelfService) emacsPath := fmt.Sprintf("/api/latest/fleet/software/titles/%d", resp.SoftwareTitles[0].ID) respTitle := getSoftwareTitleResponse{} @@ -9147,12 +9147,8 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, getHostSw.Software[1].Name, "foo") require.Len(t, getHostSw.Software[1].InstalledVersions, 2) // no package information as there is no installer - require.Nil(t, getHostSw.Software[0].SelfService) - require.False(t, getHostSw.Software[0].AvailableForInstall) require.Nil(t, getHostSw.Software[0].SoftwarePackage) require.Nil(t, getHostSw.Software[0].AppStoreApp) - require.Nil(t, getHostSw.Software[1].SelfService) - require.False(t, getHostSw.Software[1].AvailableForInstall) require.Nil(t, getHostSw.Software[1].SoftwarePackage) require.Nil(t, getHostSw.Software[1].AppStoreApp) @@ -9167,8 +9163,6 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Len(t, getHostSw.Software, 1) require.NoError(t, err) require.Equal(t, "bar", getHostSw.Software[0].Name) - require.Nil(t, getHostSw.Software[0].SelfService) - require.False(t, getHostSw.Software[0].AvailableForInstall) require.Nil(t, getHostSw.Software[0].SoftwarePackage) require.Nil(t, getHostSw.Software[0].AppStoreApp) require.Len(t, getHostSw.Software[0].InstalledVersions, 1) @@ -9184,12 +9178,8 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, getDeviceSw.Software[1].Name, "foo") require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2) // no package information as there is no installer - require.Nil(t, getDeviceSw.Software[0].SelfService) - require.False(t, getDeviceSw.Software[0].AvailableForInstall) require.Nil(t, getDeviceSw.Software[0].SoftwarePackage) require.Nil(t, getDeviceSw.Software[0].AppStoreApp) - require.Nil(t, getDeviceSw.Software[1].SelfService) - require.False(t, getDeviceSw.Software[1].AvailableForInstall) require.Nil(t, getDeviceSw.Software[1].SoftwarePackage) require.Nil(t, getDeviceSw.Software[1].AppStoreApp) @@ -9213,18 +9203,15 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) require.Len(t, getHostSw.Software, 3) // foo, bar and ruby.deb require.Equal(t, getHostSw.Software[0].Name, "bar") - require.False(t, getHostSw.Software[0].AvailableForInstall) require.Equal(t, getHostSw.Software[1].Name, "foo") - require.False(t, getHostSw.Software[1].AvailableForInstall) require.Equal(t, getHostSw.Software[2].Name, "ruby") require.Len(t, getHostSw.Software[1].InstalledVersions, 2) - require.True(t, getHostSw.Software[2].AvailableForInstall) require.Nil(t, getHostSw.Software[2].AppStoreApp) require.NotNil(t, getHostSw.Software[2].SoftwarePackage) require.Equal(t, "ruby.deb", getHostSw.Software[2].SoftwarePackage.Name) require.Equal(t, payload.Version, getHostSw.Software[2].SoftwarePackage.Version) - require.NotNil(t, getHostSw.Software[2].SelfService) - require.True(t, *getHostSw.Software[2].SelfService) + require.NotNil(t, getHostSw.Software[2].SoftwarePackage.SelfService) + require.True(t, *getHostSw.Software[2].SoftwarePackage.SelfService) require.Nil(t, getHostSw.Software[2].Status) // only the installer is returned for self-service only @@ -9242,8 +9229,6 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, getDeviceSw.Software[0].Name, "bar") require.Equal(t, getDeviceSw.Software[1].Name, "foo") require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2) - require.False(t, getDeviceSw.Software[0].AvailableForInstall) - require.False(t, getDeviceSw.Software[1].AvailableForInstall) // but it gets returned for self-service only res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) @@ -9252,11 +9237,10 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.NoError(t, err) require.Len(t, getDeviceSw.Software, 1) require.Equal(t, getDeviceSw.Software[0].Name, "ruby") - require.True(t, getDeviceSw.Software[0].AvailableForInstall) require.Nil(t, getDeviceSw.Software[0].AppStoreApp) require.NotNil(t, getDeviceSw.Software[0].SoftwarePackage) - require.NotNil(t, getDeviceSw.Software[0].SelfService) - require.True(t, *getDeviceSw.Software[0].SelfService) + require.NotNil(t, getDeviceSw.Software[0].SoftwarePackage.SelfService) + require.True(t, *getDeviceSw.Software[0].SoftwarePackage.SelfService) require.Equal(t, payload.Filename, getDeviceSw.Software[0].SoftwarePackage.Name) require.Equal(t, payload.Version, getDeviceSw.Software[0].SoftwarePackage.Version) @@ -9273,13 +9257,12 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, getHostSw.Software[1].Name, "foo") require.Equal(t, getHostSw.Software[2].Name, "ruby") require.Len(t, getHostSw.Software[1].InstalledVersions, 2) - require.True(t, getHostSw.Software[2].AvailableForInstall) require.NotNil(t, getHostSw.Software[2].SoftwarePackage) require.Equal(t, "ruby.deb", getHostSw.Software[2].SoftwarePackage.Name) require.NotNil(t, getHostSw.Software[2].Status) require.Equal(t, fleet.SoftwareInstallerPending, *getHostSw.Software[2].Status) - require.NotNil(t, getHostSw.Software[2].SelfService) - require.True(t, *getHostSw.Software[2].SelfService) + require.NotNil(t, getHostSw.Software[2].SoftwarePackage.SelfService) + require.True(t, *getHostSw.Software[2].SoftwarePackage.SelfService) // still returned with self-service filter getHostSw = getHostSoftwareResponse{} @@ -9297,13 +9280,12 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, getDeviceSw.Software[1].Name, "foo") require.Equal(t, getDeviceSw.Software[2].Name, "ruby") require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2) - require.True(t, getDeviceSw.Software[2].AvailableForInstall) require.NotNil(t, getDeviceSw.Software[2].Status) require.Equal(t, fleet.SoftwareInstallerPending, *getDeviceSw.Software[2].Status) - require.NotNil(t, getDeviceSw.Software[2].SelfService) - require.True(t, *getDeviceSw.Software[2].SelfService) require.NotNil(t, getDeviceSw.Software[2].SoftwarePackage) require.Nil(t, getDeviceSw.Software[2].AppStoreApp) + require.NotNil(t, getDeviceSw.Software[2].SoftwarePackage.SelfService) + require.True(t, *getDeviceSw.Software[2].SoftwarePackage.SelfService) // still returned for self-service only too res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) @@ -9312,11 +9294,10 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.NoError(t, err) require.Len(t, getDeviceSw.Software, 1) require.Equal(t, getDeviceSw.Software[0].Name, "ruby") - require.NotNil(t, getDeviceSw.Software[0].SelfService) - require.True(t, *getDeviceSw.Software[0].SelfService) require.NotNil(t, getDeviceSw.Software[0].SoftwarePackage) + require.NotNil(t, getDeviceSw.Software[0].SoftwarePackage.SelfService) + require.True(t, *getDeviceSw.Software[0].SoftwarePackage.SelfService) require.Nil(t, getDeviceSw.Software[0].AppStoreApp) - require.True(t, getDeviceSw.Software[0].AvailableForInstall) // test with a query getHostSw = getHostSoftwareResponse{} @@ -9829,7 +9810,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent, "team_name", tm.Name) newTitlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) - titlesResp.SoftwareTitles[0].SelfService = true + titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true) require.Equal(t, titlesResp, newTitlesResp) // empty payload cleans the software items @@ -10854,7 +10835,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() { "team_id", fmt.Sprintf("%d", team.ID), ) require.Len(t, resp.SoftwareTitles, 1) - require.True(t, resp.SoftwareTitles[0].AvailableForInstall) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) software := []fleet.Software{ {Name: "foo", Version: "0.0.1", Source: "homebrew"}, From 87f9a9a3e79e18ae74f41db789d9f5d39566aea3 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Thu, 18 Jul 2024 15:35:26 -0700 Subject: [PATCH 21/38] feat: VPP app installation flow (#20448) > Related issue: #19868 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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] Manual QA for all new/changed functionality --------- Co-authored-by: Jahziel Villasana-Espinoza --- changes/19868-vpp-install-command | 1 + ee/server/service/software_installers.go | 153 +++++++- ee/server/service/vpp.go | 2 - server/datastore/mysql/activities.go | 64 +++- .../tables/20240701113709_VPPDBUpdates.go | 8 +- server/datastore/mysql/schema.sql | 4 +- server/datastore/mysql/software_test.go | 16 +- .../datastore/mysql/software_titles_test.go | 4 +- server/datastore/mysql/vpp.go | 140 +++++++- server/datastore/mysql/vpp_test.go | 148 +++++++- server/fleet/activities.go | 9 +- server/fleet/apple_mdm.go | 1 + server/fleet/datastore.go | 3 + server/fleet/vpp.go | 14 +- server/mdm/apple/commander.go | 26 ++ server/mdm/apple/vpp/api.go | 89 ++++- server/mdm/apple/vpp/api_test.go | 10 +- server/mock/datastore_mock.go | 37 ++ server/service/apple_mdm.go | 18 + server/service/integration_enterprise_test.go | 123 ------- server/service/integration_mdm_test.go | 332 +++++++++++++++++- 21 files changed, 1001 insertions(+), 201 deletions(-) create mode 100644 changes/19868-vpp-install-command diff --git a/changes/19868-vpp-install-command b/changes/19868-vpp-install-command new file mode 100644 index 0000000000..337b5d5010 --- /dev/null +++ b/changes/19868-vpp-install-command @@ -0,0 +1 @@ +- Adds functionality for installing App Store apps to the VPP feature. \ No newline at end of file diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index f8f4faf32f..659348f7bc 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -14,11 +14,14 @@ import ( "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/go-kit/log/level" + "github.com/google/uuid" "golang.org/x/sync/errgroup" ) @@ -121,7 +124,7 @@ func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet. if teamID != nil { t, err := svc.ds.Team(ctx, *teamID) if err != nil { - return ctxerr.Wrap(ctx, err, "getting team name for deleted vpp app") + return ctxerr.Wrap(ctx, err, "getting team name for deleted VPP app") } teamName = &t.Name } @@ -132,7 +135,7 @@ func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet. TeamName: teamName, TeamID: teamID, }); err != nil { - return ctxerr.Wrap(ctx, err, "creating activity for deleted vpp app") + return ctxerr.Wrap(ctx, err, "creating activity for deleted VPP app") } return nil @@ -262,7 +265,6 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw // fleetd is required to install software so if the host is // enrolled via plain osquery we return an error svc.authz.SkipAuthorization(ctx) - // TODO(roberto): for cleanup task, confirm with product error message. return fleet.NewUserMessageError(errors.New("Host doesn't have fleetd installed"), http.StatusUnprocessableEntity) } @@ -273,19 +275,150 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false) if err != nil { + if !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "finding software installer for title") + } + installer = nil + } + + // if we found an installer, use that + if installer != nil { + return svc.installSoftwareTitleUsingInstaller(ctx, host, installer) + } + + vppApp, err := svc.ds.GetVPPAppByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false) + if err != nil { + // if we couldn't find an installer or a VPP app, return a bad + // request error if fleet.IsNotFound(err) { return &fleet.BadRequestError{ - Message: "Software title has no package added. Please add software package to install.", + Message: "Software title has no package or VPP app added. Please add software package or VPP app to install.", InternalErr: ctxerr.WrapWithData( - ctx, err, "couldn't find an installer for software title", + ctx, err, "couldn't find an installer or VPP app for software title", map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID}, ), } } - return ctxerr.Wrap(ctx, err, "finding software installer for title") + return ctxerr.Wrap(ctx, err, "finding VPP app for title") } + return svc.installSoftwareFromVPP(ctx, host, vppApp) +} + +func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp) error { + if host.FleetPlatform() != "darwin" { + return &fleet.BadRequestError{ + Message: "VPP apps can only be installed only on macOS hosts.", + InternalErr: ctxerr.NewWithData( + ctx, "invalid host platform for requested installer", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": vppApp.TitleID}, + ), + } + } + + mdmConnected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host) + if err != nil { + return ctxerr.Wrapf(ctx, err, "checking MDM status for host %d", host.ID) + } + + if !mdmConnected { + return &fleet.BadRequestError{ + Message: "VPP apps can only be installed only on hosts enrolled in MDM.", + InternalErr: ctxerr.NewWithData( + ctx, "VPP install attempted on non-MDM host", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": vppApp.TitleID}, + ), + } + } + + token, err := svc.getVPPToken(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting VPP token") + } + + // at this moment, neither the UI or the back-end are prepared to + // handle [asyncronous errors][1] on assignment, so before assigning a + // device to a license, we need to: + // + // 1. Check if the app is already assigned to the serial number. + // 2. If it's not assigned yet, check if we have enough licenses. + // + // A race still might happen, so async error checking needs to be + // implemented anyways at some point. + // + // [1]: https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3729433 + assignments, err := vpp.GetAssignments(token, &vpp.AssignmentFilter{AdamID: vppApp.AdamID, SerialNumber: host.HardwareSerial}) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting assignments from VPP API") + } + + var eventID string + + // this app is not assigned to this device, check if we have licenses + // left and assign it. + if len(assignments) == 0 { + assets, err := vpp.GetAssets(token, &vpp.AssetFilter{AdamID: vppApp.AdamID}) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting assets from VPP API") + } + + if len(assets) == 0 { + level.Debug(svc.logger).Log( + "msg", "trying to assign VPP asset to host", + "adam_id", vppApp.AdamID, + "host_serial", host.HardwareSerial, + ) + return &fleet.BadRequestError{ + Message: "Couldn't add software. isn't available in Apple Business Manager. Please purchase license in Apple Business Manager and try again.", + InternalErr: ctxerr.Errorf(ctx, "VPP API didn't return any assets for adamID %s", vppApp.AdamID), + } + } + + if len(assets) > 1 { + return ctxerr.Errorf(ctx, "VPP API returned more than one asset for adamID %s", vppApp.AdamID) + } + + if assets[0].AvailableCount <= 0 { + return &fleet.BadRequestError{ + Message: "Couldn't install. No available licenses. Please purchase license in Apple Business Manager and try again.", + InternalErr: ctxerr.NewWithData( + ctx, "license available count <= 0", + map[string]any{ + "host_id": host.ID, + "team_id": host.TeamID, + "adam_id": vppApp.AdamID, + "count": assets[0].AvailableCount, + }, + ), + } + } + + eventID, err = vpp.AssociateAssets(token, &vpp.AssociateAssetsRequest{Assets: assets, SerialNumbers: []string{host.HardwareSerial}}) + if err != nil { + return ctxerr.Wrapf(ctx, err, "associating asset with adamID %s to host %s", vppApp.AdamID, host.HardwareSerial) + } + + } + + user := authz.UserFromContext(ctx) + + // add command to install + cmdUUID := uuid.NewString() + err = svc.mdmAppleCommander.InstallApplication(ctx, []string{host.UUID}, cmdUUID, vppApp.AdamID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "sending command to install VPP %s application to host with serial %s", vppApp.AdamID, host.HardwareSerial) + } + + err = svc.ds.InsertHostVPPSoftwareInstall(ctx, host.ID, user.ID, vppApp.AdamID, cmdUUID, eventID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "inserting host vpp software install for host with serial %s and app with adamID %s", host.HardwareSerial, vppApp.AdamID) + } + + return nil +} + +func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host *fleet.Host, installer *fleet.SoftwareInstaller) error { ext := filepath.Ext(installer.Name) requiredPlatform := packageExtensionToPlatform(ext) if requiredPlatform == "" { @@ -296,14 +429,14 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw if host.FleetPlatform() != requiredPlatform { return &fleet.BadRequestError{ Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform), - InternalErr: ctxerr.WrapWithData( - ctx, err, "invalid host platform for requested installer", - map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID}, + InternalErr: ctxerr.NewWithData( + ctx, "invalid host platform for requested installer", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID}, ), } } - _, err = svc.ds.InsertSoftwareInstallRequest(ctx, hostID, installer.InstallerID, false) + _, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, installer.InstallerID, false) return ctxerr.Wrap(ctx, err, "inserting software install request") } diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index d02f89e711..d0fb05e0b0 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -73,7 +73,6 @@ func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet app := &fleet.VPPApp{ AdamID: a.AdamID, - AvailableCount: a.AvailableCount, BundleIdentifier: m.BundleID, IconURL: m.ArtworkURL, Name: m.TrackName, @@ -151,7 +150,6 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, adamID str app := &fleet.VPPApp{ AdamID: asset.AdamID, - AvailableCount: asset.AvailableCount, BundleIdentifier: assetMD.BundleID, IconURL: assetMD.ArtworkURL, Name: assetMD.TrackName, diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index f7267c36ba..1fbc21a99a 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -236,16 +236,23 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint countStmts := []string{ `SELECT COUNT(*) c - FROM host_script_results - WHERE host_id = :host_id AND + FROM host_script_results hsr + WHERE hsr.host_id = :host_id AND exit_code IS NULL AND (sync_request = 0 OR created_at >= DATE_SUB(NOW(), INTERVAL :max_wait_time SECOND))`, `SELECT COUNT(*) c - FROM host_software_installs - WHERE host_id = :host_id AND + FROM host_software_installs hsi + WHERE hsi.host_id = :host_id AND pre_install_query_output IS NULL AND install_script_exit_code IS NULL`, + ` + SELECT + COUNT(*) c + FROM nano_view_queue nvq + JOIN host_vpp_software_installs hvsi ON nvq.command_uuid = hvsi.command_uuid + WHERE hvsi.host_id = :host_id AND nvq.status IS NULL + `, } var count uint @@ -334,6 +341,40 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint hsi.pre_install_query_output IS NULL AND hsi.install_script_exit_code IS NULL `, softwareInstallerHostStatusNamedQuery("hsi", "")), + ` +SELECT + hvsi.command_uuid AS uuid, + u.name AS name, + u.id AS user_id, + u.gravatar_url as gravatar_url, + u.email as user_email, + :installed_app_store_app_type AS activity_type, + hvsi.created_at AS created_at, + JSON_OBJECT( + 'host_id', hvsi.host_id, + 'host_display_name', hdn.display_name, + 'software_title', st.name, + 'app_store_id', hvsi.adam_id, + 'command_uuid', hvsi.command_uuid, + -- status is always pending because only pending MDM commands are upcoming. + 'status', :software_status_pending + ) AS details +FROM + host_vpp_software_installs hvsi +INNER JOIN + nano_view_queue nvq ON nvq.command_uuid = hvsi.command_uuid +LEFT OUTER JOIN + users u ON hvsi.user_id = u.id +LEFT OUTER JOIN + host_display_names hdn ON hdn.host_id = hvsi.host_id +LEFT OUTER JOIN + vpp_apps vpa ON hvsi.adam_id = vpa.adam_id +LEFT OUTER JOIN + software_titles st ON st.id = vpa.title_id +WHERE + nvq.status IS NULL + AND hvsi.host_id = :host_id +`, } listStmt := ` @@ -348,13 +389,14 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint details FROM ( ` + strings.Join(listStmts, " UNION ALL ") + ` ) AS upcoming ` listStmt, args, err = sqlx.Named(listStmt, map[string]any{ - "host_id": hostID, - "ran_script_type": fleet.ActivityTypeRanScript{}.ActivityName(), - "installed_software_type": fleet.ActivityTypeInstalledSoftware{}.ActivityName(), - "max_wait_time": seconds, - "software_status_failed": string(fleet.SoftwareInstallerFailed), - "software_status_installed": string(fleet.SoftwareInstallerInstalled), - "software_status_pending": string(fleet.SoftwareInstallerPending), + "host_id": hostID, + "ran_script_type": fleet.ActivityTypeRanScript{}.ActivityName(), + "installed_software_type": fleet.ActivityTypeInstalledSoftware{}.ActivityName(), + "installed_app_store_app_type": fleet.ActivityInstalledAppStoreApp{}.ActivityName(), + "max_wait_time": seconds, + "software_status_failed": string(fleet.SoftwareInstallerFailed), + "software_status_installed": string(fleet.SoftwareInstallerInstalled), + "software_status_pending": string(fleet.SoftwareInstallerPending), }) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args") diff --git a/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go index 293c1c6137..317bb3063e 100644 --- a/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go +++ b/server/datastore/mysql/migrations/tables/20240701113709_VPPDBUpdates.go @@ -20,9 +20,6 @@ func Up_20240701113709(tx *sql.Tx) error { CREATE TABLE vpp_apps ( adam_id VARCHAR(16) NOT NULL, - -- This is a count of how many licenses are still available for this asset - available_count INT UNSIGNED, - -- FK to the "software title" this app matches title_id int(10) unsigned DEFAULT NULL, @@ -77,7 +74,7 @@ CREATE TABLE host_vpp_software_installs ( adam_id VARCHAR(16) NOT NULL, -- This is the UUID of the MDM command issued to install the software - command_uuid VARCHAR(127), + command_uuid VARCHAR(127) NOT NULL, user_id INT(10) UNSIGNED NULL, -- This indicates whether or not this was a self-service install @@ -92,7 +89,8 @@ CREATE TABLE host_vpp_software_installs ( updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY(id), FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL, - FOREIGN KEY (adam_id) REFERENCES vpp_apps (adam_id) ON DELETE CASCADE + FOREIGN KEY (adam_id) REFERENCES vpp_apps (adam_id) ON DELETE CASCADE, + UNIQUE INDEX idx_host_vpp_software_installs_command_uuid (command_uuid) )`) if err != nil { return fmt.Errorf("failed to create table host_vpp_software_installs: %w", err) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 9cc99c0050..03fa19aa73 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -555,13 +555,14 @@ CREATE TABLE `host_vpp_software_installs` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `host_id` int(10) unsigned NOT NULL, `adam_id` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL, - `command_uuid` varchar(127) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `command_uuid` varchar(127) COLLATE utf8mb4_unicode_ci NOT NULL, `user_id` int(10) unsigned DEFAULT NULL, `self_service` tinyint(1) NOT NULL DEFAULT '0', `associated_event_id` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), + UNIQUE KEY `idx_host_vpp_software_installs_command_uuid` (`command_uuid`), KEY `user_id` (`user_id`), KEY `adam_id` (`adam_id`), CONSTRAINT `host_vpp_software_installs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, @@ -1669,7 +1670,6 @@ CREATE TABLE `users` ( /*!40101 SET character_set_client = utf8 */; CREATE TABLE `vpp_apps` ( `adam_id` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL, - `available_count` int(10) unsigned DEFAULT NULL, `title_id` int(10) unsigned DEFAULT NULL, `bundle_identifier` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `icon_url` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index 3ccb27f9be..87552bd870 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -3145,6 +3145,14 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { otherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("linux")) opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}} + user, err := ds.NewUser(ctx, &fleet.User{ + Password: []byte("p4ssw0rd.123"), + Name: "user1", + Email: "user1@example.com", + GlobalRole: ptr.String(fleet.RoleAdmin), + }) + require.NoError(t, err) + expectStatus := func(s fleet.SoftwareInstallerStatus) *fleet.SoftwareInstallerStatus { return &s } @@ -3647,18 +3655,18 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { // create an installation request for vpp1 and vpp2, leaving vpp3 as // available only - vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp1) - vpp2CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp2) + vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp1, user.ID) + vpp2CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp2, user.ID) // make vpp1 install a success, while vpp2 has its initial request as failed // and a subsequent request as pending. createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged) createVPPAppInstallResult(t, ds, host, vpp2CmdUUID, fleet.MDMAppleStatusError) time.Sleep(time.Second) // ensure a different created_at timestamp - vpp2bCmdUUID := createVPPAppInstallRequest(t, ds, host, vpp2) + vpp2bCmdUUID := createVPPAppInstallRequest(t, ds, host, vpp2, user.ID) require.NotEmpty(t, vpp2bCmdUUID) // add an install request for the team host on vpp1, should not impact // main host - vpp1TmCmdUUID := createVPPAppInstallRequest(t, ds, tmHost, vpp1) + vpp1TmCmdUUID := createVPPAppInstallRequest(t, ds, tmHost, vpp1, user.ID) require.NotEmpty(t, vpp1TmCmdUUID) expected["vpp1apps"] = fleet.HostSoftwareWithInstaller{ diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index 2e85ea854d..9dfea4fd56 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -940,8 +940,8 @@ func createVPPApp(t *testing.T, ds *Datastore, teamID *uint, name, bundle string require.NoError(t, err) ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `INSERT INTO vpp_apps (adam_id, available_count, title_id, name, bundle_identifier) VALUES (?, ?, ?, ?, ?)`, - adamID, 1, titleID, name, bundle) + _, err := q.ExecContext(ctx, `INSERT INTO vpp_apps (adam_id, title_id, name, bundle_identifier) VALUES (?, ?, ?, ?)`, + adamID, titleID, name, bundle) return err }) diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 614557559c..8c08b0c3f3 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -3,11 +3,13 @@ package mysql import ( "context" "database/sql" + "errors" "fmt" "strings" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/jmoiron/sqlx" ) @@ -190,7 +192,7 @@ WHERE func insertVPPApps(ctx context.Context, tx sqlx.ExtContext, apps []*fleet.VPPApp) error { stmt := ` INSERT INTO vpp_apps - (adam_id, available_count, bundle_identifier, icon_url, name, latest_version, title_id) + (adam_id, bundle_identifier, icon_url, name, latest_version, title_id) VALUES %s ON DUPLICATE KEY UPDATE @@ -203,8 +205,8 @@ ON DUPLICATE KEY UPDATE var insertVals strings.Builder for _, a := range apps { - insertVals.WriteString(`(?, ?, ?, ?, ?, ?, ?),`) - args = append(args, a.AdamID, a.AvailableCount, a.BundleIdentifier, a.IconURL, a.Name, a.LatestVersion, a.TitleID) + insertVals.WriteString(`(?, ?, ?, ?, ?, ?),`) + args = append(args, a.AdamID, a.BundleIdentifier, a.IconURL, a.Name, a.LatestVersion, a.TitleID) } stmt = fmt.Sprintf(stmt, strings.TrimSuffix(insertVals.String(), ",")) @@ -286,3 +288,135 @@ func (ds *Datastore) DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, ada } return nil } + +func (ds *Datastore) GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.VPPApp, error) { + stmt := ` +SELECT + va.adam_id, + va.bundle_identifier, + va.icon_url, + va.name, + va.title_id, + va.created_at, + va.updated_at +FROM vpp_apps va +JOIN vpp_apps_teams vat ON va.adam_id = vat.adam_id +WHERE vat.global_or_team_id = ? AND va.title_id = ? + ` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + var dest fleet.VPPApp + err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, tmID, titleID) + if err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("VPPApp"), "get VPP app") + } + return nil, ctxerr.Wrap(ctx, err, "get VPP app") + } + + return &dest, nil +} + +func (ds *Datastore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, adamID, commandUUID, associatedEventID string) error { + stmt := ` +INSERT INTO host_vpp_software_installs + (host_id, adam_id, command_uuid, user_id, associated_event_id) +VALUES + (?,?,?,?,?) + ` + + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, adamID, commandUUID, userID, associatedEventID); err != nil { + return ctxerr.Wrap(ctx, err, "insert into host_vpp_software_installs") + } + + return nil +} + +func (ds *Datastore) GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityInstalledAppStoreApp, error) { + if commandResults == nil { + return nil, nil, nil + } + + stmt := ` +SELECT + u.name AS user_name, + u.id AS user_id, + u.email as user_email, + hvsi.host_id AS host_id, + hdn.display_name AS host_display_name, + st.name AS software_title, + hvsi.adam_id AS app_store_id, + hvsi.command_uuid AS command_uuid +FROM + host_vpp_software_installs hvsi + LEFT OUTER JOIN users u ON hvsi.user_id = u.id + LEFT OUTER JOIN host_display_names hdn ON hdn.host_id = hvsi.host_id + LEFT OUTER JOIN vpp_apps vpa ON hvsi.adam_id = vpa.adam_id + LEFT OUTER JOIN software_titles st ON st.id = vpa.title_id +WHERE + hvsi.command_uuid = :command_uuid + ` + + type result struct { + HostID uint `db:"host_id"` + HostDisplayName string `db:"host_display_name"` + SoftwareTitle string `db:"software_title"` + AppStoreID string `db:"app_store_id"` + CommandUUID string `db:"command_uuid"` + UserName string `db:"user_name"` + UserID uint `db:"user_id"` + UserEmail string `db:"user_email"` + } + + listStmt, args, err := sqlx.Named(stmt, map[string]any{ + "command_uuid": commandResults.CommandUUID, + "software_status_failed": string(fleet.SoftwareInstallerFailed), + "software_status_installed": string(fleet.SoftwareInstallerInstalled), + }) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args") + } + + var res result + if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, listStmt, args...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, notFound("install_command") + } + + return nil, nil, ctxerr.Wrap(ctx, err, "select past activity data for VPP app install") + } + + user := &fleet.User{ + ID: res.UserID, + Name: res.UserName, + Email: res.UserEmail, + } + + var status string + switch commandResults.Status { + case fleet.MDMAppleStatusAcknowledged: + status = string(fleet.SoftwareInstallerInstalled) + case fleet.MDMAppleStatusCommandFormatError: + case fleet.MDMAppleStatusError: + status = string(fleet.SoftwareInstallerFailed) + default: + // This case shouldn't happen (we should only be doing this check if the command is in a + // "terminal" state, but adding it so we have a default + status = string(fleet.SoftwareInstallerPending) + } + + act := &fleet.ActivityInstalledAppStoreApp{ + HostID: res.HostID, + HostDisplayName: res.HostDisplayName, + SoftwareTitle: res.SoftwareTitle, + AppStoreID: res.AppStoreID, + CommandUUID: res.CommandUUID, + Status: status, + } + + return user, act, nil +} diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index 14b077d5ba..faeeba65a2 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -21,6 +22,7 @@ func TestVPP(t *testing.T) { {"VPPAppMetadata", testVPPAppMetadata}, {"VPPAppStatus", testVPPAppStatus}, {"VPPApps", testVPPApps}, + {"GetVPPAppByTeamAndTitleID", testGetVPPAppByTeamAndTitleID}, } for _, c := range cases { @@ -136,12 +138,20 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.Error(t, err) require.ErrorAs(t, err, &nfe) require.Nil(t, meta) - } func testVPPAppStatus(t *testing.T, ds *Datastore) { ctx := context.Background() + // create a user + user, err := ds.NewUser(ctx, &fleet.User{ + Password: []byte("p4ssw0rd.123"), + Name: "user1", + Email: "user1@example.com", + GlobalRole: ptr.String(fleet.RoleAdmin), + }) + require.NoError(t, err) + // create a team team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) require.NoError(t, err) @@ -203,7 +213,7 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { require.NoError(t, err) // simulate an install request of vpp1 on h1 - cmd1 := createVPPAppInstallRequest(t, ds, h1, vpp1) + cmd1 := createVPPAppInstallRequest(t, ds, h1, vpp1, user.ID) summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) require.NoError(t, err) @@ -218,10 +228,16 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { // create a new request for h1 that supercedes the failed on, and a request // for h2 with a successful result. - cmd2 := createVPPAppInstallRequest(t, ds, h1, vpp1) - cmd3 := createVPPAppInstallRequest(t, ds, h2, vpp1) + cmd2 := createVPPAppInstallRequest(t, ds, h1, vpp1, user.ID) + cmd3 := createVPPAppInstallRequest(t, ds, h2, vpp1, user.ID) createVPPAppInstallResult(t, ds, h2, cmd3, fleet.MDMAppleStatusAcknowledged) + actUser, act, err := ds.GetPastActivityDataForVPPAppInstall(ctx, &mdm.CommandResults{CommandUUID: cmd3}) + require.NoError(t, err) + require.Equal(t, user.ID, actUser.ID) + require.Equal(t, user.Name, actUser.Name) + require.Equal(t, cmd3, act.CommandUUID) + summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) require.NoError(t, err) require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 1, Failed: 0, Installed: 1}, summary) @@ -239,7 +255,7 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 0}, summary) // simulate a successful request for team app vpp2 on h3 - cmd4 := createVPPAppInstallRequest(t, ds, h3, vpp2) + cmd4 := createVPPAppInstallRequest(t, ds, h3, vpp2, user.ID) createVPPAppInstallResult(t, ds, h3, cmd4, fleet.MDMAppleStatusAcknowledged) summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, &team1.ID, vpp2) @@ -248,11 +264,11 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { // simulate a successful, failed and pending request for app vpp3 on team // (h3) and no team (h1, h2) - cmd5 := createVPPAppInstallRequest(t, ds, h3, vpp3) + cmd5 := createVPPAppInstallRequest(t, ds, h3, vpp3, user.ID) createVPPAppInstallResult(t, ds, h3, cmd5, fleet.MDMAppleStatusAcknowledged) - cmd6 := createVPPAppInstallRequest(t, ds, h1, vpp3) + cmd6 := createVPPAppInstallRequest(t, ds, h1, vpp3, user.ID) createVPPAppInstallResult(t, ds, h1, cmd6, fleet.MDMAppleStatusCommandFormatError) - createVPPAppInstallRequest(t, ds, h2, vpp3) + createVPPAppInstallRequest(t, ds, h2, vpp3, user.ID) // for no team, it sees the failed and pending counts summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp3) @@ -266,7 +282,7 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { } // simulates creating the VPP app install request on the host, returns the command UUID. -func createVPPAppInstallRequest(t *testing.T, ds *Datastore, host *fleet.Host, adamID string) string { +func createVPPAppInstallRequest(t *testing.T, ds *Datastore, host *fleet.Host, adamID string, userID uint) string { ctx := context.Background() cmdUUID := uuid.NewString() @@ -276,8 +292,8 @@ func createVPPAppInstallRequest(t *testing.T, ds *Datastore, host *fleet.Host, a require.NoError(t, err) ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid) VALUES (?, ?, ?)`, - host.ID, adamID, cmdUUID) + _, err := q.ExecContext(ctx, `INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid, user_id) VALUES (?, ?, ?, ?)`, + host.ID, adamID, cmdUUID, userID) return err }) return cmdUUID @@ -337,6 +353,43 @@ func testVPPApps(t *testing.T, ds *Datastore) { err = ds.InsertVPPAppWithTeam(ctx, appNoTeam2, nil) require.NoError(t, err) + // Check that host_vpp_software_installs works + u, err := ds.NewUser(ctx, &fleet.User{ + Password: []byte("p4ssw0rd.123"), + Name: "user1", + Email: "user1@example.com", + GlobalRole: ptr.String(fleet.RoleAdmin), + }) + require.NoError(t, err) + err = ds.InsertHostVPPSoftwareInstall(ctx, 1, u.ID, app1.AdamID, "a", "b") + require.NoError(t, err) + + err = ds.InsertHostVPPSoftwareInstall(ctx, 2, u.ID, app2.AdamID, "c", "d") + require.NoError(t, err) + + var results []struct { + HostID uint `db:"host_id"` + UserID uint `db:"user_id"` + AdamID string `db:"adam_id"` + CommandUUID string `db:"command_uuid"` + AssociatedEventID string `db:"associated_event_id"` + } + err = sqlx.SelectContext(ctx, ds.reader(ctx), &results, `SELECT host_id, user_id, adam_id, command_uuid, associated_event_id FROM host_vpp_software_installs ORDER BY adam_id`) + require.NoError(t, err) + require.Len(t, results, 2) + a1 := results[0] + a2 := results[1] + require.Equal(t, a1.HostID, uint(1)) + require.Equal(t, a1.UserID, u.ID) + require.Equal(t, a1.AdamID, app1.AdamID) + require.Equal(t, a1.CommandUUID, "a") + require.Equal(t, a1.AssociatedEventID, "b") + require.Equal(t, a2.HostID, uint(2)) + require.Equal(t, a2.UserID, u.ID) + require.Equal(t, a2.AdamID, app2.AdamID) + require.Equal(t, a2.CommandUUID, "c") + require.Equal(t, a2.AssociatedEventID, "d") + // Check that getting the assigned apps works appSet, err := ds.GetAssignedVPPApps(ctx, &team.ID) require.NoError(t, err) @@ -355,3 +408,76 @@ func testVPPApps(t *testing.T, ds *Datastore) { require.Equal(t, "foo", appTitles[0].Name) require.Equal(t, app2.Name, appTitles[1].Name) } + +func testGetVPPAppByTeamAndTitleID(t *testing.T, ds *Datastore) { + ctx := context.Background() + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) + require.NoError(t, err) + + // TODO(roberto): replace with actual datastore method(s) once we have them + createVPPApp := func(adamID string, teamID *uint) uint { + var titleID int64 + ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { + res, err := tx.ExecContext( + ctx, + "INSERT INTO software_titles (name, source, browser) VALUES (?, ?, ?)", + uuid.NewString(), uuid.NewString(), "", + ) + if err != nil { + return err + } + + titleID, _ = res.LastInsertId() + return nil + }) + + ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { + _, err = tx.ExecContext( + ctx, + "INSERT INTO vpp_apps (adam_id, title_id) VALUES (?, ?)", + adamID, + titleID, + ) + return err + }) + + ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { + var tmID uint + if teamID != nil { + tmID = *teamID + } + _, err = tx.ExecContext( + ctx, + "INSERT INTO vpp_apps_teams (adam_id, team_id, global_or_team_id) VALUES (?, ?, ?)", + adamID, + teamID, + tmID, + ) + return err + }) + + return uint(titleID) + } + + var nfe fleet.NotFoundError + + fooTitleID := createVPPApp("foo", &team.ID) + gotVPPApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, fooTitleID, true) + require.NoError(t, err) + require.Equal(t, "foo", gotVPPApp.AdamID) + require.Equal(t, fooTitleID, gotVPPApp.TitleID) + // title that doesn't exist + gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, 999, true) + require.ErrorAs(t, err, &nfe) + + // create an entry for the global team + barTitleID := createVPPApp("bar", nil) + // not found providing the team id + gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, barTitleID, true) + require.ErrorAs(t, err, &nfe) + // found for the global team + gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, nil, barTitleID, true) + require.NoError(t, err) + require.Equal(t, "bar", gotVPPApp.AdamID) + require.Equal(t, barTitleID, gotVPPApp.TitleID) +} diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 1af7e9e41f..4a22ea3d07 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -1673,11 +1673,16 @@ func (a ActivityDeletedAppStoreApp) Documentation() (activity string, details st } type ActivityInstalledAppStoreApp struct { - HostID int `json:"host_id"` + HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` SoftwareTitle string `json:"software_title"` - AppStoreID int `json:"app_store_id"` + AppStoreID string `json:"app_store_id"` CommandUUID string `json:"command_uuid"` + Status string `json:"status,omitempty"` +} + +func (a ActivityInstalledAppStoreApp) HostIDs() []uint { + return []uint{a.HostID} } func (a ActivityInstalledAppStoreApp) ActivityName() string { diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 7d8d6ebb26..e1cabf2533 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -21,6 +21,7 @@ type MDMAppleCommandIssuer interface { DeviceLock(ctx context.Context, host *Host, uuid string) (unlockPIN string, err error) EraseDevice(ctx context.Context, host *Host, uuid string) error InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error + InstallApplication(ctx context.Context, hostUUIDs []string, uuid string, adamID string) error } // MDMAppleEnrollmentType is the type for Apple MDM enrollments. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index f002479d91..305b7a9e58 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1554,6 +1554,7 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) + GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*VPPApp, error) // GetVPPAppMetadataByTeamAndTitleID returns the VPP app corresponding to the // specified team and title ids. GetVPPAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*VPPAppStoreApp, error) @@ -1588,6 +1589,8 @@ type Datastore interface { BatchInsertVPPApps(ctx context.Context, apps []*VPPApp) error GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[string]struct{}, error) InsertVPPAppWithTeam(ctx context.Context, app *VPPApp, teamID *uint) error + InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, adamID, commandUUID, associatedEventID string) error + GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*User, *ActivityInstalledAppStoreApp, error) } // MDMAppleStore wraps nanomdm's storage and adds methods to deal with diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index 3e76cf9f73..35d092d0bf 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -9,14 +9,6 @@ type VPPApp struct { // AdamID is a unique identifier assigned to each app in // the App Store, this value is managed by Apple. AdamID string `db:"adam_id" json:"app_store_id"` - // AvailableCount keeps track of how many licenses are - // available for the specific software, this value is - // managed by Apple and tracked in the DB as a helper. - // - // TODO(roberto): could we omit this and rely on API errors - // from Apple instead? seems safer unless we really need to - // display this value in the API. - AvailableCount uint `db:"available_count" json:"available_count"` // BundleIdentifier is the unique bundle identifier of the // Application. BundleIdentifier string `db:"bundle_identifier" json:"bundle_identifier"` @@ -26,10 +18,8 @@ type VPPApp struct { Name string `db:"name" json:"name"` // LatestVersion is the latest version of this app. LatestVersion string `db:"latest_version" json:"latest_version"` - // Added indicates whether or not this app has been added to Fleet. - Added bool `json:"added"` - TeamID *uint `db:"-" json:"-"` - TitleID uint `db:"title_id" json:"-"` + TeamID *uint `db:"-" json:"-"` + TitleID uint `db:"title_id" json:"-"` CreatedAt time.Time `db:"created_at" json:"-"` UpdatedAt time.Time `db:"updated_at" json:"-"` diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index fbacedfe4a..d8c172a829 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -161,6 +161,32 @@ func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, host *fleet.Host, return nil } +func (svc *MDMAppleCommander) InstallApplication(ctx context.Context, hostUUIDs []string, uuid string, adamID string) error { + raw := fmt.Sprintf(` + + + + Command + + ManagementFlags + 0 + Options + + PurchaseMethod + 1 + + RequestType + InstallApplication + iTunesStoreID + %s + + CommandUUID + %s + +`, adamID, uuid) + return svc.EnqueueCommand(ctx, hostUUIDs, raw) +} + func (svc *MDMAppleCommander) InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error { raw := fmt.Sprintf(` diff --git a/server/mdm/apple/vpp/api.go b/server/mdm/apple/vpp/api.go index 80d48808c6..2925a8e082 100644 --- a/server/mdm/apple/vpp/api.go +++ b/server/mdm/apple/vpp/api.go @@ -93,21 +93,28 @@ type AssociateAssetsRequest struct { // request parameters provided. // // https://developer.apple.com/documentation/devicemanagement/associate_assets -func AssociateAssets(token string, params *AssociateAssetsRequest) error { +func AssociateAssets(token string, params *AssociateAssetsRequest) (string, error) { var reqBody bytes.Buffer if err := json.NewEncoder(&reqBody).Encode(params); err != nil { - return fmt.Errorf("encoding params as JSON: %w", err) + return "", fmt.Errorf("encoding params as JSON: %w", err) } req, err := http.NewRequest(http.MethodPost, getBaseURL()+"/assets/associate", &reqBody) if err != nil { - return fmt.Errorf("creating request to Apple VPP endpoint: %w", err) + return "", fmt.Errorf("creating request to Apple VPP endpoint: %w", err) } - if err := do[any](req, token, nil); err != nil { - return fmt.Errorf("making request to Apple VPP endpoint: %w", err) + req.Header.Add("Content-Type", "application/json") + + var respBody struct { + EventID string `json:"eventId"` } - return nil + + if err := do(req, token, &respBody); err != nil { + return "", fmt.Errorf("making request to Apple VPP endpoint: %w", err) + } + + return respBody.EventID, nil } // AssetFilter represents the filters for querying assets. @@ -184,6 +191,76 @@ func GetAssets(token string, filter *AssetFilter) ([]Asset, error) { return bodyResp.Assets, nil } +// AssignmentFilter is a representation of the query params for the Apple "Get Assignments" +// endpoint. +// https://developer.apple.com/documentation/devicemanagement/get_assignments-o3j#query-parameters +type AssignmentFilter struct { + // The filter for the assignment product's unique identifier. + AdamID string `json:"adamId"` + // The filter for the unique identifier of assigned users in your organization. + ClientUserID string `json:"clientUserId"` + // The requested page index. + PageIndex int `json:"pageIndex"` + // The filter for the unique identifier of assigned devices in your organization. + SerialNumber string `json:"serialNumber"` + // The filter for modified assignments since the specified version identifier. + SinceVersionID string `json:"sinceVersionId"` +} + +// Assignment represents an asset assignment for a device. +// +// https://developer.apple.com/documentation/devicemanagement/assignment +type Assignment struct { + // The unique identifier for a product in the store. + AdamID string `json:"adamId"` + // PricingParam is the quality of a product in the store. + // Possible Values are `STDQ` and `PLUS` + PricingParam string `json:"pricingParam"` + // The unique identifier for a device. + SerialNumber string `json:"serialNumber"` +} + +// GetAssignments fetches the assets from Apple's VPP API with optional filters. +// +// https://developer.apple.com/documentation/devicemanagement/get_assignments-o3j +func GetAssignments(token string, filter *AssignmentFilter) ([]Assignment, error) { + baseURL := getBaseURL() + "/assignments" + reqURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("parsing base URL: %w", err) + } + + if filter != nil { + query := url.Values{} + addFilter(query, "adamId", filter.AdamID) + addFilter(query, "clientUserId", filter.ClientUserID) + addFilter(query, "serialNumber", filter.SerialNumber) + addFilter(query, "sinceVersionId", filter.SinceVersionID) + addFilter(query, "pageIndex", filter.PageIndex) + reqURL.RawQuery = query.Encode() + } + + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating request to Apple VPP endpoint: %w", err) + } + + // TODO(roberto): when we get to importing assets assigned by other + // MDMs we'll need other top-level keys in this struct, and to modify + // the return value of this function. + // + // https://developer.apple.com/documentation/devicemanagement/getassignmentsresponse + var bodyResp struct { + Assignments []Assignment `json:"assignments"` + } + + if err = do(req, token, &bodyResp); err != nil { + return nil, fmt.Errorf("retrieving assignments: %w", err) + } + + return bodyResp.Assignments, nil +} + func do[T any](req *http.Request, token string, dest *T) error { req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) resp, err := client.Do(req) diff --git a/server/mdm/apple/vpp/api_test.go b/server/mdm/apple/vpp/api_test.go index c3fda13c3d..5d265b9b19 100644 --- a/server/mdm/apple/vpp/api_test.go +++ b/server/mdm/apple/vpp/api_test.go @@ -105,7 +105,7 @@ func TestAssociateAssets(t *testing.T) { require.Equal(t, []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, reqParams.Assets) require.Equal(t, []string{"SN12345"}, reqParams.SerialNumbers) - w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"eventId": "123"}`)) }, expectedErrMsg: "", }, @@ -114,7 +114,8 @@ func TestAssociateAssets(t *testing.T) { token: "valid_token", params: &AssociateAssetsRequest{ Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, - SerialNumbers: []string{"SN12345"}}, + SerialNumbers: []string{"SN12345"}, + }, handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintln(w, `Internal Server Error`) @@ -126,7 +127,8 @@ func TestAssociateAssets(t *testing.T) { token: "valid_token", params: &AssociateAssetsRequest{ Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, - SerialNumbers: []string{"SN12345"}}, + SerialNumbers: []string{"SN12345"}, + }, handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) fmt.Fprintln(w, `{"errorInfo":{},"errorMessage":"Bad Request","errorNumber":400}`) @@ -139,7 +141,7 @@ func TestAssociateAssets(t *testing.T) { t.Run(tt.name, func(t *testing.T) { setupFakeServer(t, tt.handler) - err := AssociateAssets(tt.token, tt.params) + _, err := AssociateAssets(tt.token, tt.params) if tt.expectedErrMsg != "" { require.Error(t, err) require.Contains(t, err.Error(), tt.expectedErrMsg) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index e137130ca5..c315c06968 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -15,6 +15,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "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/nanomdm/mdm" ) var _ fleet.Datastore = (*DataStore)(nil) @@ -977,6 +978,8 @@ type GetSoftwareInstallerMetadataByIDFunc func(ctx context.Context, id uint) (*f type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type GetVPPAppByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.VPPApp, error) + type GetVPPAppMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPAppStoreApp, error) type DeleteSoftwareInstallerFunc func(ctx context.Context, id uint) error @@ -1001,6 +1004,10 @@ type GetAssignedVPPAppsFunc func(ctx context.Context, teamID *uint) (map[string] type InsertVPPAppWithTeamFunc func(ctx context.Context, app *fleet.VPPApp, teamID *uint) error +type InsertHostVPPSoftwareInstallFunc func(ctx context.Context, hostID uint, userID uint, adamID string, commandUUID string, associatedEventID string) error + +type GetPastActivityDataForVPPAppInstallFunc func(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityInstalledAppStoreApp, error) + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -2439,6 +2446,9 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetVPPAppByTeamAndTitleIDFunc GetVPPAppByTeamAndTitleIDFunc + GetVPPAppByTeamAndTitleIDFuncInvoked bool + GetVPPAppMetadataByTeamAndTitleIDFunc GetVPPAppMetadataByTeamAndTitleIDFunc GetVPPAppMetadataByTeamAndTitleIDFuncInvoked bool @@ -2475,6 +2485,12 @@ type DataStore struct { InsertVPPAppWithTeamFunc InsertVPPAppWithTeamFunc InsertVPPAppWithTeamFuncInvoked bool + InsertHostVPPSoftwareInstallFunc InsertHostVPPSoftwareInstallFunc + InsertHostVPPSoftwareInstallFuncInvoked bool + + GetPastActivityDataForVPPAppInstallFunc GetPastActivityDataForVPPAppInstallFunc + GetPastActivityDataForVPPAppInstallFuncInvoked bool + mu sync.Mutex } @@ -5831,6 +5847,13 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.VPPApp, error) { + s.mu.Lock() + s.GetVPPAppByTeamAndTitleIDFuncInvoked = true + s.mu.Unlock() + return s.GetVPPAppByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) +} + func (s *DataStore) GetVPPAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPAppStoreApp, error) { s.mu.Lock() s.GetVPPAppMetadataByTeamAndTitleIDFuncInvoked = true @@ -5914,3 +5937,17 @@ func (s *DataStore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, s.mu.Unlock() return s.InsertVPPAppWithTeamFunc(ctx, app, teamID) } + +func (s *DataStore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID uint, userID uint, adamID string, commandUUID string, associatedEventID string) error { + s.mu.Lock() + s.InsertHostVPPSoftwareInstallFuncInvoked = true + s.mu.Unlock() + return s.InsertHostVPPSoftwareInstallFunc(ctx, hostID, userID, adamID, commandUUID, associatedEventID) +} + +func (s *DataStore) GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityInstalledAppStoreApp, error) { + s.mu.Lock() + s.GetPastActivityDataForVPPAppInstallFuncInvoked = true + s.mu.Unlock() + return s.GetPastActivityDataForVPPAppInstallFunc(ctx, commandResults) +} diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index e8db6fe331..8aac6cfac2 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -2808,7 +2808,25 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ detail := fmt.Sprintf("%s. Make sure the host is on macOS 13 or higher.", apple_mdm.FmtErrorChain(cmdResult.ErrorChain)) err := svc.ds.MDMAppleSetPendingDeclarationsAs(r.Context, cmdResult.UDID, status, detail) return nil, ctxerr.Wrap(r.Context, err, "update declaration status on DeclarativeManagement ack") + case "InstallApplication": + // Create an activity for installing only if we're in a terminal state + if cmdResult.Status == fleet.MDMAppleStatusAcknowledged || + cmdResult.Status == fleet.MDMAppleStatusError || + cmdResult.Status == fleet.MDMAppleStatusCommandFormatError { + user, act, err := svc.ds.GetPastActivityDataForVPPAppInstall(r.Context, cmdResult) + if err != nil { + if fleet.IsNotFound(err) { + // Then this isn't a VPP install, so no activity generated + return nil, nil + } + return nil, ctxerr.Wrap(r.Context, err, "fetching data for installed app store app activity") + } + + if err := newActivity(r.Context, user, act, svc.ds, svc.logger); err != nil { + return nil, ctxerr.Wrap(r.Context, err, "creating activity for installed app store app") + } + } } return nil, nil diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index c2d6c1cc2b..b6f97c90db 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -10671,129 +10671,6 @@ func (s *integrationEnterpriseTestSuite) TestAutofillPoliciesAuthTeamUser() { } } -func (s *integrationMDMTestSuite) TestVPPApps() { - t := s.T() - // Invalid token - t.Setenv("FLEET_DEV_VPP_URL", 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 - t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?serverError") - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "Apple VPP endpoint returned error: Internal server error (error number: 9603)") - - // 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) - t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL) - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") - - s.lastActivityMatches(fleet.ActivityEnabledVPP{}.ActivityName(), "", 0) - - // 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) - - // Create a team - var newTeamResp teamResponse - s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp) - team := newTeamResp.Team - - // Get list of VPP apps from "Apple" - // We're passing team 1 here, but we haven't added any app store apps to that team, so we get - // back all available apps in our VPP location. - var appResp getAppStoreAppsResponse - s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", strconv.Itoa(int(team.ID))) - require.NoError(t, appResp.Err) - require.Len(t, appResp.AppStoreApps, 2) - require.Equal(t, "App 1", appResp.AppStoreApps[0].Name) - require.Equal(t, "a-1", appResp.AppStoreApps[0].BundleIdentifier) - require.Equal(t, uint(12), appResp.AppStoreApps[0].AvailableCount) - require.Equal(t, "https://example.com/images/1", appResp.AppStoreApps[0].IconURL) - require.Equal(t, "1", appResp.AppStoreApps[0].AdamID) - require.Equal(t, "1.0.0", appResp.AppStoreApps[0].LatestVersion) - require.False(t, appResp.AppStoreApps[0].Added) - - require.Equal(t, "App 2", appResp.AppStoreApps[1].Name) - require.Equal(t, "b-2", appResp.AppStoreApps[1].BundleIdentifier) - require.Equal(t, uint(3), appResp.AppStoreApps[1].AvailableCount) - require.Equal(t, "https://example.com/images/2", appResp.AppStoreApps[1].IconURL) - require.Equal(t, "2", appResp.AppStoreApps[1].AdamID) - require.Equal(t, "2.0.0", appResp.AppStoreApps[1].LatestVersion) - require.False(t, appResp.AppStoreApps[1].Added) - - // Add an app store app to team 1 - addedApp := appResp.AppStoreApps[0] - var addAppResp addAppStoreAppResponse - s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID}, http.StatusOK, &addAppResp) - s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, addedApp.Name, addedApp.AdamID, team.ID), 0) - - // Add an app store app to non-existent team - s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: ptr.Uint(9999), AppStoreID: addedApp.AdamID}, http.StatusNotFound, &addAppResp) - - // Add an installer - // Verify that we are not able to add the VPP app for that same app whose installer we just added - - // Now we should be filtering out the app we added to team 1 - appResp = getAppStoreAppsResponse{} - s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", strconv.Itoa(int(team.ID))) - require.NoError(t, appResp.Err) - require.Len(t, appResp.AppStoreApps, 1) - require.Equal(t, "App 2", appResp.AppStoreApps[0].Name) - require.Equal(t, "b-2", appResp.AppStoreApps[0].BundleIdentifier) - require.Equal(t, uint(3), appResp.AppStoreApps[0].AvailableCount) - require.Equal(t, "https://example.com/images/2", appResp.AppStoreApps[0].IconURL) - require.Equal(t, "2", appResp.AppStoreApps[0].AdamID) - require.Equal(t, "2.0.0", appResp.AppStoreApps[0].LatestVersion) - require.False(t, appResp.AppStoreApps[0].Added) - - // list the software titles for that team, to get the title id of the VPP app - var listSw listSoftwareTitlesResponse - s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), "available_for_install", "true") - require.Len(t, listSw.SoftwareTitles, 1) - titleID := listSw.SoftwareTitles[0].ID - - // delete the app store app for team 1 - s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprint(team.ID)) - s.lastActivityMatches(fleet.ActivityDeletedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, addedApp.Name, addedApp.AdamID, team.ID), 0) - - // deleting it again fails, not found - s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprint(team.ID)) - - // get the list of available apps, returns both apps now - appResp = getAppStoreAppsResponse{} - s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", nil, http.StatusOK, &appResp, "team_id", fmt.Sprint(team.ID)) - require.NoError(t, appResp.Err) - require.Len(t, appResp.AppStoreApps, 2) - require.Equal(t, "App 1", appResp.AppStoreApps[0].Name) - require.Equal(t, "App 2", appResp.AppStoreApps[1].Name) - - // Delete VPP token 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) - s.lastActivityMatches(fleet.ActivityDisabledVPP{}.ActivityName(), "", 0) -} - // 1. software title uploaded doesn't match existing title // 2. host reports software with the same bundle identifier // 3. reconciler runs, doesn't create a new title diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index b5df5e9c60..177794d08c 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -44,6 +44,7 @@ import ( servermdm "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/apple/vpp" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" @@ -94,11 +95,18 @@ type integrationMDMTestSuite struct { mdmCommander *apple_mdm.MDMAppleCommander logger kitlog.Logger scepChallenge string + appleVPPConfigSrvConfig *appleVPPConfigSrvConf appleVPPConfigSrv *httptest.Server appleITunesSrv *httptest.Server mockedDownloadFleetdmMeta fleetdbase.Metadata } +// appleVPPConfigSrvConf is used to configure the mock server that mocks Apple's VPP endpoints. +type appleVPPConfigSrvConf struct { + Assets []vpp.Asset + SerialNumbers []string +} + func (s *integrationMDMTestSuite) SetupSuite() { s.withDS.SetupSuite("integrationMDMTestSuite") @@ -313,14 +321,143 @@ func (s *integrationMDMTestSuite) SetupSuite() { _, _ = w.Write(resp) })) + if s.appleVPPConfigSrvConfig == nil { + s.appleVPPConfigSrvConfig = &appleVPPConfigSrvConf{ + Assets: []vpp.Asset{ + { + AdamID: "1", + PricingParam: "STDQ", + AvailableCount: 12, + }, + { + AdamID: "2", + PricingParam: "STDQ", + AvailableCount: 3, + }, + }, + SerialNumbers: []string{"123", "456"}, + } + } + s.appleVPPConfigSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "assets") { - // Then we're responding to GetAssets - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"assets": [{"adamId": "1", "pricingParam": "STDQ", "availableCount": 12}, {"adamId": "2", "pricingParam": "STDQ", "availableCount": 3}]}`)) + // Handle /associate + if strings.Contains(r.URL.Path, "associate") { + var associations vpp.AssociateAssetsRequest + + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&associations); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + fmt.Printf("Mock VPP Server: Trying to associate %v with %v\n", associations.SerialNumbers, associations.Assets) + + if len(associations.Assets) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + res := vpp.ErrorResponse{ + ErrorNumber: 9718, + ErrorMessage: "This request doesn't contain an asset, which is a required argument. Change the request to provide an asset.", + } + if err := json.NewEncoder(w).Encode(res); err != nil { + panic(err) + } + return + } + + if len(associations.SerialNumbers) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + res := vpp.ErrorResponse{ + ErrorNumber: 9719, + ErrorMessage: "Either clientUserIds or serialNumbers are required arguments. Change the request to provide assignable users and devices.", + } + if err := json.NewEncoder(w).Encode(res); err != nil { + panic(err) + } + return + } + + var badAssets []vpp.Asset + for _, reqAsset := range associations.Assets { + var found bool + for _, goodAsset := range s.appleVPPConfigSrvConfig.Assets { + if reqAsset == goodAsset { + found = true + } + } + if !found { + badAssets = append(badAssets, reqAsset) + } + } + + var badSerials []string + for _, reqSerial := range associations.SerialNumbers { + var found bool + for _, goodSerial := range s.appleVPPConfigSrvConfig.SerialNumbers { + if reqSerial == goodSerial { + found = true + } + } + if !found { + badSerials = append(badSerials, reqSerial) + } + } + + if len(badAssets) != 0 || len(badSerials) != 0 { + errMsg := "error associating assets." + if len(badAssets) > 0 { + var badAdamIds []string + for _, asset := range badAssets { + badAdamIds = append(badAdamIds, asset.AdamID) + } + errMsg += fmt.Sprintf(" assets don't exist on account: %s.", strings.Join(badAdamIds, ", ")) + } + if len(badSerials) > 0 { + errMsg += fmt.Sprintf(" bad serials: %s.", strings.Join(badSerials, ", ")) + } + res := vpp.ErrorResponse{ + ErrorInfo: vpp.ResponseErrorInfo{ + Assets: badAssets, + ClientUserIds: []string{"something"}, + SerialNumbers: badSerials, + }, + // Not sure what error should be returned on each + // error type + ErrorNumber: 1, + ErrorMessage: errMsg, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(res); err != nil { + panic(err) + } + } + + _, _ = w.Write([]byte(`{"eventId": "123-345"}`)) return } + // Handle /assets + if strings.Contains(r.URL.Path, "assets") { + w.Header().Set("Content-Type", "application/json") + assets := s.appleVPPConfigSrvConfig.Assets + if adamID := r.URL.Query().Get("adamId"); adamID != "" { + for _, a := range assets { + if a.AdamID == adamID { + assets = []vpp.Asset{a} + } + } + } + encoder := json.NewEncoder(w) + err := encoder.Encode(map[string][]vpp.Asset{"assets": assets}) + if err != nil { + panic(err) + } + return + } + + // Handle /client/config 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 @@ -9251,3 +9388,190 @@ func (s *integrationMDMTestSuite) TestConnectedToFleetWithoutCheckout() { require.NotNil(t, hostResp.Host.MDM.ConnectedToFleet) require.False(t, *hostResp.Host.MDM.ConnectedToFleet) } + +func (s *integrationMDMTestSuite) TestVPPApps() { + t := s.T() + // Invalid token + t.Setenv("FLEET_DEV_VPP_URL", 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 + t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?serverError") + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "Apple VPP endpoint returned error: Internal server error (error number: 9603)") + + // 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) + t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL) + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") + + s.lastActivityMatches(fleet.ActivityEnabledVPP{}.ActivityName(), "", 0) + + // 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) + + // Create a team + var newTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp) + team := newTeamResp.Team + + // Get list of VPP apps from "Apple" + // We're passing team 1 here, but we haven't added any app store apps to that team, so we get + // back all available apps in our VPP location. + var appResp getAppStoreAppsResponse + s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", strconv.Itoa(int(team.ID))) + require.NoError(t, appResp.Err) + require.Len(t, appResp.AppStoreApps, 2) + require.Equal(t, "App 1", appResp.AppStoreApps[0].Name) + require.Equal(t, "a-1", appResp.AppStoreApps[0].BundleIdentifier) + require.Equal(t, "https://example.com/images/1", appResp.AppStoreApps[0].IconURL) + require.Equal(t, "1", appResp.AppStoreApps[0].AdamID) + require.Equal(t, "1.0.0", appResp.AppStoreApps[0].LatestVersion) + + require.Equal(t, "App 2", appResp.AppStoreApps[1].Name) + require.Equal(t, "b-2", appResp.AppStoreApps[1].BundleIdentifier) + require.Equal(t, "https://example.com/images/2", appResp.AppStoreApps[1].IconURL) + require.Equal(t, "2", appResp.AppStoreApps[1].AdamID) + require.Equal(t, "2.0.0", appResp.AppStoreApps[1].LatestVersion) + + // Add an app store app to team 1 + addedApp := appResp.AppStoreApps[0] + var addAppResp addAppStoreAppResponse + // Add an app store app to non-existent team + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: ptr.Uint(9999), AppStoreID: addedApp.AdamID}, http.StatusNotFound, &addAppResp) + + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID}, http.StatusOK, &addAppResp) + s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, addedApp.Name, addedApp.AdamID, team.ID), 0) + + // Now we should be filtering out the app we added to team 1 + appResp = getAppStoreAppsResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", strconv.Itoa(int(team.ID))) + require.NoError(t, appResp.Err) + require.Len(t, appResp.AppStoreApps, 1) + require.Equal(t, "App 2", appResp.AppStoreApps[0].Name) + require.Equal(t, "b-2", appResp.AppStoreApps[0].BundleIdentifier) + require.Equal(t, "https://example.com/images/2", appResp.AppStoreApps[0].IconURL) + require.Equal(t, "2", appResp.AppStoreApps[0].AdamID) + require.Equal(t, "2.0.0", appResp.AppStoreApps[0].LatestVersion) + + // list the software titles for that team, to get the title id of the VPP app + var listSw listSoftwareTitlesResponse + s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), "available_for_install", "true") + require.Len(t, listSw.SoftwareTitles, 1) + titleID := listSw.SoftwareTitles[0].ID + + // delete the app store app for team 1 + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprint(team.ID)) + s.lastActivityMatches(fleet.ActivityDeletedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, addedApp.Name, addedApp.AdamID, team.ID), 0) + + // deleting it again fails, not found + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprint(team.ID)) + + // get the list of available apps, returns both apps now + appResp = getAppStoreAppsResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", nil, http.StatusOK, &appResp, "team_id", fmt.Sprint(team.ID)) + require.NoError(t, appResp.Err) + require.Len(t, appResp.AppStoreApps, 2) + require.Equal(t, "App 1", appResp.AppStoreApps[0].Name) + require.Equal(t, "App 2", appResp.AppStoreApps[1].Name) + + // Installation flow + + // Create a couple of hosts + orbitHost := createOrbitEnrolledHost(t, "darwin", "nonmdm", s.ds) + mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) + setOrbitEnrollment(t, mdmHost, s.ds) + // Add serial number to our fake Apple server + s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost.HardwareSerial) + s.Do("POST", "/api/latest/fleet/hosts/transfer", &addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID, orbitHost.ID}, TeamID: &team.ID}, http.StatusOK) + + // Add both apps to the team + addedApp = appResp.AppStoreApps[0] + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID}, http.StatusOK, &addAppResp) + s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, addedApp.Name, addedApp.AdamID, team.ID), 0) + + errApp := appResp.AppStoreApps[1] + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: errApp.AdamID}, http.StatusOK, &addAppResp) + s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, errApp.Name, errApp.AdamID, team.ID), 0) + + listSw = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), "available_for_install", "true") + require.Len(t, listSw.SoftwareTitles, 2) + titleID = listSw.SoftwareTitles[0].ID + errTitleID := listSw.SoftwareTitles[1].ID + + // attempt to install a VPP app on the non-MDM enrolled host + + installResp := installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", orbitHost.ID, titleID), &installSoftwareRequest{}, http.StatusBadRequest, &installResp) + + // Trigger install to the host + installResp = installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, errTitleID), &installSoftwareRequest{}, http.StatusAccepted, &installResp) + + // Simulate failed installation on the host + cmd, err := mdmDevice.Idle() + var cmdUUID string + require.NoError(t, err) + for cmd != nil { + var fullCmd micromdm.CommandPayload + switch cmd.Command.RequestType { + case "InstallApplication": + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + cmdUUID = cmd.CommandUUID + cmd, err = mdmDevice.Err(cmd.CommandUUID, []mdm.ErrorChain{{ErrorCode: 1234}}) + require.NoError(t, err) + } + } + + s.lastActivityMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s"}`, mdmHost.ID, mdmHost.DisplayName(), errApp.Name, errApp.AdamID, cmdUUID, fleet.SoftwareInstallerFailed), 0) + + // Trigger install to the host + installResp = installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, titleID), &installSoftwareRequest{}, http.StatusAccepted, &installResp) + + // Simulate successful installation on the host + cmd, err = mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + var fullCmd micromdm.CommandPayload + switch cmd.Command.RequestType { + case "InstallApplication": + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + cmdUUID = cmd.CommandUUID + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + } + + s.lastActivityMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s"}`, mdmHost.ID, mdmHost.DisplayName(), addedApp.Name, addedApp.AdamID, cmdUUID, fleet.SoftwareInstallerInstalled), 0) + + // Delete VPP token 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) + s.lastActivityMatches(fleet.ActivityDisabledVPP{}.ActivityName(), "", 0) +} From f5296ab4002223fea0d5f0c51289c4ca214ca6d7 Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Thu, 18 Jul 2024 17:39:19 -0500 Subject: [PATCH 22/38] Fix unreleased issues in VPP feature branch (#20590) --- docs/Using Fleet/Audit-logs.md | 4 ++-- .../cards/ActivityFeed/ActivityItem/ActivityItem.tsx | 4 ++-- .../SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx | 8 +++++++- .../cards/Vpp/VppSetupPage/VppSetupPage.tsx | 5 ++++- .../components/DisableVppModal/DisableVppModal.tsx | 8 ++++++-- frontend/utilities/endpoints.ts | 2 +- server/fleet/activities.go | 4 ++-- 7 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index cbffea6455..b547f659d8 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -1174,13 +1174,13 @@ This activity contains the following fields: } ``` -## vpp_enabled +## enabled_vpp Generated when the VPP feature is enabled in Fleet. -## vpp_disabled +## disabled_vpp Generated when the VPP feature is disabled in Fleet. diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 9919fe65f3..d4f4d87ef6 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -833,7 +833,7 @@ const TAGGED_TEMPLATES = { <> {" "} added {activity.details?.software_title} ( - {activity.details?.software_package}) software to{" "} + {activity.details?.software_package}) to{" "} {activity.details?.team_name ? ( <> {" "} @@ -850,7 +850,7 @@ const TAGGED_TEMPLATES = { <> {" "} deleted {activity.details?.software_title} ( - {activity.details?.software_package}) software from{" "} + {activity.details?.software_package}) from{" "} {activity.details?.team_name ? ( <> {" "} diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx index 0d226efb90..54b3b6f0ad 100644 --- a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx @@ -130,7 +130,13 @@ const AppStoreVpp = ({ teamId, router, onExit }: IAppStoreVppProps) => { }); router.push(`${PATHS.SOFTWARE}?${queryParams}`); } catch (e) { - renderFlash("error", getErrorReason(e)); + const reason = getErrorReason(e); + // TODO: update with pre-defined error messages we want to pass through from the API + if (reason.toLowerCase().includes("already")) { + renderFlash("error", reason); + } else { + renderFlash("error", "Couldn’t add software. Please try again."); + } } onExit(); }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx index 0d0ce44069..a4289664e6 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage/VppSetupPage.tsx @@ -172,7 +172,10 @@ const VppSetupPage = ({ router }: IVppSetupPageProps) => { <>{renderContent()} {showDisableModal && ( - setShowDisableModal(false)} /> + setShowDisableModal(false)} + /> )} {showRenewModal && ( void; } -const DisableVppModal = ({ onExit }: IDisableVppModalProps) => { +const DisableVppModal = ({ router, onExit }: IDisableVppModalProps) => { const { renderFlash } = useContext(NotificationContext); const [isDisabling, setIsDisabling] = useState(false); const onDisableVpp = async () => { - // TODO: API integration + setIsDisabling(true); try { await mdmAppleAPI.disableVpp(); renderFlash( "success", "Volume Purchasing Program (VPP) disabled successfully." ); + router.push(paths.ADMIN_INTEGRATIONS_VPP); } catch { renderFlash( "error", diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index cc2d46ca16..235029bf70 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -155,7 +155,7 @@ export default { SOFTWARE_PACKAGE_INSTALL: (id: number) => `/${API_VERSION}/fleet/software/packages/${id}`, SOFTWARE_AVAILABLE_FOR_INSTALL: (id: number) => - `/${API_VERSION}/fleet/software/${id}/available_for_install`, + `/${API_VERSION}/fleet/software/titles/${id}/available_for_install`, // AI endpoints AUTOFILL_POLICY: `/${API_VERSION}/fleet/autofill/policy`, diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 4a22ea3d07..e2794d957b 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -1607,7 +1607,7 @@ func LogRoleChangeActivities( type ActivityEnabledVPP struct{} func (a ActivityEnabledVPP) ActivityName() string { - return "vpp_enabled" + return "enabled_vpp" } func (a ActivityEnabledVPP) Documentation() (activity string, details string, detailsExample string) { @@ -1617,7 +1617,7 @@ func (a ActivityEnabledVPP) Documentation() (activity string, details string, de type ActivityDisabledVPP struct{} func (a ActivityDisabledVPP) ActivityName() string { - return "vpp_disabled" + return "disabled_vpp" } func (a ActivityDisabledVPP) Documentation() (activity string, details string, detailsExample string) { From 97cfaebe3a90be91260fa83e06c1fa1ea343c1f4 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Thu, 18 Jul 2024 15:54:34 -0700 Subject: [PATCH 23/38] add retries to iTunes API (#20602) # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- server/mdm/apple/itunes/api.go | 11 +++++ server/mdm/apple/itunes/api_test.go | 69 +++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/server/mdm/apple/itunes/api.go b/server/mdm/apple/itunes/api.go index fafac6a82f..f81fe878eb 100644 --- a/server/mdm/apple/itunes/api.go +++ b/server/mdm/apple/itunes/api.go @@ -12,6 +12,7 @@ import ( "time" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/pkg/retry" ) type AssetMetadata struct { @@ -86,6 +87,16 @@ func do[T any](req *http.Request, dest *T) error { if len(limitedBody) > 1000 { limitedBody = limitedBody[:1000] } + + if resp.StatusCode >= http.StatusInternalServerError { + return retry.Do( + func() error { return do(req, dest) }, + retry.WithInterval(1*time.Second), + retry.WithMaxAttempts(4), + ) + + } + return fmt.Errorf("calling Apple iTunes endpoint failed with status %d: %s", resp.StatusCode, string(limitedBody)) } diff --git a/server/mdm/apple/itunes/api_test.go b/server/mdm/apple/itunes/api_test.go index a43031fcea..bec950ff6f 100644 --- a/server/mdm/apple/itunes/api_test.go +++ b/server/mdm/apple/itunes/api_test.go @@ -1,8 +1,11 @@ package itunes import ( + "net/http" + "net/http/httptest" "os" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -19,3 +22,69 @@ func TestGetBaseURL(t *testing.T) { require.Equal(t, customURL, getBaseURL()) }) } + +func setupFakeServer(t *testing.T, handler http.HandlerFunc) { + server := httptest.NewServer(handler) + os.Setenv("FLEET_DEV_ITUNES_URL", server.URL) + t.Cleanup(server.Close) +} + +func TestDoRetries(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantCalls int + wantErr bool + }{ + { + name: "success status code", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }, + wantCalls: 1, + wantErr: true, + }, + { + name: "bad requests", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }, + wantCalls: 1, + wantErr: true, + }, + { + name: "500 requests retries", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + }, + wantCalls: 4, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls int + setupFakeServer(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls < tt.wantCalls { + tt.handler(w, r) + return + } + }) + + start := time.Now() + req, err := http.NewRequest(http.MethodGet, os.Getenv("FLEET_DEV_ITUNES_URL"), nil) + require.NoError(t, err) + err = do[any](req, nil) + require.NoError(t, err) + require.Equal(t, tt.wantCalls, calls) + require.WithinRange(t, time.Now(), start, start.Add(time.Duration(tt.wantCalls)*time.Second)) + }) + } +} From b27b63bc3bb0e98d317830231040382ef2e284c2 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Fri, 19 Jul 2024 17:10:28 +0100 Subject: [PATCH 24/38] Feat UI vpp host details page (#20611) relates to #20612 This is the UI updates for the host details and device user pages for the new VPP software feature. - [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] Added/updated tests - [x] Manual QA for all new/changed functionality --- ...es-host-software-device-user-pages-for-vpp | 1 + frontend/__mocks__/deviceUserMock.ts | 8 ++-- frontend/__mocks__/hostMock.ts | 36 +++++++++++++-- frontend/interfaces/software.ts | 29 +++++++----- .../icons/SoftwareIcon/_styles.scss | 4 ++ .../Software/HostSoftwareTableConfig.tsx | 12 ++--- .../InstallStatusCell/InstallStatusCell.tsx | 45 ++++++++++++------- .../SelfServiceItem/SelfServiceItem.tsx | 2 +- .../SelfService/SelfServiceItem/_styles.scss | 2 - frontend/services/entities/software.ts | 6 --- 10 files changed, 97 insertions(+), 48 deletions(-) create mode 100644 changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp diff --git a/changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp b/changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp new file mode 100644 index 0000000000..01e6073b2d --- /dev/null +++ b/changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp @@ -0,0 +1 @@ +- add UI updates for VPP feature on host software and my device pages. diff --git a/frontend/__mocks__/deviceUserMock.ts b/frontend/__mocks__/deviceUserMock.ts index 38017864fd..3114621257 100644 --- a/frontend/__mocks__/deviceUserMock.ts +++ b/frontend/__mocks__/deviceUserMock.ts @@ -1,6 +1,7 @@ import { IDeviceUser } from "interfaces/host"; import { IDeviceSoftware } from "interfaces/software"; import { IGetDeviceSoftwareResponse } from "services/entities/device_user"; +import { createMockHostSoftwarePackage } from "./hostMock"; const DEFAULT_DEVICE_USER_MOCK: IDeviceUser = { email: "test@test.com", @@ -16,16 +17,13 @@ const createMockDeviceUser = ( const DEFAULT_DEVICE_SOFTWARE_MOCK: IDeviceSoftware = { id: 1, name: "mock software 1.app", - self_service: false, source: "apps", bundle_identifier: "com.app.mock", status: null, last_install: null, installed_versions: null, - package: { - name: "mock software 1", - version: "1.0.0", - }, + software_package: createMockHostSoftwarePackage(), + app_store_app: null, }; export const createMockDeviceSoftware = ( diff --git a/frontend/__mocks__/hostMock.ts b/frontend/__mocks__/hostMock.ts index 4c67aebe8f..a9c5b65f0d 100644 --- a/frontend/__mocks__/hostMock.ts +++ b/frontend/__mocks__/hostMock.ts @@ -5,7 +5,11 @@ import { pick } from "lodash"; import { normalizeEmptyValues } from "utilities/helpers"; import { HOST_SUMMARY_DATA } from "utilities/constants"; import { IGetHostSoftwareResponse } from "services/entities/hosts"; -import { IHostSoftware } from "interfaces/software"; +import { + IHostAppStoreApp, + IHostSoftware, + IHostSoftwarePackage, +} from "interfaces/software"; const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = { profile_uuid: "123-abc", @@ -136,11 +140,37 @@ export const createMockHostSummary = (overrides?: Partial) => { ); }; +const DEFAULT_HOST_SOFTWARE_PACKAGE_MOCK: IHostSoftwarePackage = { + name: "mock software.app", + version: "1.0.0", + self_service: false, + icon_url: "https://example.com/icon.png", +}; + +export const createMockHostSoftwarePackage = ( + overrides?: Partial +): IHostSoftwarePackage => { + return { ...DEFAULT_HOST_SOFTWARE_PACKAGE_MOCK, ...overrides }; +}; + +const DEFAULT_HOST_APP_STORE_APP_MOCK: IHostAppStoreApp = { + app_store_id: "123456789", + version: "1.0.0", + self_service: false, + icon_url: "https://via.placeholder.com/512", +}; + +export const createMockHostAppStoreApp = ( + overrides?: Partial +): IHostAppStoreApp => { + return { ...DEFAULT_HOST_APP_STORE_APP_MOCK, ...overrides }; +}; + const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = { id: 1, name: "mock software.app", - package_available_for_install: "mockSoftware.app", - self_service: false, + software_package: createMockHostSoftwarePackage(), + app_store_app: null, source: "apps", bundle_identifier: "com.test.mock", status: "installed", diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index b5bdeb3095..447ec6f81b 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -242,11 +242,25 @@ export interface ISoftwareInstallVersion { installed_paths: string[]; } +export interface IHostSoftwarePackage { + name: string; + self_service: boolean; + icon_url: string; + version: string; +} + +export interface IHostAppStoreApp { + app_store_id: string; + self_service: boolean; + icon_url: string; + version: string; +} + export interface IHostSoftware { id: number; name: string; - package_available_for_install?: string | null; - self_service: boolean; + software_package: IHostSoftwarePackage | null; + app_store_app: IHostAppStoreApp | null; source: string; bundle_identifier?: string; status: SoftwareInstallStatus | null; @@ -254,15 +268,8 @@ export interface IHostSoftware { installed_versions: ISoftwareInstallVersion[] | null; } -export type IDeviceSoftware = Omit< - IHostSoftware, - "package_available_for_install" -> & { - package: { - name: string; - version: string; - }; -}; +export type IDeviceSoftware = IHostSoftware; + const INSTALL_STATUS_PREDICATES: Record = { failed: "failed to install", installed: "installed", diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss index ee0b9e5412..a94db1daa5 100644 --- a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss +++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss @@ -10,6 +10,10 @@ border-radius: $border-radius-xlarge; } + &__large { + border-radius: $border-radius-xxlarge; + } + &__xlarge { border-radius: $border-radius-xxlarge; } diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index 1e8cd5fbb8..0d54533b37 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -53,14 +53,14 @@ const generateActions = ({ isFleetdHost, softwareId, status, - packageToInstall, + hasSoftwareToInstall, }: { canInstall: boolean; installingSoftwareId: number | null; isFleetdHost: boolean; softwareId: number; status: SoftwareInstallStatus | null; - packageToInstall?: string | null; + hasSoftwareToInstall?: boolean; }) => { // this gives us a clean slate of the default actions so we can modify // the options. @@ -74,7 +74,7 @@ const generateActions = ({ } // remove install if there is no package to install - if (!packageToInstall || !canInstall) { + if (!hasSoftwareToInstall || !canInstall) { actions.splice(indexInstallAction, 1); return actions; } @@ -188,8 +188,10 @@ export const generateSoftwareTableHeaders = ({ const { id: softwareId, status, - package_available_for_install: packageToInstall, + software_package, + app_store_app, } = original; + return ( onSelectAction(original, action)} /> diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx index 8dfceee721..e35980904c 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx @@ -12,6 +12,11 @@ import TextCell from "components/TableContainer/DataTable/TextCell"; const baseClass = "install-status-cell"; type IStatusValue = SoftwareInstallStatus | "avaiableForInstall"; +interface TootipArgs { + softwareName?: string | null; + lastInstalledAt?: string; + isAppStoreApp?: boolean; +} export type IStatusDisplayConfig = { iconName: @@ -21,10 +26,7 @@ export type IStatusDisplayConfig = { | "install" | "install-self-service"; displayText: string; - tooltip: (args: { - softwareName?: string | null; - lastInstalledAt?: string; - }) => ReactNode; + tooltip: (args: TootipArgs) => ReactNode; }; export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< @@ -59,12 +61,18 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< avaiableForInstall: { iconName: "install", displayText: "Available for install", - tooltip: ({ softwareName }) => ( - <> - {softwareName ? {softwareName} : "Software"} can be installed on - the host. Select Actions {">"} Install to install. - - ), + tooltip: ({ softwareName, isAppStoreApp }) => + isAppStoreApp ? ( + <> + App Store app can be installed on the host. Select{" "} + Actions {">"} Install to install. + + ) : ( + <> + {softwareName ? {softwareName} : "Software"} can be installed + on the host. Select Actions {">"} Install to install. + + ), }, selfService: { iconName: "install-self-service", @@ -79,21 +87,26 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< }, }; +type IInstallStatusCellProps = IHostSoftware; + const InstallStatusCell = ({ status, last_install, - package_available_for_install: softwareName, - self_service, -}: IHostSoftware) => { + software_package, + app_store_app, +}: IInstallStatusCellProps) => { const lastInstalledAt = last_install?.installed_at; + const hasPackage = !!software_package; + const hasAppStoreApp = !!app_store_app; let displayStatus: keyof typeof INSTALL_STATUS_DISPLAY_OPTIONS; if (status !== null) { displayStatus = status; - } else if (softwareName && self_service) { + } else if (software_package?.self_service) { + // currently only software packages can be self-service displayStatus = "selfService"; - } else if (softwareName) { + } else if (hasPackage || hasAppStoreApp) { displayStatus = "avaiableForInstall"; } else { return ; @@ -101,6 +114,7 @@ const InstallStatusCell = ({ const displayConfig = INSTALL_STATUS_DISPLAY_OPTIONS[displayStatus]; const tooltipId = uniqueId(); + const softwareName = software_package?.name; return (
@@ -123,6 +137,7 @@ const InstallStatusCell = ({ {displayConfig.tooltip({ softwareName, lastInstalledAt, + isAppStoreApp: hasAppStoreApp, })} diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx index 71f20d7c50..0f1d2df28b 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx @@ -52,7 +52,7 @@ interface IInstallerInfoProps { } const InstallerInfo = ({ software }: IInstallerInfoProps) => { - const { name, source, package: installerPackage } = software; + const { name, source, software_package: installerPackage } = software; return (
diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss index 56d357f798..027b9c4fd6 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss @@ -21,8 +21,6 @@ &__item-icon { display: flex; - height: 64px; - min-width: 64px; } &__item-name-version { diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 1ebfcbd655..a866a987a7 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -14,12 +14,6 @@ import { convertParamsToSnakeCase, } from "utilities/url"; import { IAddSoftwareFormData } from "pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm"; -import { - createMockAppStoreApp, - createMockSoftware, - createMockSoftwareTitleDetails, - createMockSoftwareTitleResponse, -} from "__mocks__/softwareMock"; export interface ISoftwareApiParams { page?: number; From 9bf5e97a0b0c5ab069aa795e6af06ea975a4430e Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Fri, 19 Jul 2024 11:11:35 -0500 Subject: [PATCH 25/38] Update add software modal with instructions to enable VPP (#20579) --- .../components/AppStoreVpp/AppStoreVpp.tsx | 62 ++++++++++++++++--- .../components/AppStoreVpp/_styles.scss | 13 ++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx index 54b3b6f0ad..358c09ffad 100644 --- a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx @@ -1,11 +1,17 @@ import React, { 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, { IVppApp } from "services/entities/mdm_apple"; +import mdmAppleAPI, { + IGetVppInfoResponse, + IVppApp, +} from "services/entities/mdm_apple"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import Card from "components/Card"; +import CustomLink from "components/CustomLink"; import Spinner from "components/Spinner"; import Button from "components/buttons/Button"; import DataError from "components/DataError"; @@ -17,6 +23,26 @@ import SoftwareIcon from "../icons/SoftwareIcon"; const baseClass = "app-store-vpp"; +const EnableVppCard = () => { + return ( + +
+

+ Volume Purchasing Program (VPP) isn’t enabled. +

+

+ To add App Store apps, first enable VPP. +

+ +
+
+ ); +}; + interface IVppAppListItemProps { app: IVppApp; selected: boolean; @@ -95,16 +121,31 @@ const AppStoreVpp = ({ teamId, router, onExit }: IAppStoreVppProps) => { const [isSubmitDisabled, setIsSubmitDisabled] = useState(true); const [selectedApp, setSelectedApp] = useState(null); - const { data: vppApps, isLoading, isError } = useQuery( - "vppSoftware", - () => mdmAppleAPI.getVppApps(teamId), + const { + data: vppInfo, + isLoading: isLoadingVppInfo, + error: errorVppInfo, + } = useQuery( + ["vppInfo"], + () => mdmAppleAPI.getVppInfo(), { ...DEFAULT_USE_QUERY_OPTIONS, staleTime: 30000, - select: (res) => res.app_store_apps, + retry: (tries, error) => error.status !== 404 && tries <= 3, } ); + const { + data: vppApps, + isLoading: isLoadingVppApps, + error: errorVppApps, + } = useQuery(["vppSoftware", teamId], () => mdmAppleAPI.getVppApps(teamId), { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: !!vppInfo, + staleTime: 30000, + select: (res) => res.app_store_apps, + }); + const onSelectApp = (app: IVppApp) => { setIsSubmitDisabled(false); setSelectedApp(app); @@ -142,11 +183,18 @@ const AppStoreVpp = ({ teamId, router, onExit }: IAppStoreVppProps) => { }; const renderContent = () => { - if (isLoading) { + if (isLoadingVppInfo || isLoadingVppApps) { return ; } - if (isError) { + if ( + errorVppInfo && + getErrorReason(errorVppInfo).includes("MDMConfigAsset was not found") + ) { + return ; + } + + if (errorVppInfo || errorVppApps) { return ; } diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss index 15c64a9aea..7c0ac47565 100644 --- a/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss @@ -50,4 +50,17 @@ &__error { margin: $pad-xxlarge 0; } + + &__enable-vpp { + display: flex; + flex-direction: column; + min-height: 149px; + align-items: center; + justify-content: center; + gap: $pad-small; + + p { + margin: 0; + } + } } From 80faef8327d38b46adaa00d55f833c233ca5911a Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Fri, 19 Jul 2024 14:35:10 -0500 Subject: [PATCH 26/38] Set `source = 'apps'` when creating software titles for vpp apps (#20622) Issue #20229 --- server/datastore/mysql/vpp.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 8c08b0c3f3..6c0d6bd6a6 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -235,11 +235,10 @@ VALUES } func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx sqlx.ExtContext, app *fleet.VPPApp) (uint, error) { - // NOTE: it was decided to leave the source empty for VPP apps for now, TBD + // NOTE: it was decided to populate "apps" as the source for VPP apps for now, TBD // if this needs to change to better map to how software titles are reported - // back by osquery. Since I think this will likely not stay empty, I'm using - // a variable for the source. - const source = "" + // back by osquery. Since it may change, we're using a variable for the source. + const source = "apps" selectStmt := `SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''` selectArgs := []any{app.Name, source} From caa7fd74e42dca490b7fcb1d98dfdbe58f97d2ba Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Fri, 19 Jul 2024 17:10:03 -0400 Subject: [PATCH 27/38] fix: remove temporary functions in tests (#20615) > Related issue: #20229 # Checklist for submitter If some of the following don't apply, delete the relevant line. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- ee/server/service/vpp.go | 2 +- server/datastore/mysql/software_test.go | 15 ++- .../datastore/mysql/software_titles_test.go | 62 ++----------- server/datastore/mysql/vpp.go | 9 +- server/datastore/mysql/vpp_test.go | 92 +++++++------------ server/fleet/datastore.go | 2 +- server/mock/datastore_mock.go | 4 +- 7 files changed, 64 insertions(+), 122 deletions(-) diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index d0fb05e0b0..3944768e43 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -155,7 +155,7 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, adamID str Name: assetMD.TrackName, LatestVersion: assetMD.Version, } - if err := svc.ds.InsertVPPAppWithTeam(ctx, app, teamID); err != nil { + if _, err := svc.ds.InsertVPPAppWithTeam(ctx, app, teamID); err != nil { return ctxerr.Wrap(ctx, err, "writing VPP app to db") } diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index 87552bd870..c38f5af197 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -3647,11 +3647,16 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { require.Empty(t, sw) // add VPP apps, one for both no team and team, and two for no-team only. - vpp1, _ := createVPPApp(t, ds, nil, "vpp1", "com.app.vpp1") - createVPPAppTeamOnly(t, ds, &tm.ID, vpp1) - vpp2, _ := createVPPApp(t, ds, nil, "vpp2", "com.app.vpp2") - vpp3, _ := createVPPApp(t, ds, nil, "vpp3", "com.app.vpp3") - require.NotEmpty(t, vpp3) + va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{AdamID: "adam_vpp_1", Name: "vpp1", BundleIdentifier: "com.app.vpp1"}, nil) + require.NoError(t, err) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{AdamID: "adam_vpp_1", Name: "vpp1", BundleIdentifier: "com.app.vpp1"}, &tm.ID) + require.NoError(t, err) + vpp1 := va1.AdamID + va2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{AdamID: "adam_vpp_2", Name: "vpp2", BundleIdentifier: "com.app.vpp2"}, nil) + require.NoError(t, err) + va3, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{AdamID: "adam_vpp_3", Name: "vpp3", BundleIdentifier: "com.app.vpp3"}, nil) + require.NoError(t, err) + vpp2, vpp3 := va2.AdamID, va3.AdamID // create an installation request for vpp1 and vpp2, leaving vpp3 as // available only diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index 2235ce819d..8f6ebbb5ff 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -2,10 +2,7 @@ package mysql import ( "context" - "crypto/rand" "database/sql" - "encoding/base64" - "io" "sort" "testing" "time" @@ -318,8 +315,8 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false) require.NoError(t, err) // create a VPP app not installed anywhere - vpp1, _ := createVPPApp(t, ds, nil, "vpp1", "com.app.vpp1") - require.NotEmpty(t, vpp1) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app.vpp1", AdamID: "adam_vpp_app_1"}, nil) + require.NoError(t, err) require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) @@ -617,8 +614,8 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { require.NoError(t, err) require.NotZero(t, installer2) // create a VPP app for team2 - vpp2, _ := createVPPApp(t, ds, &team2.ID, "vpp2", "com.app.vpp2") - require.NotEmpty(t, vpp2) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", AdamID: "adam_vpp_app_2"}, &team2.ID) + require.NoError(t, err) require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) @@ -800,8 +797,8 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { require.NoError(t, err) require.NotZero(t, installer2) // create a VPP app not installed on a host - vpp1, _ := createVPPApp(t, ds, nil, "vpp1", "com.app.vpp1") - require.NotEmpty(t, vpp1) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app,vpp1", AdamID: "adam_vpp_app_1"}, nil) + require.NoError(t, err) titles, counts, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ OrderKey: "name", @@ -887,10 +884,10 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore require.NotZero(t, installer2) // create a couple VPP apps - vpp1, _ := createVPPApp(t, ds, nil, "vpp1", "com.example.vpp1") - require.NotEmpty(t, vpp1) - vpp2, _ := createVPPApp(t, ds, nil, "vpp2", "com.example.vpp2") - require.NotEmpty(t, vpp2) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.example.vpp1", AdamID: "adam_vpp_app_1"}, nil) + require.NoError(t, err) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.example.vpp2", AdamID: "adam_vpp_app_2"}, nil) + require.NoError(t, err) host := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now()) software := []fleet.Software{ @@ -947,45 +944,6 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore require.ElementsMatch(t, []string{"installer1", "installer2", "vpp1", "vpp2"}, names) } -// creates the entry in vpp_apps and vpp_apps_teams, linking to a software -// title, creating it if necessary. The source column is always "apps". Returns -// the adam_id (auto-generated) and the software title id. -// TODO: temporary, until datastore methods are available for VPP apps. -func createVPPApp(t *testing.T, ds *Datastore, teamID *uint, name, bundle string) (string, uint) { - ctx := context.Background() - - rawBytes := make([]byte, 10) - _, err := io.ReadFull(rand.Reader, rawBytes) - require.NoError(t, err) - adamID := base64.RawStdEncoding.EncodeToString(rawBytes) - - titleID, err := ds.getOrGenerateSoftwareInstallerTitleID(ctx, &fleet.UploadSoftwareInstallerPayload{Title: name, Source: "apps", BundleIdentifier: bundle}) - require.NoError(t, err) - - ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `INSERT INTO vpp_apps (adam_id, title_id, name, bundle_identifier) VALUES (?, ?, ?, ?)`, - adamID, titleID, name, bundle) - return err - }) - - createVPPAppTeamOnly(t, ds, teamID, adamID) - return adamID, titleID -} - -func createVPPAppTeamOnly(t *testing.T, ds *Datastore, teamID *uint, adamID string) { - ctx := context.Background() - - ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - var globalOrTeamID uint - if teamID != nil { - globalOrTeamID = *teamID - } - _, err := q.ExecContext(ctx, `INSERT INTO vpp_apps_teams (adam_id, team_id, global_or_team_id) VALUES (?, ?, ?)`, - adamID, teamID, globalOrTeamID) - return err - }) -} - func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 6c0d6bd6a6..2ecfdc185d 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -141,8 +141,8 @@ func (ds *Datastore) BatchInsertVPPApps(ctx context.Context, apps []*fleet.VPPAp }) } -func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, teamID *uint) error { - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { +func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, teamID *uint) (*fleet.VPPApp, error) { + err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { titleID, err := ds.getOrInsertSoftwareTitleForVPPApp(ctx, tx, app) if err != nil { return err @@ -160,6 +160,11 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp return nil }) + if err != nil { + return nil, err + } + + return app, nil } func (ds *Datastore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[string]struct{}, error) { diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index faeeba65a2..b3141d0a9c 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -52,7 +52,9 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.Nil(t, meta) // create no-team app - vpp1, titleID1 := createVPPApp(t, ds, nil, "vpp1", "com.app.vpp1") + va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app.vpp1", AdamID: "adam_vpp_app_1"}, nil) + require.NoError(t, err) + vpp1, titleID1 := va1.AdamID, va1.TitleID // get no-team app meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, titleID1) @@ -60,7 +62,9 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp1", AppStoreID: vpp1}, meta) // create team1 app - vpp2, titleID2 := createVPPApp(t, ds, &team1.ID, "vpp2", "com.app.vpp2") + va2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", AdamID: "adam_vpp_app_2"}, &team1.ID) + require.NoError(t, err) + vpp2, titleID2 := va2.AdamID, va2.TitleID // get it for team 1 meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, titleID2) @@ -74,7 +78,8 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.Nil(t, meta) // create the same app for team2 - createVPPAppTeamOnly(t, ds, &team2.ID, vpp2) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", AdamID: "adam_vpp_app_2"}, &team2.ID) + require.NoError(t, err) // get it for team 1 and team 2, both work meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, titleID2) @@ -85,7 +90,9 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", AppStoreID: vpp2}, meta) // create another no-team app - vpp3, titleID3 := createVPPApp(t, ds, nil, "vpp3", "com.app.vpp3") + va3, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp3", BundleIdentifier: "com.app.vpp3", AdamID: "adam_vpp_app_3"}, nil) + require.NoError(t, err) + vpp3, titleID3 := va3.AdamID, va3.TitleID // get it for team 2, does not exist meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team2.ID, titleID3) @@ -158,10 +165,17 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { require.NotNil(t, team1) // create some apps, one for no-team, one for team1, and one in both - vpp1, _ := createVPPApp(t, ds, nil, "vpp1", "com.app.vpp1") - vpp2, _ := createVPPApp(t, ds, &team1.ID, "vpp2", "com.app.vpp2") - vpp3, _ := createVPPApp(t, ds, nil, "vpp3", "com.app.vpp3") - createVPPAppTeamOnly(t, ds, &team1.ID, vpp3) + va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app.vpp1", AdamID: "adam_vpp_app_1"}, nil) + require.NoError(t, err) + vpp1 := va1.AdamID + va2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", AdamID: "adam_vpp_app_2"}, &team1.ID) + require.NoError(t, err) + vpp2 := va2.AdamID + va3, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp3", BundleIdentifier: "com.app.vpp3", AdamID: "adam_vpp_app_3"}, nil) + require.NoError(t, err) + vpp3 := va3.AdamID + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp3", BundleIdentifier: "com.app.vpp3", AdamID: "adam_vpp_app_3"}, &team1.ID) + require.NoError(t, err) // for now they all return zeroes summary, err := ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) @@ -339,18 +353,18 @@ func testVPPApps(t *testing.T, ds *Datastore) { // Insert some VPP apps for the team, "vpp_app_1" should match the existing "foo" title app1 := &fleet.VPPApp{Name: "vpp_app_1", AdamID: "1", BundleIdentifier: "b1"} app2 := &fleet.VPPApp{Name: "vpp_app_2", AdamID: "2", BundleIdentifier: "b2"} - err = ds.InsertVPPAppWithTeam(ctx, app1, &team.ID) + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team.ID) require.NoError(t, err) - err = ds.InsertVPPAppWithTeam(ctx, app2, &team.ID) + _, err = ds.InsertVPPAppWithTeam(ctx, app2, &team.ID) require.NoError(t, err) // Insert some VPP apps for no team appNoTeam1 := &fleet.VPPApp{Name: "vpp_no_team_app_1", AdamID: "3", BundleIdentifier: "b3"} appNoTeam2 := &fleet.VPPApp{Name: "vpp_no_team_app_2", AdamID: "4", BundleIdentifier: "b4"} - err = ds.InsertVPPAppWithTeam(ctx, appNoTeam1, nil) + _, err = ds.InsertVPPAppWithTeam(ctx, appNoTeam1, nil) require.NoError(t, err) - err = ds.InsertVPPAppWithTeam(ctx, appNoTeam2, nil) + _, err = ds.InsertVPPAppWithTeam(ctx, appNoTeam2, nil) require.NoError(t, err) // Check that host_vpp_software_installs works @@ -414,54 +428,12 @@ func testGetVPPAppByTeamAndTitleID(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) - // TODO(roberto): replace with actual datastore method(s) once we have them - createVPPApp := func(adamID string, teamID *uint) uint { - var titleID int64 - ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { - res, err := tx.ExecContext( - ctx, - "INSERT INTO software_titles (name, source, browser) VALUES (?, ?, ?)", - uuid.NewString(), uuid.NewString(), "", - ) - if err != nil { - return err - } - - titleID, _ = res.LastInsertId() - return nil - }) - - ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { - _, err = tx.ExecContext( - ctx, - "INSERT INTO vpp_apps (adam_id, title_id) VALUES (?, ?)", - adamID, - titleID, - ) - return err - }) - - ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { - var tmID uint - if teamID != nil { - tmID = *teamID - } - _, err = tx.ExecContext( - ctx, - "INSERT INTO vpp_apps_teams (adam_id, team_id, global_or_team_id) VALUES (?, ?, ?)", - adamID, - teamID, - tmID, - ) - return err - }) - - return uint(titleID) - } - var nfe fleet.NotFoundError - fooTitleID := createVPPApp("foo", &team.ID) + fooApp, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{AdamID: "foo", BundleIdentifier: "b1", Name: "Foo"}, &team.ID) + require.NoError(t, err) + + fooTitleID := fooApp.TitleID gotVPPApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, fooTitleID, true) require.NoError(t, err) require.Equal(t, "foo", gotVPPApp.AdamID) @@ -471,7 +443,9 @@ func testGetVPPAppByTeamAndTitleID(t *testing.T, ds *Datastore) { require.ErrorAs(t, err, &nfe) // create an entry for the global team - barTitleID := createVPPApp("bar", nil) + barApp, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{AdamID: "bar", BundleIdentifier: "b2", Name: "Bar"}, nil) + require.NoError(t, err) + barTitleID := barApp.TitleID // not found providing the team id gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, barTitleID, true) require.ErrorAs(t, err, &nfe) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 6a01efb16b..483c45a252 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1590,7 +1590,7 @@ type Datastore interface { BatchInsertVPPApps(ctx context.Context, apps []*VPPApp) error GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[string]struct{}, error) - InsertVPPAppWithTeam(ctx context.Context, app *VPPApp, teamID *uint) error + InsertVPPAppWithTeam(ctx context.Context, app *VPPApp, teamID *uint) (*VPPApp, error) InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, adamID, commandUUID, associatedEventID string) error GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*User, *ActivityInstalledAppStoreApp, error) } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 26f5517375..1434eafd89 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1004,7 +1004,7 @@ type BatchInsertVPPAppsFunc func(ctx context.Context, apps []*fleet.VPPApp) erro type GetAssignedVPPAppsFunc func(ctx context.Context, teamID *uint) (map[string]struct{}, error) -type InsertVPPAppWithTeamFunc func(ctx context.Context, app *fleet.VPPApp, teamID *uint) error +type InsertVPPAppWithTeamFunc func(ctx context.Context, app *fleet.VPPApp, teamID *uint) (*fleet.VPPApp, error) type InsertHostVPPSoftwareInstallFunc func(ctx context.Context, hostID uint, userID uint, adamID string, commandUUID string, associatedEventID string) error @@ -5943,7 +5943,7 @@ func (s *DataStore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[s return s.GetAssignedVPPAppsFunc(ctx, teamID) } -func (s *DataStore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, teamID *uint) error { +func (s *DataStore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, teamID *uint) (*fleet.VPPApp, error) { s.mu.Lock() s.InsertVPPAppWithTeamFuncInvoked = true s.mu.Unlock() From b35724bd30fb625e080d1645ad89e2cf4972c788 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Fri, 19 Jul 2024 18:13:01 -0400 Subject: [PATCH 28/38] fix: store the VPP token encoded (#20606) > Related issue: part of #20229 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Manual QA for all new/changed functionality --- ee/server/service/vpp.go | 3 +-- server/service/mdm.go | 14 +++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index 3944768e43..6af48009f9 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -2,7 +2,6 @@ package service import ( "context" - "encoding/base64" "encoding/json" "fmt" "net/http" @@ -25,7 +24,7 @@ func (svc *Service) getVPPToken(ctx context.Context) (string, error) { return "", ctxerr.Wrap(ctx, err, "unmarshaling VPP token data") } - return base64.StdEncoding.EncodeToString([]byte(vppTokenData.Token)), nil + return vppTokenData.Token, nil } func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet.VPPApp, error) { diff --git a/server/service/mdm.go b/server/service/mdm.go index 545df4e6d5..bd80e45562 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2624,13 +2624,8 @@ func (svc *Service) UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSee return ctxerr.Wrap(ctx, err, "validating VPP token with Apple") } - decodedTokenBytes, err := base64.StdEncoding.DecodeString(string(tokenBytes)) - if err != nil { - return ctxerr.Wrap(ctx, err, "decoding VPP token") - } - data := fleet.VPPTokenData{ - Token: string(decodedTokenBytes), + Token: string(tokenBytes), Location: locName, } @@ -2694,7 +2689,12 @@ func (svc *Service) GetMDMAppleVPPToken(ctx context.Context) (*fleet.VPPTokenInf } var rawToken fleet.VPPTokenRaw - if err := json.Unmarshal([]byte(tokenData.Token), &rawToken); err != nil { + decodedBytes, err := base64.StdEncoding.DecodeString(tokenData.Token) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "decoding VPP token") + } + + if err := json.Unmarshal(decodedBytes, &rawToken); err != nil { return nil, ctxerr.Wrap(ctx, err, "unmarshaling VPP token") } From 9ec52cea9c320988d1b3cdd5457c2574ef69423e Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Mon, 22 Jul 2024 13:19:19 -0400 Subject: [PATCH 29/38] VPP Batch API (#20351) #20278 --- changes/20278-vpp-batch-api | 1 + cmd/fleetctl/gitops_test.go | 279 +++++++++++++++++- .../testdata/gitops/team_vpp_invalid_app.yml | 17 ++ .../testdata/gitops/team_vpp_valid_app.yml | 17 ++ .../testdata/gitops/team_vpp_valid_empty.yml | 16 + ee/server/service/vpp.go | 124 ++++++++ server/datastore/mysql/vpp.go | 77 ++++- server/datastore/mysql/vpp_test.go | 67 +++++ server/fleet/datastore.go | 2 + server/fleet/service.go | 1 + server/fleet/software.go | 4 + server/mdm/apple/vpp/api.go | 2 +- server/mock/datastore_mock.go | 12 + server/service/client.go | 66 ++++- server/service/client_teams.go | 10 + server/service/handler.go | 1 + server/service/integration_mdm_test.go | 123 +++++++- server/service/software_installers.go | 34 +++ 18 files changed, 840 insertions(+), 13 deletions(-) create mode 100644 changes/20278-vpp-batch-api create mode 100644 cmd/fleetctl/testdata/gitops/team_vpp_invalid_app.yml create mode 100644 cmd/fleetctl/testdata/gitops/team_vpp_valid_app.yml create mode 100644 cmd/fleetctl/testdata/gitops/team_vpp_valid_empty.yml diff --git a/changes/20278-vpp-batch-api b/changes/20278-vpp-batch-api new file mode 100644 index 0000000000..e5cbbf7eca --- /dev/null +++ b/changes/20278-vpp-batch-api @@ -0,0 +1 @@ +- GitOps supports VPP app associations diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 42ab05dfad..1723abb23e 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -2,6 +2,9 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -16,6 +19,7 @@ import ( "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/apple/vpp" "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" @@ -161,6 +165,12 @@ func TestBasicTeamGitOps(t *testing.T) { const secret = "TestSecret" + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, @@ -627,7 +637,12 @@ func TestFullTeamGitOps(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } - + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledSecrets = secrets return nil @@ -748,6 +763,13 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { return nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } + const ( fleetServerURL = "https://fleet.example.com" orgName = "GitOps Test" @@ -1030,6 +1052,13 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) { }, nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } + globalFile := "./testdata/gitops/global_config_no_paths.yml" teamFile := "./testdata/gitops/team_config_no_paths.yml" @@ -1076,7 +1105,14 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { - setupFullGitOpsPremiumServer(t) + ds, _, _ := setupFullGitOpsPremiumServer(t) + + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { @@ -1088,6 +1124,99 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { } } +func TestTeamVPPAppsGitOps(t *testing.T) { + config := &appleVPPConfigSrvConf{ + Assets: []vpp.Asset{ + { + AdamID: "1", + PricingParam: "STDQ", + AvailableCount: 12, + }, + { + AdamID: "2", + PricingParam: "STDQ", + AvailableCount: 3, + }, + }, + SerialNumbers: []string{"123", "456"}, + } + + startVPPApplyServer(t, config) + + cases := []struct { + file string + wantErr string + tokenExpiration time.Time + }{ + {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_app.yml", "vpp token expired", time.Now().Add(-24 * time.Hour)}, + {"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour)}, + } + + for _, c := range cases { + t.Run(filepath.Base(c.file), func(t *testing.T) { + ds, _, _ := setupFullGitOpsPremiumServer(t) + token, err := createVPPDataToken(c.tokenExpiration, "fleet", "ca") + require.NoError(t, err) + + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } + + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + asset := map[fleet.MDMAssetName]fleet.MDMConfigAsset{ + fleet.MDMAssetVPPToken: { + Name: fleet.MDMAssetVPPToken, + Value: token, + }, + } + return asset, nil + } + + _, err = runAppNoChecks([]string{"gitops", "-f", c.file}) + if c.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, c.wantErr) + } + }) + } +} + +func createVPPDataToken(expiration time.Time, orgName, location string) ([]byte, error) { + var randBytes [32]byte + _, err := rand.Read(randBytes[:]) + if err != nil { + return nil, fmt.Errorf("generating random bytes: %w", err) + } + token := base64.StdEncoding.EncodeToString(randBytes[:]) + raw := fleet.VPPTokenRaw{ + OrgName: orgName, + Token: token, + ExpDate: expiration.Format("2006-01-02T15:04:05Z0700"), + } + rawJson, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("marshalling vpp raw token: %w", err) + } + + base64Token := base64.StdEncoding.EncodeToString(rawJson) + + dataToken := fleet.VPPTokenData{Token: base64Token, Location: location} + dataTokenJson, err := json.Marshal(dataToken) + if err != nil { + return nil, fmt.Errorf("marshalling vpp data token: %w", err) + } + + return dataTokenJson, nil +} + func TestCustomSettingsGitOps(t *testing.T) { cases := []struct { file string @@ -1124,6 +1253,12 @@ func TestCustomSettingsGitOps(t *testing.T) { } return ret, nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { @@ -1169,6 +1304,140 @@ func startSoftwareInstallerServer(t *testing.T) { t.Setenv("SOFTWARE_INSTALLER_URL", srv.URL) } +type appleVPPConfigSrvConf struct { + Assets []vpp.Asset + SerialNumbers []string +} + +func startVPPApplyServer(t *testing.T, config *appleVPPConfigSrvConf) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "associate") { + var associations vpp.AssociateAssetsRequest + + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&associations); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if len(associations.Assets) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + res := vpp.ErrorResponse{ + ErrorNumber: 9718, + ErrorMessage: "This request doesn't contain an asset, which is a required argument. Change the request to provide an asset.", + } + if err := json.NewEncoder(w).Encode(res); err != nil { + panic(err) + } + return + } + + if len(associations.SerialNumbers) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + res := vpp.ErrorResponse{ + ErrorNumber: 9719, + ErrorMessage: "Either clientUserIds or serialNumbers are required arguments. Change the request to provide assignable users and devices.", + } + if err := json.NewEncoder(w).Encode(res); err != nil { + panic(err) + } + return + } + + var badAssets []vpp.Asset + for _, reqAsset := range associations.Assets { + var found bool + for _, goodAsset := range config.Assets { + if reqAsset == goodAsset { + found = true + } + } + if !found { + badAssets = append(badAssets, reqAsset) + } + } + + var badSerials []string + for _, reqSerial := range associations.SerialNumbers { + var found bool + for _, goodSerial := range config.SerialNumbers { + if reqSerial == goodSerial { + found = true + } + } + if !found { + badSerials = append(badSerials, reqSerial) + } + } + + if len(badAssets) != 0 || len(badSerials) != 0 { + errMsg := "error associating assets." + if len(badAssets) > 0 { + var badAdamIds []string + for _, asset := range badAssets { + badAdamIds = append(badAdamIds, asset.AdamID) + } + errMsg += fmt.Sprintf(" assets don't exist on account: %s.", strings.Join(badAdamIds, ", ")) + } + if len(badSerials) > 0 { + errMsg += fmt.Sprintf(" bad serials: %s.", strings.Join(badSerials, ", ")) + } + res := vpp.ErrorResponse{ + ErrorInfo: vpp.ResponseErrorInfo{ + Assets: badAssets, + ClientUserIds: []string{"something"}, + SerialNumbers: badSerials, + }, + // Not sure what error should be returned on each + // error type + ErrorNumber: 1, + ErrorMessage: errMsg, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(res); err != nil { + panic(err) + } + } + return + } + + if strings.Contains(r.URL.Path, "assets") { + // Then we're responding to GetAssets + w.Header().Set("Content-Type", "application/json") + encoder := json.NewEncoder(w) + err := encoder.Encode(map[string][]vpp.Asset{"assets": config.Assets}) + if err != nil { + panic(err) + } + return + } + + 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) + })) + + t.Setenv("FLEET_DEV_VPP_URL", srv.URL) + t.Cleanup(srv.Close) +} + func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) { testCert, testKey, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) @@ -1203,6 +1472,12 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, savedAppConfig = &appConfigCopy return nil } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []string) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } var savedTeam *fleet.Team diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app.yml b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app.yml new file mode 100644 index 0000000000..f0822f443b --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_invalid_app.yml @@ -0,0 +1,17 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: "999999999" diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_valid_app.yml b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app.yml new file mode 100644 index 0000000000..8d588fb127 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app.yml @@ -0,0 +1,17 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: "1" diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_valid_empty.yml b/cmd/fleetctl/testdata/gitops/team_vpp_valid_empty.yml new file mode 100644 index 0000000000..1c98065d59 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_valid_empty.yml @@ -0,0 +1,16 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index 6af48009f9..e76393111f 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -2,9 +2,12 @@ package service import ( "context" + "encoding/base64" "encoding/json" "fmt" "net/http" + "strings" + "time" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -24,9 +27,107 @@ func (svc *Service) getVPPToken(ctx context.Context) (string, error) { return "", ctxerr.Wrap(ctx, err, "unmarshaling VPP token data") } + vppTokenRawBytes, err := base64.StdEncoding.DecodeString(vppTokenData.Token) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "decoding raw vpp token data") + } + + var vppTokenRaw fleet.VPPTokenRaw + + if err := json.Unmarshal(vppTokenRawBytes, &vppTokenRaw); err != nil { + return "", ctxerr.Wrap(ctx, err, "unmarshaling raw vpp token data") + } + + exp, err := time.Parse("2006-01-02T15:04:05Z0700", vppTokenRaw.ExpDate) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "parsing vpp token expiration date") + } + + if time.Now().After(exp) { + return "", ctxerr.Errorf(ctx, "vpp token expired on %s", exp.String()) + } + return vppTokenData.Token, nil } +func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) error { + if teamName == "" { + svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden" + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "must not be empty")) + } + + if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil { + return err + } + + team, err := svc.ds.TeamByName(ctx, teamName) + if err != nil { + // If this is a dry run, the team may not have been created yet + if dryRun && fleet.IsNotFound(err) { + return nil + } + return err + } + + if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: &team.ID}, fleet.ActionWrite); err != nil { + return ctxerr.Wrap(ctx, err, "validating authorization") + } + + var adamIDs []string + + // Don't check for token if we're only disassociating assets + if len(payloads) > 0 { + token, err := svc.getVPPToken(ctx) + if err != nil { + return fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "could not retrieve vpp token"), http.StatusUnprocessableEntity) + } + + for _, payload := range payloads { + adamIDs = append(adamIDs, payload.AppStoreID) + } + + var missingAssets []string + + assets, err := vpp.GetAssets(token, nil) + if err != nil { + return ctxerr.Wrap(ctx, err, "unable to retrieve assets") + } + + assetMap := map[string]struct{}{} + for _, asset := range assets { + assetMap[asset.AdamID] = struct{}{} + } + + for _, adamID := range adamIDs { + if _, ok := assetMap[adamID]; !ok { + missingAssets = append(missingAssets, adamID) + } + } + + if len(missingAssets) != 0 { + reqErr := ctxerr.Errorf(ctx, "requested app not available on vpp account: %s", strings.Join(missingAssets, ",")) + return fleet.NewUserMessageError(reqErr, http.StatusUnprocessableEntity) + } + } + + if !dryRun { + apps, err := getVPPAppsMetadata(ctx, adamIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "refreshing VPP app metadata") + } + + if err := svc.ds.BatchInsertVPPApps(ctx, apps); err != nil { + return ctxerr.Wrap(ctx, err, "inserting vpp app metadata") + } + + if err := svc.ds.SetTeamVPPApps(ctx, &team.ID, adamIDs); err != nil { + return fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "set team vpp assets"), http.StatusInternalServerError) + } + } + + return nil +} + func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet.VPPApp, error) { if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionRead); err != nil { return nil, err @@ -170,3 +271,26 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, adamID str return nil } + +func getVPPAppsMetadata(ctx context.Context, adamIDs []string) ([]*fleet.VPPApp, error) { + var apps []*fleet.VPPApp + + assetMetatada, err := itunes.GetAssetMetadata(adamIDs, &itunes.AssetMetadataFilter{Entity: "desktopSoftware"}) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "fetching VPP asset metadata") + } + + for adamID, metadata := range assetMetatada { + app := &fleet.VPPApp{ + AdamID: adamID, + BundleIdentifier: metadata.BundleID, + IconURL: metadata.ArtworkURL, + Name: metadata.TrackName, + LatestVersion: metadata.Version, + } + + apps = append(apps, app) + } + + return apps, nil +} diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 2ecfdc185d..353dcd2fbc 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -133,8 +133,60 @@ func vppAppHostStatusNamedQuery(hvsiAlias, ncrAlias, colAlias string) string { func (ds *Datastore) BatchInsertVPPApps(ctx context.Context, apps []*fleet.VPPApp) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - if err := insertVPPApps(ctx, tx, apps); err != nil { - return ctxerr.Wrap(ctx, err, "BatchInsertVPPApps insertVPPApps transaction") + for _, app := range apps { + titleID, err := ds.getOrInsertSoftwareTitleForVPPApp(ctx, tx, app) + if err != nil { + return err + } + + app.TitleID = titleID + + if err := insertVPPApps(ctx, tx, []*fleet.VPPApp{app}); err != nil { + return ctxerr.Wrap(ctx, err, "BatchInsertVPPApps insertVPPApps transaction") + } + } + return nil + }) +} + +func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, adamIDs []string) error { + existingApps, err := ds.GetAssignedVPPApps(ctx, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "SetTeamVPPApps getting list of existing apps") + } + + var missingApps []string + var toRemoveApps []string + + for existingApp := range existingApps { + var found bool + for _, adamID := range adamIDs { + if adamID == existingApp { + found = true + } + } + if !found { + toRemoveApps = append(toRemoveApps, existingApp) + } + } + + for _, adamID := range adamIDs { + if _, ok := existingApps[adamID]; !ok { + missingApps = append(missingApps, adamID) + } + } + + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + for _, toAdd := range missingApps { + if err := insertVPPAppTeams(ctx, tx, toAdd, teamID); err != nil { + return ctxerr.Wrap(ctx, err, "SetTeamVPPApps inserting vpp app into team") + } + } + + for _, toRemove := range toRemoveApps { + if err := removeVPPAppTeams(ctx, tx, toRemove, teamID); err != nil { + return ctxerr.Wrap(ctx, err, "SetTeamVPPApps removing vpp app from team") + } } return nil @@ -239,6 +291,23 @@ VALUES return ctxerr.Wrap(ctx, err, "writing vpp app team mapping to db") } +func removeVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, adamID string, teamID *uint) error { + stmt := ` +DELETE FROM + vpp_apps_teams +WHERE + adam_id = ? +AND + team_id = ? +` + _, err := tx.ExecContext(ctx, stmt, adamID, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "deleting vpp app from team") + } + + return nil +} + func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx sqlx.ExtContext, app *fleet.VPPApp) (uint, error) { // NOTE: it was decided to populate "apps" as the source for VPP apps for now, TBD // if this needs to change to better map to how software titles are reported @@ -251,6 +320,10 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s insertArgs := []any{app.Name, source} if app.BundleIdentifier != "" { + // NOTE: The index `idx_sw_titles` doesn't include the bundle + // identifier. It's possible for the select to return nothing + // but for the insert to fail if an app with the same name but + // no bundle identifier exists in the DB. selectStmt = `SELECT id FROM software_titles WHERE bundle_identifier = ?` selectArgs = []any{app.BundleIdentifier} insertStmt = `INSERT INTO software_titles (name, source, bundle_identifier, browser) VALUES (?, ?, ?, '')` diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index b3141d0a9c..dd2deaba1b 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -19,6 +19,7 @@ func TestVPP(t *testing.T) { name string fn func(t *testing.T, ds *Datastore) }{ + {"SetTeamVPPApps", testSetTeamVPPApps}, {"VPPAppMetadata", testVPPAppMetadata}, {"VPPAppStatus", testVPPAppStatus}, {"VPPApps", testVPPApps}, @@ -423,6 +424,72 @@ func testVPPApps(t *testing.T, ds *Datastore) { require.Equal(t, app2.Name, appTitles[1].Name) } +func testSetTeamVPPApps(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Create a team + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "vpp gang"}) + require.NoError(t, err) + + // Insert some VPP apps for the team + app1 := &fleet.VPPApp{Name: "vpp_app_1", AdamID: "1", BundleIdentifier: "b1"} + _, err = ds.InsertVPPAppWithTeam(ctx, app1, nil) + require.NoError(t, err) + app2 := &fleet.VPPApp{Name: "vpp_app_2", AdamID: "2", BundleIdentifier: "b2"} + _, err = ds.InsertVPPAppWithTeam(ctx, app2, nil) + require.NoError(t, err) + app3 := &fleet.VPPApp{Name: "vpp_app_3", AdamID: "3", BundleIdentifier: "b3"} + _, err = ds.InsertVPPAppWithTeam(ctx, app3, nil) + require.NoError(t, err) + app4 := &fleet.VPPApp{Name: "vpp_app_4", AdamID: "4", BundleIdentifier: "b4"} + _, err = ds.InsertVPPAppWithTeam(ctx, app4, nil) + require.NoError(t, err) + + assigned, err := ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Len(t, assigned, 0) + + // Assign 2 apps + err = ds.SetTeamVPPApps(ctx, &team.ID, []string{app1.AdamID, app2.AdamID}) + require.NoError(t, err) + + assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Len(t, assigned, 2) + require.Contains(t, assigned, app1.AdamID) + require.Contains(t, assigned, app2.AdamID) + + // Assign an additional app + err = ds.SetTeamVPPApps(ctx, &team.ID, []string{app1.AdamID, app2.AdamID, app3.AdamID}) + require.NoError(t, err) + + assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Len(t, assigned, 3) + require.Contains(t, assigned, app1.AdamID) + require.Contains(t, assigned, app2.AdamID) + require.Contains(t, assigned, app3.AdamID) + + // Swap one app out for another + err = ds.SetTeamVPPApps(ctx, &team.ID, []string{app1.AdamID, app2.AdamID, app4.AdamID}) + require.NoError(t, err) + + assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Len(t, assigned, 3) + require.Contains(t, assigned, app1.AdamID) + require.Contains(t, assigned, app2.AdamID) + require.Contains(t, assigned, app4.AdamID) + + // Remove all apps + err = ds.SetTeamVPPApps(ctx, &team.ID, []string{}) + require.NoError(t, err) + + assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) + require.NoError(t, err) + require.Len(t, assigned, 0) +} + func testGetVPPAppByTeamAndTitleID(t *testing.T, ds *Datastore) { ctx := context.Background() team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 483c45a252..b6b8af675a 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1590,7 +1590,9 @@ type Datastore interface { BatchInsertVPPApps(ctx context.Context, apps []*VPPApp) error GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[string]struct{}, error) + SetTeamVPPApps(ctx context.Context, teamID *uint, adamIDs []string) error InsertVPPAppWithTeam(ctx context.Context, app *VPPApp, teamID *uint) (*VPPApp, error) + InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, adamID, commandUUID, associatedEventID string) error GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*User, *ActivityInstalledAppStoreApp, error) } diff --git a/server/fleet/service.go b/server/fleet/service.go index 808a4a8257..2be1e663d9 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -713,6 +713,7 @@ type Service interface { UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSeeker) error GetMDMAppleVPPToken(ctx context.Context) (*VPPTokenInfo, error) DeleteMDMAppleVPPToken(ctx context.Context) error + BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []VPPBatchPayload, dryRun bool) error // GetHostDEPAssignment retrieves the host DEP assignment for the specified host. GetHostDEPAssignment(ctx context.Context, host *Host) (*HostDEPAssignment, error) diff --git a/server/fleet/software.go b/server/fleet/software.go index f8f11696a9..dc2c4d98a3 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -413,3 +413,7 @@ func SoftwareFromOsqueryRow(name, version, source, vendor, installedPath, releas } return &software, nil } + +type VPPBatchPayload struct { + AppStoreID string `json:"app_store_id"` +} diff --git a/server/mdm/apple/vpp/api.go b/server/mdm/apple/vpp/api.go index 2925a8e082..86a91b5054 100644 --- a/server/mdm/apple/vpp/api.go +++ b/server/mdm/apple/vpp/api.go @@ -33,7 +33,7 @@ type Asset struct { // // https://developer.apple.com/documentation/devicemanagement/errorresponse type ErrorResponse struct { - ErrorInfo ResponseErrorInfo `json:"errorInfo"` + ErrorInfo ResponseErrorInfo `json:"errorInfo,omitempty"` ErrorMessage string `json:"errorMessage"` ErrorNumber int32 `json:"errorNumber"` } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 1434eafd89..bfb9cef312 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1006,6 +1006,8 @@ type GetAssignedVPPAppsFunc func(ctx context.Context, teamID *uint) (map[string] type InsertVPPAppWithTeamFunc func(ctx context.Context, app *fleet.VPPApp, teamID *uint) (*fleet.VPPApp, error) +type SetTeamVPPAppsFunc func(ctx context.Context, teamID *uint, adamIDs []string) error + type InsertHostVPPSoftwareInstallFunc func(ctx context.Context, hostID uint, userID uint, adamID string, commandUUID string, associatedEventID string) error type GetPastActivityDataForVPPAppInstallFunc func(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityInstalledAppStoreApp, error) @@ -2490,6 +2492,9 @@ type DataStore struct { InsertVPPAppWithTeamFunc InsertVPPAppWithTeamFunc InsertVPPAppWithTeamFuncInvoked bool + SetTeamVPPAppsFunc SetTeamVPPAppsFunc + SetTeamVPPAppsFuncInvoked bool + InsertHostVPPSoftwareInstallFunc InsertHostVPPSoftwareInstallFunc InsertHostVPPSoftwareInstallFuncInvoked bool @@ -5950,6 +5955,13 @@ func (s *DataStore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, return s.InsertVPPAppWithTeamFunc(ctx, app, teamID) } +func (s *DataStore) SetTeamVPPApps(ctx context.Context, teamID *uint, adamIDs []string) error { + s.mu.Lock() + s.SetTeamVPPAppsFuncInvoked = true + s.mu.Unlock() + return s.SetTeamVPPAppsFunc(ctx, teamID, adamIDs) +} + func (s *DataStore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID uint, userID uint, adamID string, commandUUID string, associatedEventID string) error { s.mu.Lock() s.InsertHostVPPSoftwareInstallFuncInvoked = true diff --git a/server/service/client.go b/server/service/client.go index f792d1908c..abcb5de2f2 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -606,8 +606,8 @@ func (c *Client) ApplyGroup( tmScriptsPayloads[k] = scriptPayloads } - tmSoftwarePackages := extractTmSpecsSoftware(specs.Teams) - tmSoftwarePayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmScripts)) + tmSoftwarePackages := extractTmSpecsSoftwarePackages(specs.Teams) + tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmScripts)) for tmName, software := range tmSoftwarePackages { softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(software)) for i, si := range software { @@ -663,7 +663,17 @@ func (c *Client) ApplyGroup( } } - tmSoftwarePayloads[tmName] = softwarePayloads + tmSoftwarePackagesPayloads[tmName] = softwarePayloads + } + + tmSoftwareApps := extractTmSpecsSoftwareApps(specs.Teams) + tmSoftwareAppsPayloads := make(map[string][]fleet.VPPBatchPayload) + for tmName, apps := range tmSoftwareApps { + appPayloads := make([]fleet.VPPBatchPayload, 0, len(apps)) + for _, app := range apps { + appPayloads = append(appPayloads, fleet.VPPBatchPayload{AppStoreID: app.AppStoreID}) + } + tmSoftwareAppsPayloads[tmName] = appPayloads } // Next, apply the teams specs before saving the profiles, so that any @@ -731,8 +741,8 @@ func (c *Client) ApplyGroup( } } } - if len(tmSoftwarePayloads) > 0 { - for tmName, software := range tmSoftwarePayloads { + if len(tmSoftwarePackagesPayloads) > 0 { + for tmName, software := range tmSoftwarePackagesPayloads { // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) if err := c.ApplyTeamSoftwareInstallers(currentTeamName, software, opts.ApplySpecOptions); err != nil { @@ -740,6 +750,15 @@ func (c *Client) ApplyGroup( } } } + if len(tmSoftwareAppsPayloads) > 0 { + for tmName, apps := range tmSoftwareAppsPayloads { + // For non-dry run, currentTeamName and tmName are the same + currentTeamName := getTeamName(tmName) + if err := c.ApplyTeamAppStoreAppsAssociation(currentTeamName, apps, opts.ApplySpecOptions); err != nil { + return nil, fmt.Errorf("applying app store apps for team: %q: %w", tmName, err) + } + } + } if opts.DryRun { logfn("[+] would've applied %d teams\n", len(specs.Teams)) } else { @@ -995,7 +1014,7 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string]profi return m } -func extractTmSpecsSoftware(tmSpecs []json.RawMessage) map[string][]fleet.TeamSpecSoftwarePackage { +func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]fleet.TeamSpecSoftwarePackage { var m map[string][]fleet.TeamSpecSoftwarePackage for _, tm := range tmSpecs { var spec struct { @@ -1030,6 +1049,41 @@ func extractTmSpecsSoftware(tmSpecs []json.RawMessage) map[string][]fleet.TeamSp return m } +func extractTmSpecsSoftwareApps(tmSpecs []json.RawMessage) map[string][]fleet.TeamSpecAppStoreApp { + var m map[string][]fleet.TeamSpecAppStoreApp + for _, tm := range tmSpecs { + var spec struct { + Name string `json:"name"` + Software json.RawMessage `json:"software"` + } + if err := json.Unmarshal(tm, &spec); err != nil { + // ignore, this will fail in the call to apply team specs + continue + } + spec.Name = norm.NFC.String(spec.Name) + if spec.Name != "" && len(spec.Software) > 0 { + if m == nil { + m = make(map[string][]fleet.TeamSpecAppStoreApp) + } + var software fleet.TeamSpecSoftware + var apps []fleet.TeamSpecAppStoreApp + if err := json.Unmarshal(spec.Software, &software); err != nil { + // ignore, will fail in apply team specs call + continue + } + if !software.AppStoreApps.Valid { + // to be consistent with the AppConfig custom settings, set it to an + // empty slice if the provided custom settings are present but empty. + apps = []fleet.TeamSpecAppStoreApp{} + } else { + apps = software.AppStoreApps.Value + } + m[spec.Name] = apps + } + } + return m +} + func extractTmSpecsScripts(tmSpecs []json.RawMessage) map[string][]string { var m map[string][]string for _, tm := range tmSpecs { diff --git a/server/service/client_teams.go b/server/service/client_teams.go index 8acbd0998e..e2edc217a0 100644 --- a/server/service/client_teams.go +++ b/server/service/client_teams.go @@ -102,3 +102,13 @@ func (c *Client) ApplyTeamSoftwareInstallers(tmName string, softwareInstallers [ query.Add("team_name", tmName) return c.authenticatedRequestWithQuery(map[string]interface{}{"software": softwareInstallers}, verb, path, nil, query.Encode()) } + +func (c *Client) ApplyTeamAppStoreAppsAssociation(tmName string, vppBatchPayload []fleet.VPPBatchPayload, opts fleet.ApplySpecOptions) error { + verb, path := "POST", "/api/latest/fleet/software/app_store_apps/batch" + query, err := url.ParseQuery(opts.RawQuery()) + if err != nil { + return err + } + query.Add("team_name", tmName) + return c.authenticatedRequestWithQuery(map[string]interface{}{"app_store_apps": vppBatchPayload}, verb, path, nil, query.Encode()) +} diff --git a/server/service/handler.go b/server/service/handler.go index a0f46b0286..738cb770f2 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -731,6 +731,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC 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{}) + ue.POST("/api/_version_/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsEndpoint, batchAssociateAppStoreAppsRequest{}) // Deprecated: GET /mdm/apple_bm is now deprecated, replaced by the // GET /abm endpoint. diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 9255281909..6ddee1929e 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -95,8 +95,8 @@ type integrationMDMTestSuite struct { mdmCommander *apple_mdm.MDMAppleCommander logger kitlog.Logger scepChallenge string - appleVPPConfigSrvConfig *appleVPPConfigSrvConf appleVPPConfigSrv *httptest.Server + appleVPPConfigSrvConfig *appleVPPConfigSrvConf appleITunesSrv *httptest.Server mockedDownloadFleetdmMeta fleetdbase.Metadata } @@ -433,7 +433,6 @@ func (s *integrationMDMTestSuite) SetupSuite() { panic(err) } } - _, _ = w.Write([]byte(`{"eventId": "123-345"}`)) return } @@ -9489,6 +9488,126 @@ func (s *integrationMDMTestSuite) TestConnectedToFleetWithoutCheckout() { require.False(t, *hostResp.Host.MDM.ConnectedToFleet) } +func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { + t := s.T() + batchURL := "/api/latest/fleet/software/app_store_apps/batch" + + // a team name is required (we don't allow installers for "no team") + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusBadRequest) + + // non-existent team + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNotFound, "team_name", "foo") + + // create a team + tmGood, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: t.Name() + " good", + Description: "desc", + }) + require.NoError(t, err) + + // create a team + tmEmpty, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: t.Name() + " empty", + Description: "desc", + }) + require.NoError(t, err) + + // No vpp token set, no association + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) + + // No vpp token set, try association + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}}}, http.StatusUnprocessableEntity, "team_name", tmGood.Name) + + // Valid token + orgName := "Fleet Device Management Inc." + token := "mycooltoken" + expDate := "2025-06-24T15:50:50+0000" + tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName) + t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL) + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") + // Remove all vpp associations from team with no members + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) + + // host with valid serial number + hValid, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + HardwareSerial: "123", + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + uuid.New().String()), + NodeKey: ptr.String(t.Name() + uuid.New().String()), + Hostname: fmt.Sprintf("%sfoo.local", t.Name()), + Platform: "darwin", + }) + require.NoError(t, err) + err = s.ds.AddHostsToTeam(context.Background(), &tmGood.ID, []uint{hValid.ID}) + require.NoError(t, err) + + ctx := context.Background() + + assoc, err := s.ds.GetAssignedVPPApps(ctx, &tmGood.ID) + require.NoError(t, err) + require.Len(t, assoc, 0) + + // Remove all vpp associations from team with no members + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) + + // Associating an app we don't own + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: "fake-app"}}}, http.StatusUnprocessableEntity, "team_name", tmGood.Name) + + assoc, err = s.ds.GetAssignedVPPApps(ctx, &tmGood.ID) + require.NoError(t, err) + require.Len(t, assoc, 0) + + // Associating an app we own + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}}}, http.StatusNoContent, "team_name", tmGood.Name) + + assoc, err = s.ds.GetAssignedVPPApps(ctx, &tmGood.ID) + require.NoError(t, err) + require.Len(t, assoc, 1) + require.Contains(t, assoc, s.appleVPPConfigSrvConfig.Assets[0].AdamID) + + // Associating one good and one bad app + s.Do("POST", + batchURL, + batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{ + {AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}, + {AppStoreID: "fake-app"}, + }}, http.StatusUnprocessableEntity, "team_name", tmGood.Name, + ) + + assoc, err = s.ds.GetAssignedVPPApps(ctx, &tmGood.ID) + require.NoError(t, err) + require.Len(t, assoc, 1) + + // Associating two apps we own + s.Do("POST", + batchURL, + batchAssociateAppStoreAppsRequest{ + Apps: []fleet.VPPBatchPayload{ + {AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}, + {AppStoreID: s.appleVPPConfigSrvConfig.Assets[1].AdamID}, + }, + }, http.StatusNoContent, "team_name", tmGood.Name, + ) + assoc, err = s.ds.GetAssignedVPPApps(ctx, &tmGood.ID) + require.NoError(t, err) + require.Len(t, assoc, 2) + require.Contains(t, assoc, s.appleVPPConfigSrvConfig.Assets[0].AdamID) + require.Contains(t, assoc, s.appleVPPConfigSrvConfig.Assets[1].AdamID) + + // Associate an app with a team with no team members + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}}}, http.StatusNoContent, "team_name", tmEmpty.Name) + + // Remove all vpp associations + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) + + assoc, err = s.ds.GetAssignedVPPApps(ctx, &tmGood.ID) + require.NoError(t, err) + require.Len(t, assoc, 0) +} + func (s *integrationMDMTestSuite) TestInvalidCommandUUID() { t := s.T() _, device := createHostThenEnrollMDM(s.ds, s.server.URL, t) diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 6624bb7bb0..af2d302698 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -400,3 +400,37 @@ func (svc *Service) HasSelfServiceSoftwareInstallers(ctx context.Context, host * return svc.ds.HasSelfServiceSoftwareInstallers(ctx, host.Platform, host.TeamID) } + +////////////////////////////////////////////////////////////////////////////// +// VPP App Store Apps Batch Install +////////////////////////////////////////////////////////////////////////////// + +type batchAssociateAppStoreAppsRequest struct { + TeamName string `json:"-" query:"team_name"` + DryRun bool `json:"-" query:"dry_run,optional"` + Apps []fleet.VPPBatchPayload `json:"app_store_apps"` +} + +type batchAssociateAppStoreAppsResponse struct { + Err error `json:"error,omitempty"` +} + +func (r batchAssociateAppStoreAppsResponse) error() error { return r.Err } + +func (r batchAssociateAppStoreAppsResponse) Status() int { return http.StatusNoContent } + +func batchAssociateAppStoreAppsEndpoint(ctx context.Context, request any, svc fleet.Service) (errorer, error) { + req := request.(*batchAssociateAppStoreAppsRequest) + if err := svc.BatchAssociateVPPApps(ctx, req.TeamName, req.Apps, req.DryRun); err != nil { + return batchAssociateAppStoreAppsResponse{Err: err}, nil + } + return batchAssociateAppStoreAppsResponse{}, nil +} + +func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} From bbe7b0225bb82975ae85470403b9c7ba6d84a8f9 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Mon, 22 Jul 2024 21:01:34 +0100 Subject: [PATCH 30/38] UI vpp feature polish (#20636) --- .../components/AppStoreVpp/AppStoreVpp.tsx | 9 ++--- .../components/AppStoreVpp/helpers.tsx | 32 +++++++++++++++++ .../details/cards/Software/HostSoftware.tsx | 13 ++----- .../Software/HostSoftwareTableConfig.tsx | 3 +- .../hosts/details/cards/Software/helpers.tsx | 36 +++++++++++++++++++ 5 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx create mode 100644 frontend/pages/hosts/details/cards/Software/helpers.tsx diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx index 358c09ffad..1a1fc02ad0 100644 --- a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx @@ -20,6 +20,7 @@ import { NotificationContext } from "context/notification"; import { getErrorReason } from "interfaces/errors"; import { buildQueryStringFromParams } from "utilities/url"; import SoftwareIcon from "../icons/SoftwareIcon"; +import { getErrorMessage } from "./helpers"; const baseClass = "app-store-vpp"; @@ -171,13 +172,7 @@ const AppStoreVpp = ({ teamId, router, onExit }: IAppStoreVppProps) => { }); router.push(`${PATHS.SOFTWARE}?${queryParams}`); } catch (e) { - const reason = getErrorReason(e); - // TODO: update with pre-defined error messages we want to pass through from the API - if (reason.toLowerCase().includes("already")) { - renderFlash("error", reason); - } else { - renderFlash("error", "Couldn’t add software. Please try again."); - } + renderFlash("error", getErrorMessage(e)); } onExit(); }; diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx new file mode 100644 index 0000000000..b32495cbb8 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/helpers.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { getErrorReason } from "interfaces/errors"; + +const ADD_SOFTWARE_ERROR_PREFIX = "Couldn’t add software."; +const DEFAULT_ERROR_MESSAGE = `${ADD_SOFTWARE_ERROR_PREFIX} Please try again.`; + +const generateAlreadyAvailableMessage = (msg: string) => { + const regex = new RegExp( + `${ADD_SOFTWARE_ERROR_PREFIX} (.+) already.+on the (.+) team.` + ); + + const match = msg.match(regex); + if (!match) return DEFAULT_ERROR_MESSAGE; + + return ( + <> + {ADD_SOFTWARE_ERROR_PREFIX} {match[1]} already has software + available for install on the {match[2]} team.{" "} + + ); +}; + +// eslint-disable-next-line import/prefer-default-export +export const getErrorMessage = (e: unknown) => { + const reason = getErrorReason(e); + + // software is already available for install + if (reason.toLowerCase().includes("already")) { + return generateAlreadyAvailableMessage(reason); + } + return DEFAULT_ERROR_MESSAGE; +}; diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx index 6cf80111ac..f4a369cdb4 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx @@ -27,6 +27,7 @@ import CustomLink from "components/CustomLink"; import { generateSoftwareTableHeaders as generateHostSoftwareTableConfig } from "./HostSoftwareTableConfig"; import { generateSoftwareTableHeaders as generateDeviceSoftwareTableConfig } from "./DeviceSoftwareTableConfig"; import HostSoftwareTable from "./HostSoftwareTable"; +import { getErrorMessage } from "./helpers"; const baseClass = "software-card"; @@ -187,17 +188,7 @@ const HostSoftware = ({ "Software is installing or will install when the host comes online." ); } catch (e) { - const reason = upperFirst(trimEnd(getErrorReason(e), ".")); - if (reason.includes("fleetd installed")) { - renderFlash("error", `Couldn't install. ${reason}.`); - } else if (reason.includes("can be installed only on")) { - renderFlash( - "error", - `Couldn't install. ${reason.replace("darwin", "macOS")}.` - ); - } else { - renderFlash("error", "Couldn't install. Please try again."); - } + renderFlash("error", getErrorMessage(e)); } setInstallingSoftwareId(null); refetchSoftware(); diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index 0d54533b37..5825f48146 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -123,7 +123,7 @@ export const generateSoftwareTableHeaders = ({ accessor: "name", disableSortBy: false, Cell: (cellProps: ITableStringCellProps) => { - const { id, name, source } = cellProps.row.original; + const { id, name, source, app_store_app } = cellProps.row.original; const softwareTitleDetailsPath = PATHS.SOFTWARE_TITLE_DETAILS( id.toString().concat(`?team_id=${teamId}`) @@ -133,6 +133,7 @@ export const generateSoftwareTableHeaders = ({ diff --git a/frontend/pages/hosts/details/cards/Software/helpers.tsx b/frontend/pages/hosts/details/cards/Software/helpers.tsx new file mode 100644 index 0000000000..e251728bdf --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/helpers.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { getErrorReason } from "interfaces/errors"; +import { trimEnd, upperFirst } from "lodash"; + +const INSTALL_SOFTWARE_ERROR_PREFIX = "Couldn't install."; +const DEFAULT_ERROR_MESSAGE = `${INSTALL_SOFTWARE_ERROR_PREFIX} Please try again.`; + +const createOnlyInstallableOnMacOSMessage = (reason: string) => + `Couldn't install. ${reason.replace("darwin", "macOS")}.`; + +const createVPPTokenExpiredMessage = () => ( + <> + {INSTALL_SOFTWARE_ERROR_PREFIX} VPP token expired. Go to{" "} + + Settings {">"} Integration {">"} Volume Purchasing Program + {" "} + and renew token. + +); + +// eslint-disable-next-line import/prefer-default-export +export const getErrorMessage = (e: unknown) => { + const reason = upperFirst(trimEnd(getErrorReason(e), ".")); + + if (reason.includes("fleetd installed")) { + return `${INSTALL_SOFTWARE_ERROR_PREFIX}. ${reason}.`; + } else if (reason.includes("can be installed only on")) { + return createOnlyInstallableOnMacOSMessage(reason); + } else if (reason.includes("VPP token expired")) { + createVPPTokenExpiredMessage(); + } else if (reason.includes("MDM is turned off")) { + return reason; + } + + return DEFAULT_ERROR_MESSAGE; +}; From 746c442d0146c4d0f3ded8900ff2b3dced70f160 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Mon, 22 Jul 2024 16:11:07 -0400 Subject: [PATCH 31/38] feat: list host software integration tests (#20632) > Related issue: #20229 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- server/service/integration_mdm_test.go | 79 ++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 6ddee1929e..f0d60d122f 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -9776,12 +9776,20 @@ func (s *integrationMDMTestSuite) TestVPPApps() { addedApp = appResp.AppStoreApps[0] addAppResp = addAppStoreAppResponse{} s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID}, http.StatusOK, &addAppResp) - s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, addedApp.Name, addedApp.AdamID, team.ID), 0) + s.lastActivityMatches( + fleet.ActivityAddedAppStoreApp{}.ActivityName(), + fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, addedApp.Name, addedApp.AdamID, team.ID), + 0, + ) errApp := appResp.AppStoreApps[1] addAppResp = addAppStoreAppResponse{} s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: errApp.AdamID}, http.StatusOK, &addAppResp) - s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, errApp.Name, errApp.AdamID, team.ID), 0) + s.lastActivityMatches( + fleet.ActivityAddedAppStoreApp{}.ActivityName(), + fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d}`, team.Name, errApp.Name, errApp.AdamID, team.ID), + 0, + ) listSw = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), "available_for_install", "true") @@ -9813,7 +9821,19 @@ func (s *integrationMDMTestSuite) TestVPPApps() { } } - s.lastActivityMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s"}`, mdmHost.ID, mdmHost.DisplayName(), errApp.Name, errApp.AdamID, cmdUUID, fleet.SoftwareInstallerFailed), 0) + s.lastActivityMatches( + fleet.ActivityInstalledAppStoreApp{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s"}`, + mdmHost.ID, + mdmHost.DisplayName(), + errApp.Name, + errApp.AdamID, + cmdUUID, + fleet.SoftwareInstallerFailed, + ), + 0, + ) // Trigger install to the host installResp = installSoftwareResponse{} @@ -9833,7 +9853,58 @@ func (s *integrationMDMTestSuite) TestVPPApps() { } } - s.lastActivityMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s"}`, mdmHost.ID, mdmHost.DisplayName(), addedApp.Name, addedApp.AdamID, cmdUUID, fleet.SoftwareInstallerInstalled), 0) + s.lastActivityMatches( + fleet.ActivityInstalledAppStoreApp{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s"}`, + mdmHost.ID, + mdmHost.DisplayName(), + addedApp.Name, + addedApp.AdamID, + cmdUUID, + fleet.SoftwareInstallerInstalled, + ), + 0, + ) + + // Check list host software + + getHostSw := getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw) + gotSW := getHostSw.Software + require.Len(t, gotSW, 2) // App 1 and App 2 + got1, got2 := gotSW[0], gotSW[1] + require.Equal(t, got1.Name, "App 1") + require.NotNil(t, got1.AppStoreApp) + require.Equal(t, got1.AppStoreApp.AppStoreID, addedApp.AdamID) + require.Equal(t, got1.AppStoreApp.IconURL, ptr.String(addedApp.IconURL)) + require.Empty(t, got1.AppStoreApp.Name) // Name is only present for installer packages + require.Equal(t, got1.AppStoreApp.Version, addedApp.LatestVersion) + require.NotNil(t, *got1.Status) + require.Equal(t, *got1.Status, fleet.SoftwareInstallerInstalled) + require.Equal(t, got2.Name, "App 2") + require.NotNil(t, *got2.Status) + require.Equal(t, *got2.Status, fleet.SoftwareInstallerFailed) + require.NotNil(t, got2.AppStoreApp) + require.Equal(t, got2.AppStoreApp.AppStoreID, errApp.AdamID) + require.Equal(t, got2.AppStoreApp.IconURL, ptr.String(errApp.IconURL)) + require.Empty(t, got2.AppStoreApp.Name) + require.Equal(t, got2.AppStoreApp.Version, errApp.LatestVersion) + + // Check with a query + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw, "query", "App 1") + require.Len(t, getHostSw.Software, 1) // App 1 only + got1 = getHostSw.Software[0] + require.Equal(t, got1.Name, "App 1") + require.NotNil(t, got1.AppStoreApp) + require.NotNil(t, got1.AppStoreApp) + require.Equal(t, got1.AppStoreApp.AppStoreID, addedApp.AdamID) + require.Equal(t, got1.AppStoreApp.IconURL, ptr.String(addedApp.IconURL)) + require.Empty(t, got1.AppStoreApp.Name) + require.Equal(t, got1.AppStoreApp.Version, addedApp.LatestVersion) + require.NotNil(t, *got1.Status) + require.Equal(t, *got1.Status, fleet.SoftwareInstallerInstalled) // Delete VPP token and check that it's not appearing anymore s.Do("DELETE", "/api/latest/fleet/mdm/apple/vpp_token", &deleteMDMAppleVPPTokenRequest{}, http.StatusNoContent) From 06cbdeba36428a5753b9d02f88fd2f9b3b303beb Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Mon, 22 Jul 2024 16:07:31 -0500 Subject: [PATCH 32/38] Update software details modal on host details page to include VPP installs (#20638) --- .../SoftwareInstallDetails/_styles.scss | 4 + frontend/interfaces/software.ts | 10 ++ .../HostDetailsPage/HostDetailsPage.tsx | 1 + .../Software/HostSoftwareTableConfig.tsx | 12 +- .../SoftwareDetailsModal.tsx | 134 +++++++++++------- 5 files changed, 106 insertions(+), 55 deletions(-) diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss index 5e8df96d67..8cdc2ef600 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/_styles.scss @@ -7,6 +7,10 @@ align-items: center; gap: $pad-small; margin: 0; + .icon { + padding-top: 3px; + align-self: flex-start; + } } &__script-output { padding-top: $pad-xlarge; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 447ec6f81b..f6021202ad 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -291,3 +291,13 @@ export const INSTALL_STATUS_ICONS: Record = { installed: "success-outline", failed: "error-outline", } as const; + +export type IHostSoftwareWithLastInstall = IHostSoftware & { + last_install: ISoftwareLastInstall; +}; + +export const hasLastInstall = ( + software: IHostSoftware +): software is IHostSoftwareWithLastInstall => { + return !!software.last_install; +}; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 6e1d83d5a5..f1b2589b11 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -1046,6 +1046,7 @@ const HostDetailsPage = ({ )} {selectedSoftwareDetails && ( setSelectedSoftwareDetails(null)} /> diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index 5825f48146..b01806032f 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -4,7 +4,9 @@ import { CellProps, Column } from "react-table"; import { cloneDeep } from "lodash"; import { + IHostAppStoreApp, IHostSoftware, + IHostSoftwarePackage, SoftwareInstallStatus, formatSoftwareType, } from "interfaces/software"; @@ -53,14 +55,16 @@ const generateActions = ({ isFleetdHost, softwareId, status, - hasSoftwareToInstall, + software_package, + app_store_app, }: { canInstall: boolean; installingSoftwareId: number | null; isFleetdHost: boolean; softwareId: number; status: SoftwareInstallStatus | null; - hasSoftwareToInstall?: boolean; + software_package: IHostSoftwarePackage | null; + app_store_app: IHostAppStoreApp | null; }) => { // this gives us a clean slate of the default actions so we can modify // the options. @@ -73,6 +77,7 @@ const generateActions = ({ throw new Error("Install action not found in default actions"); } + const hasSoftwareToInstall = !!software_package || !!app_store_app; // remove install if there is no package to install if (!hasSoftwareToInstall || !canInstall) { actions.splice(indexInstallAction, 1); @@ -202,7 +207,8 @@ export const generateSoftwareTableHeaders = ({ installingSoftwareId, softwareId, status, - hasSoftwareToInstall: !!software_package || !!app_store_app, + software_package, + app_store_app, })} onChange={(action) => onSelectAction(original, action)} /> diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx b/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx index a3148cd521..78638520cb 100644 --- a/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx +++ b/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx @@ -3,8 +3,10 @@ import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; import { IHostSoftware, + IHostSoftwareWithLastInstall, ISoftwareInstallVersion, formatSoftwareType, + hasLastInstall, } from "interfaces/software"; import Modal from "components/Modal"; @@ -13,6 +15,7 @@ import Button from "components/buttons/Button"; import DataSet from "components/DataSet"; import { dateAgo } from "utilities/date_format"; +import { AppInstallDetails } from "components/ActivityDetails/InstallDetails/AppInstallDetails"; import { SoftwareInstallDetails } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails"; import TooltipTruncatedText from "components/TooltipTruncatedText"; @@ -88,69 +91,96 @@ const SoftwareDetailsInfo = ({ }; interface ISoftwareDetailsModalProps { + hostDisplayName: string; software: IHostSoftware; onExit: () => void; } +const SoftwareDetailsContent = ({ + software, +}: Pick) => { + const { installed_versions } = software; + + // special case when we dont have installed versions. We can only show the + // software type atm. + if (!installed_versions || installed_versions.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {installed_versions?.map((installedVersion) => { + return ( + + ); + })} +
+ ); +}; + +const TabsContent = ({ + hostDisplayName, + software, +}: { + hostDisplayName: string; + software: IHostSoftwareWithLastInstall; +}) => { + return ( + + + + Software details + Install details + + + + + + {software.app_store_app ? ( + + ) : ( + + )} + + + + ); +}; + const SoftwareDetailsModal = ({ + hostDisplayName, software, onExit, }: ISoftwareDetailsModalProps) => { - const installUuid = software.last_install?.install_uuid || ""; - - const renderSoftwareDetails = () => { - const { installed_versions } = software; - - // special case when we dont have installed versions. We can only show the - // software type atm. - if (!installed_versions || installed_versions.length === 0) { - return ( -
- -
- ); - } - - return ( -
- {installed_versions?.map((installedVersion) => { - return ( - - ); - })} -
- ); - }; - - const renderTabs = () => { - return ( - - - - Software details - Install Details - - {renderSoftwareDetails()} - - - - - - ); - }; - return ( <> - {software.last_install ? renderTabs() : renderSoftwareDetails()} + {!hasLastInstall(software) ? ( + + ) : ( + + )}
{data.software.map((s) => { - // concatenating install_uuid so item updates with fresh data on refetch - const key = `${s.id}${s.last_install?.install_uuid}`; + // TODO: update this if/when we support self-service app store apps + const uuid = + s.software_package?.last_install?.install_uuid || ""; + // concatenating uuid so item updates with fresh data on refetch + const key = `${s.id}${uuid}`; return ( { ); }; -type IInstallerStatusProps = Pick< - IHostSoftware, - "id" | "status" | "last_install" ->; +// TODO: update if/when we support self-service app store apps +type IInstallerStatusProps = Pick & { + last_install: ISoftwareLastInstall | null; +}; const InstallerStatus = ({ id, @@ -137,11 +138,14 @@ const getInstallButtonText = (status: SoftwareInstallStatus | null) => { const InstallerStatusAction = ({ deviceToken, - software: { id, status, last_install }, + software: { id, status, software_package }, onInstall, }: IInstallerStatusActionProps) => { const { renderFlash } = useContext(NotificationContext); + // TODO: update this if/when we support self-service app store apps + const last_install = software_package?.last_install || null; + // localStatus is used to track the status of the any user-initiated install action const [localStatus, setLocalStatus] = React.useState< SoftwareInstallStatus | undefined @@ -152,7 +156,7 @@ const InstallerStatusAction = ({ const displayStatus = localStatus || status; const installButtonText = getInstallButtonText(displayStatus); - // if the localStatus is "failed", we don't our tooltip to include the old installed_at date so we + // if the localStatus is "failed", we don't want our tooltip to include the old installed_at date so we // set this to null, which tells the tooltip to omit the parenthetical date const lastInstall = localStatus === "failed" ? null : last_install; diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx b/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx index 78638520cb..f8373d8c14 100644 --- a/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx +++ b/frontend/pages/hosts/details/cards/Software/SoftwareDetailsModal/SoftwareDetailsModal.tsx @@ -3,10 +3,10 @@ import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; import { IHostSoftware, - IHostSoftwareWithLastInstall, ISoftwareInstallVersion, formatSoftwareType, - hasLastInstall, + hasHostSoftwareAppLastInstall, + hasHostSoftwarePackageLastInstall, } from "interfaces/software"; import Modal from "components/Modal"; @@ -130,12 +130,40 @@ const SoftwareDetailsContent = ({ ); }; +const InstallDetailsContent = ({ + hostDisplayName, + software, +}: { + hostDisplayName: string; + software: IHostSoftware; +}) => { + if (hasHostSoftwareAppLastInstall(software)) { + return ( + + ); + } else if (hasHostSoftwarePackageLastInstall(software)) { + return ( + + ); + } + + // caller should ensure this nevers happen + return null; +}; + const TabsContent = ({ hostDisplayName, software, }: { hostDisplayName: string; - software: IHostSoftwareWithLastInstall; + software: IHostSoftware; }) => { return ( @@ -148,20 +176,10 @@ const TabsContent = ({ - {software.app_store_app ? ( - - ) : ( - - )} + @@ -173,10 +191,13 @@ const SoftwareDetailsModal = ({ software, onExit, }: ISoftwareDetailsModalProps) => { + const hasLastInstall = + hasHostSoftwarePackageLastInstall(software) || + hasHostSoftwareAppLastInstall(software); return ( <> - {!hasLastInstall(software) ? ( + {!hasLastInstall ? ( ) : (