From 47cc3256c1e63eb1587ea9e16cba3a7d408078a7 Mon Sep 17 00:00:00 2001 From: Carlo <1778532+cdcme@users.noreply.github.com> Date: Tue, 12 May 2026 13:59:16 -0400 Subject: [PATCH] iOS/iPadOS managed config: service wiring (#43965) (#44932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #38790. Stacked on top of #44931. Closes #43965. Wires managed configuration through the existing REST endpoints (`POST /api/.../app_store_apps`, `PATCH /api/.../software/titles/:id/app_store_app`, in-house `.ipa` upload / update). Validation runs at the service layer for iOS / iPadOS; macOS VPP installs silently drop the field. Wire format: a JSON-encoded string of the XML plist on POST/PATCH and on GET single-title responses (not base64). Includes `server/service/integration_apple_vpp_config_test.go` with end-to-end coverage: add / update with valid plist, allowed `$FLEET_VAR_HOST_UUID`, omit-field-no-change, **`configuration: null` → row deleted** (regression test for the clear-on-null fix in #43964), malformed XML → 422, disallowed Fleet variable → 422, and macOS silent-drop pre- / post-validation. Also drops a stray `fmt.Println("auth")` in `SoftwareTitleByID`'s authorization-failure branch. ## Summary by CodeRabbit * **New Features** * Added support for managed app configuration on iOS/iPadOS devices through VPP and in-house installers. * Configuration now validates plist format and detects disallowed Fleet variables. * **Bug Fixes** * macOS apps now correctly ignore configuration settings as expected. * **Tests** * Added comprehensive integration tests for Apple VPP and in-house installer configuration workflows. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/44932) --------- Co-authored-by: jkatz01 Co-authored-by: Jonathan Katz <44128041+jkatz01@users.noreply.github.com> --- ee/server/service/in_house_apps.go | 16 ++ ee/server/service/software_installers.go | 19 ++ ee/server/service/vpp.go | 74 ++++- server/datastore/mysql/android_test.go | 4 +- .../integration_apple_vpp_config_test.go | 259 ++++++++++++++++++ server/service/integration_enterprise_test.go | 85 ++++++ server/service/software_installers.go | 14 + server/service/software_titles.go | 23 +- server/service/testing_client.go | 3 + 9 files changed, 483 insertions(+), 14 deletions(-) create mode 100644 server/service/integration_apple_vpp_config_test.go diff --git a/ee/server/service/in_house_apps.go b/ee/server/service/in_house_apps.go index 9934ac1953..bcb13a7ade 100644 --- a/ee/server/service/in_house_apps.go +++ b/ee/server/service/in_house_apps.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/json" "fmt" "strings" "text/template" @@ -110,6 +111,12 @@ func (svc *Service) updateInHouseAppInstaller(ctx context.Context, payload *flee payload.SelfService = &existingInstaller.SelfService } + if len(payload.Configuration) > 0 { + if err := fleet.ValidateAppleAppConfiguration(payload.Configuration); err != nil { + return nil, err + } + } + // persist changes starting here, now that we've done all the validation/diffing we can if payloadForNewInstallerFile != nil { if err := svc.storeSoftware(ctx, payloadForNewInstallerFile); err != nil { @@ -155,6 +162,15 @@ func (svc *Service) updateInHouseAppInstaller(ctx context.Context, payload *flee } updatedInstaller.Status = &fleet.SoftwareInstallerStatusSummary{Installed: st.Installed, PendingInstall: st.Pending, FailedInstall: st.Failed} + // Wrap iOS / iPadOS plist as a JSON string for the response. + if len(updatedInstaller.Configuration) > 0 { + wrapped, err := json.Marshal(string(updatedInstaller.Configuration)) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "wrapping configuration for response") + } + updatedInstaller.Configuration = wrapped + } + return updatedInstaller, nil } diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index b820feaac2..4e95b7bfe8 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -89,6 +89,17 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return nil, ctxerr.Wrap(ctx, err, "adding metadata to payload") } + // Validate iOS/iPadOS managed app configuration up-front. For non-.ipa extensions, silently drop. + if payload.Extension == "ipa" { + if len(payload.Configuration) > 0 { + if err := fleet.ValidateAppleAppConfiguration(payload.Configuration); err != nil { + return nil, err + } + } + } else { + payload.Configuration = nil + } + // Validate install/post-install/uninstall script contents for non-script // packages. Script packages (.sh/.ps1) are already validated in // addScriptPackageMetadata. @@ -194,6 +205,14 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. if err != nil { return nil, err } + // Wrap iOS / iPadOS plist as a JSON string for the response. + if len(addedInstaller.Configuration) > 0 { + wrapped, err := json.Marshal(string(addedInstaller.Configuration)) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "wrapping configuration for response") + } + addedInstaller.Configuration = wrapped + } return addedInstaller, nil } diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index d3c316845b..0a4d94698e 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "encoding/base64" + "encoding/json" "errors" "fmt" "image/png" @@ -254,6 +255,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, LabelsIncludeAll: payload.LabelsIncludeAll, Categories: payload.Categories, DisplayName: payload.DisplayName, + Configuration: payload.Configuration, AutoUpdateEnabled: payload.AutoUpdateEnabled, AutoUpdateStartTime: payload.AutoUpdateStartTime, AutoUpdateEndTime: payload.AutoUpdateEndTime, @@ -268,6 +270,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, LabelsIncludeAll: payload.LabelsIncludeAll, Categories: payload.Categories, DisplayName: payload.DisplayName, + Configuration: payload.Configuration, AutoUpdateEnabled: payload.AutoUpdateEnabled, AutoUpdateStartTime: payload.AutoUpdateStartTime, AutoUpdateEndTime: payload.AutoUpdateEndTime, @@ -370,6 +373,16 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, appStoreApp.Configuration = payload.Configuration incomingAndroidApps = append(incomingAndroidApps, appStoreApp) case fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform: + if payload.Configuration != nil && payload.Platform != fleet.MacOSPlatform { + var plist string + if err := json.Unmarshal(payload.Configuration, &plist); err != nil { + return nil, fleet.NewInvalidArgumentError("configuration", "expected configuration as a JSON string containing the XML") + } + if err := fleet.ValidateAppleAppConfiguration([]byte(plist)); err != nil { + return nil, err + } + appStoreApp.Configuration = []byte(plist) + } incomingAppleApps = append(incomingAppleApps, appStoreApp) } @@ -863,8 +876,9 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee assetMD := assetMetadata[asset.AdamID] - // Configuration is an Android only feature - appID.Configuration = nil + if appID.Platform == fleet.MacOSPlatform { + appID.Configuration = nil + } platforms := apple_apps.ToVPPApps(assetMD) appFromApple, ok := platforms[appID.Platform] @@ -927,12 +941,24 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee var androidConfigChanged bool // note that if appID.Configuration is nil, InsertVPPAppWithTeam will ignore it (it will not // update or remove it), so here we ignore it too if it is nil. - if appID.Configuration != nil && appID.Platform == fleet.AndroidPlatform { - changed, err := svc.ds.HasAndroidAppConfigurationChanged(ctx, appID.AdamID, ptr.ValOrZero(teamID), appID.Configuration) - if err != nil { - return 0, "", ctxerr.Wrap(ctx, err, "checking android app configuration change") + if appID.Configuration != nil { + switch appID.Platform { + case fleet.AndroidPlatform: + changed, err := svc.ds.HasAndroidAppConfigurationChanged(ctx, appID.AdamID, ptr.ValOrZero(teamID), appID.Configuration) + if err != nil { + return 0, "", ctxerr.Wrap(ctx, err, "checking android app configuration change") + } + androidConfigChanged = changed + case fleet.IOSPlatform, fleet.IPadOSPlatform: + var plist string + if err := json.Unmarshal(appID.Configuration, &plist); err != nil { + return 0, "", fleet.NewInvalidArgumentError("configuration", "expected configuration as a JSON string containing the XML") + } + if err := fleet.ValidateAppleAppConfiguration([]byte(plist)); err != nil { + return 0, "", err + } + app.Configuration = []byte(plist) } - androidConfigChanged = changed } addedApp, err := svc.ds.InsertVPPAppWithTeam(ctx, app, teamID) @@ -967,7 +993,7 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee LabelsIncludeAny: actLabelsInclAny, LabelsExcludeAny: actLabelsExclAny, LabelsIncludeAll: actLabelsInclAll, - Configuration: app.Configuration, + Configuration: json.RawMessage(appID.Configuration), } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { @@ -1107,6 +1133,7 @@ func (svc *Service) getAnchoredVPPAppsMetadata(ctx context.Context, ids []fleet. Categories: props.Categories, CategoryIDs: props.CategoryIDs, DisplayName: props.DisplayName, + Configuration: props.Configuration, AutoUpdateEnabled: props.AutoUpdateEnabled, AutoUpdateStartTime: props.AutoUpdateStartTime, AutoUpdateEndTime: props.AutoUpdateEndTime, @@ -1190,10 +1217,23 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID if payload.SelfService != nil && meta.Platform != fleet.AndroidPlatform { selfServiceVal = *payload.SelfService } - if payload.Configuration != nil && meta.Platform != fleet.AndroidPlatform { + if meta.Platform == fleet.MacOSPlatform { payload.Configuration = nil } + // datastoreConfig holds the decoded plist for iOS/iPadOS; payload.Configuration stays in its incoming form for the activity below. + datastoreConfig := payload.Configuration + if payload.Configuration != nil && (meta.Platform == fleet.IOSPlatform || meta.Platform == fleet.IPadOSPlatform) { + var plist string + if err := json.Unmarshal(payload.Configuration, &plist); err != nil { + return nil, nil, fleet.NewInvalidArgumentError("configuration", "expected configuration as a JSON string containing the XML") + } + if err := fleet.ValidateAppleAppConfiguration([]byte(plist)); err != nil { + return nil, nil, err + } + datastoreConfig = []byte(plist) + } + appToWrite := &fleet.VPPApp{ VPPAppTeam: fleet.VPPAppTeam{ VPPAppID: fleet.VPPAppID{ @@ -1202,7 +1242,7 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID SelfService: selfServiceVal, ValidatedLabels: validatedLabels, DisplayName: payload.DisplayName, - Configuration: payload.Configuration, + Configuration: datastoreConfig, }, TeamID: teamID, TitleID: titleID, @@ -1342,7 +1382,7 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID LabelsIncludeAll: actLabelsInclAll, SoftwareIconURL: meta.IconURL, SoftwareDisplayName: displayNameVal, - Configuration: appToWrite.Configuration, + Configuration: payload.Configuration, } updatedAppMeta, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) @@ -1350,6 +1390,18 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: getting updated app metadata") } + // Wrap iOS / iPadOS plist as a JSON string for the response. + if len(updatedAppMeta.Configuration) > 0 { + switch updatedAppMeta.Platform { + case fleet.IOSPlatform, fleet.IPadOSPlatform: + wrapped, err := json.Marshal(string(updatedAppMeta.Configuration)) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "wrapping configuration for response") + } + updatedAppMeta.Configuration = wrapped + } + } + return updatedAppMeta, &act, nil } diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go index 75db800a8b..27e6af18e9 100644 --- a/server/datastore/mysql/android_test.go +++ b/server/datastore/mysql/android_test.go @@ -2730,7 +2730,7 @@ func testAddDeleteAndroidAppWithConfiguration(t *testing.T, ds *Datastore) { require.NotZero(t, meta.VPPAppsTeamsID) require.NotZero(t, meta.Configuration) require.Equal(t, "android1", meta.BundleIdentifier) - require.Equal(t, testConfig, meta.Configuration) + require.JSONEq(t, string(testConfig), string(meta.Configuration)) // Get ios app meta2, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, app2.TitleID) @@ -2747,7 +2747,7 @@ func testAddDeleteAndroidAppWithConfiguration(t *testing.T, ds *Datastore) { meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, app1.TitleID) require.NoError(t, err) require.NotZero(t, meta.VPPAppsTeamsID) - require.Equal(t, newConfig, meta.Configuration) + require.JSONEq(t, string(newConfig), string(meta.Configuration)) // Add invalid configuration badConfig := []byte(`"-": "-"`) diff --git a/server/service/integration_apple_vpp_config_test.go b/server/service/integration_apple_vpp_config_test.go new file mode 100644 index 0000000000..c4cd0637a7 --- /dev/null +++ b/server/service/integration_apple_vpp_config_test.go @@ -0,0 +1,259 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" + "github.com/fleetdm/fleet/v4/server/dev_mode" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +func (s *integrationMDMTestSuite) TestVPPAppleManagedAppConfiguration() { + t := s.T() + s.setSkipWorkerJobs(t) + ctx := context.Background() + + // VPP setup: token + team association. + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-apple-config-team"}) + require.NoError(t, err) + + orgName := "Fleet Device Management Inc." + token := "applemcptoken" + expDate := time.Now().Add(200 * time.Hour).UTC().Round(time.Second).Format(fleet.VPPTimeFormat) + tokenJSON := fmt.Sprintf(`{"expDate":%q,"token":%q,"orgName":%q}`, expDate, token, orgName) + dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t) + + // Adam IDs "2" and "3" come pre-registered by the mock VPP server with iOS/iPadOS metadata. + const iosAdamID = "2" + const ipadOSAdamID = "3" + + var validToken uploadVPPTokenResponse + s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", + []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &validToken) + + var getVPPTokenResp getVPPTokensResponse + s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &getVPPTokenResp) + + var resPatchVPP patchVPPTokensTeamsResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID), + patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP) + + const validPlist = `ServerURLhttps://example.com` + const validPlist2 = `ServerURLhttps://other.example.comHostUUID$FLEET_VAR_HOST_UUID` + + // Helper: encode an XML string as a JSON string (the form clients send). + asJSONString := func(s string) json.RawMessage { + b, err := json.Marshal(s) + require.NoError(t, err) + return json.RawMessage(b) + } + + // Helper: read the stored configuration directly from the datastore. + readStoredConfig := func(adamID string, platform fleet.InstallableDevicePlatform) []byte { + got, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), platform, adamID, team.ID) + require.NoError(t, err) + return got + } + + // 1. Add iOS app with valid plist configuration → 200, stored, activity emitted. + var addResp addAppStoreAppResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + TeamID: &team.ID, + AppStoreID: iosAdamID, + Platform: fleet.IOSPlatform, + Configuration: asJSONString(validPlist), + }, http.StatusOK, &addResp) + require.NotZero(t, addResp.TitleID) + + require.Equal(t, []byte(validPlist), readStoredConfig(iosAdamID, fleet.IOSPlatform)) + + // 2. Update iOS app with new configuration that includes an allowed Fleet variable. + var updResp updateAppStoreAppResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID), + &updateAppStoreAppRequest{ + TeamID: &team.ID, + Configuration: asJSONString(validPlist2), + }, http.StatusOK, &updResp) + require.Equal(t, []byte(validPlist2), readStoredConfig(iosAdamID, fleet.IOSPlatform)) + + // GET title returns the iOS configuration as a JSON string of plist; unmarshal to recover the raw plist bytes. + var titleResp getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addResp.TitleID), + &getSoftwareTitleRequest{ID: addResp.TitleID, TeamID: &team.ID}, + http.StatusOK, &titleResp, "fleet_id", fmt.Sprint(team.ID)) + require.NotNil(t, titleResp.SoftwareTitle.AppStoreApp) + var gotPlist string + require.NoError(t, json.Unmarshal(titleResp.SoftwareTitle.AppStoreApp.Configuration, &gotPlist)) + require.Equal(t, validPlist2, gotPlist) + + // 3. Update iOS app omitting `configuration` field → no change. + updResp = updateAppStoreAppResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID), + &updateAppStoreAppRequest{TeamID: &team.ID, SelfService: new(true)}, http.StatusOK, &updResp) + require.Equal(t, []byte(validPlist2), readStoredConfig(iosAdamID, fleet.IOSPlatform)) + + // 3b. Update iOS app with `configuration: null` → row deleted (clear semantics + // must match the batch path; previously the single-app PATCH stored empty bytes). + updResp = updateAppStoreAppResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID), + &updateAppStoreAppRequest{TeamID: &team.ID, Configuration: json.RawMessage(`null`)}, + http.StatusOK, &updResp) + _, err = s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, iosAdamID, team.ID) + require.True(t, fleet.IsNotFound(err), "expected configuration row to be deleted on null PATCH, got %v", err) + + // Re-set the configuration so the rest of the test continues with state. + updResp = updateAppStoreAppResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID), + &updateAppStoreAppRequest{TeamID: &team.ID, Configuration: asJSONString(validPlist2)}, + http.StatusOK, &updResp) + require.Equal(t, []byte(validPlist2), readStoredConfig(iosAdamID, fleet.IOSPlatform)) + + // 4. Add iPadOS app with malformed XML → 422. + res := s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + TeamID: &team.ID, + AppStoreID: ipadOSAdamID, + Platform: fleet.IPadOSPlatform, + Configuration: asJSONString(`not actually a plist`), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(res.Body), "invalid plist") + + // 5. Add iOS app with disallowed Fleet variable → 422. + res = s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + TeamID: &team.ID, + AppStoreID: ipadOSAdamID, + Platform: fleet.IPadOSPlatform, + Configuration: asJSONString(`K$FLEET_VAR_NDES_SCEP_CHALLENGE`), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(res.Body), "$FLEET_VAR_NDES_SCEP_CHALLENGE") + + // Update iOS app with malformed XML → 422. + res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID), + &updateAppStoreAppRequest{ + TeamID: &team.ID, + Configuration: asJSONString(`not actually a plist`), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(res.Body), "invalid plist") + + // Update iOS app with disallowed Fleet variable → 422. + res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID), + &updateAppStoreAppRequest{ + TeamID: &team.ID, + Configuration: asJSONString(`K$FLEET_VAR_NDES_SCEP_CHALLENGE`), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(res.Body), "$FLEET_VAR_NDES_SCEP_CHALLENGE") + + // macOS adam ID — pre-registered as a macOS-only app in the mock VPP server. + const macosAdamID = "1" + + requireNoStoredConfig := func(platform fleet.InstallableDevicePlatform, adamID string, teamID uint) { + _, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), platform, adamID, teamID) + require.True(t, fleet.IsNotFound(err), "expected not found, got %v", err) + } + + // 6. Add macOS app with configuration → 200, configuration silently dropped. + var addMacResp addAppStoreAppResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + TeamID: &team.ID, + AppStoreID: macosAdamID, + Platform: fleet.MacOSPlatform, + Configuration: asJSONString(validPlist), + }, http.StatusOK, &addMacResp) + require.NotZero(t, addMacResp.TitleID) + requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, team.ID) + + // 7. Update macOS app with configuration → 200, configuration still not stored. + var updMacResp updateAppStoreAppResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addMacResp.TitleID), + &updateAppStoreAppRequest{ + TeamID: &team.ID, + Configuration: asJSONString(validPlist), + }, http.StatusOK, &updMacResp) + requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, team.ID) + + // 8. Add macOS app with malformed XML → 200 (silent drop must come before validation). + const macosAdamIDInvalid = "2" + var addMacInvalidResp addAppStoreAppResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + TeamID: &team.ID, + AppStoreID: macosAdamIDInvalid, + Platform: fleet.MacOSPlatform, + Configuration: asJSONString(`not actually a plist`), + }, http.StatusOK, &addMacInvalidResp) + require.NotZero(t, addMacInvalidResp.TitleID) + requireNoStoredConfig(fleet.MacOSPlatform, macosAdamIDInvalid, team.ID) + + // 9. Update macOS app with malformed XML → 200, still no row. + var updMacInvalidResp updateAppStoreAppResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addMacResp.TitleID), + &updateAppStoreAppRequest{ + TeamID: &team.ID, + Configuration: asJSONString(`not actually a plist`), + }, http.StatusOK, &updMacInvalidResp) + requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, team.ID) + + t.Run("BatchAssociateVPPApps", func(t *testing.T) { + // Use a fresh team so batch "replace all" doesn't clobber the prior state. + batchTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-apple-config-batch-team"}) + require.NoError(t, err) + + var resPatchVPPBatch patchVPPTokensTeamsResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID), + patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, batchTeam.ID}}, http.StatusOK, &resPatchVPPBatch) + + var batchResp batchAssociateAppStoreAppsResponse + + // iOS in the same batch proves the path actually ran, so a missing macOS row isn't a no-op. + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", + batchAssociateAppStoreAppsRequest{ + Apps: []fleet.VPPBatchPayload{ + {AppStoreID: iosAdamID, Platform: fleet.IOSPlatform, Configuration: asJSONString(validPlist)}, + {AppStoreID: macosAdamID, Platform: fleet.MacOSPlatform, Configuration: asJSONString(validPlist)}, + }, + }, http.StatusOK, &batchResp, "fleet_name", batchTeam.Name) + + // iOS config IS stored — confirms the batch wrote configurations. + iosCfg, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, iosAdamID, batchTeam.ID) + require.NoError(t, err) + require.Equal(t, []byte(validPlist), iosCfg) + + // macOS config silently dropped. + requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, batchTeam.ID) + + // Sanity: macOS app IS associated — silent drop applies to config only, not the app association. + macMeta, err := s.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, macosAdamID, fleet.MacOSPlatform, &batchTeam.ID) + require.NoError(t, err) + require.Equal(t, macosAdamID, macMeta.AdamID) + + // Remove macOS via batch by omitting it from the payload. + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", + batchAssociateAppStoreAppsRequest{ + Apps: []fleet.VPPBatchPayload{ + {AppStoreID: iosAdamID, Platform: fleet.IOSPlatform, Configuration: asJSONString(validPlist)}, + }, + }, http.StatusOK, &batchResp, "fleet_name", batchTeam.Name) + + _, err = s.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, macosAdamID, fleet.MacOSPlatform, &batchTeam.ID) + require.True(t, fleet.IsNotFound(err), "expected macOS app to be removed, got %v", err) + + // Re-add macOS via batch with malformed plist → locks the silent-drop ordering for the batch path. + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", + batchAssociateAppStoreAppsRequest{ + Apps: []fleet.VPPBatchPayload{ + {AppStoreID: iosAdamID, Platform: fleet.IOSPlatform, Configuration: asJSONString(validPlist)}, + {AppStoreID: macosAdamID, Platform: fleet.MacOSPlatform, Configuration: asJSONString(`not actually a plist`)}, + }, + }, http.StatusOK, &batchResp, "fleet_name", batchTeam.Name) + + macMeta, err = s.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, macosAdamID, fleet.MacOSPlatform, &batchTeam.ID) + require.NoError(t, err) + require.Equal(t, macosAdamID, macMeta.AdamID) + requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, batchTeam.ID) + }) +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index abdbcdc72b..fd98e2a4f4 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -26271,6 +26271,91 @@ func (s *integrationEnterpriseTestSuite) TestInHouseAppCRUD() { // download the installer, not found anymore s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", *payload.TeamID)) }) + + t.Run("managed app configuration", func(t *testing.T) { + var teamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{Name: t.Name()}, http.StatusOK, &teamResp) + + const validPlist = `Kv` + const validPlist2 = `K2v2` + + // Upload .ipa with configuration. + payload := &fleet.UploadSoftwareInstallerPayload{ + TeamID: &teamResp.Team.ID, + Filename: "ipa_test2.ipa", + Version: "1.0.0", + StorageID: uuid.New().String(), + Configuration: []byte(validPlist), + } + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") + + var titleID, installerID uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + require.NoError(t, sqlx.GetContext(ctx, q, &titleID, `SELECT title_id FROM in_house_apps WHERE filename = ? AND platform = 'ios' AND team_id = ?`, payload.Filename, teamResp.Team.ID)) + require.NoError(t, sqlx.GetContext(ctx, q, &installerID, `SELECT id FROM in_house_apps WHERE filename = ? AND platform = 'ios' AND team_id = ?`, payload.Filename, teamResp.Team.ID)) + return nil + }) + + // Stored configuration matches what was sent. + storedCfg, err := s.ds.GetInHouseAppConfiguration(ctx, installerID) + require.NoError(t, err) + require.Equal(t, []byte(validPlist), storedCfg) + + // Update with a new configuration. + body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "team_id": {fmt.Sprintf("%d", teamResp.Team.ID)}, + "configuration": {validPlist2}, + }) + s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers) + + storedCfg, err = s.ds.GetInHouseAppConfiguration(ctx, installerID) + require.NoError(t, err) + require.Equal(t, []byte(validPlist2), storedCfg) + + // GET title returns the iOS configuration as a JSON string of plist; unmarshal to recover. + var titleResp getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), + &getSoftwareTitleRequest{ID: titleID, TeamID: &teamResp.Team.ID}, + http.StatusOK, &titleResp, "fleet_id", fmt.Sprint(teamResp.Team.ID)) + require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage) + var gotPlist string + require.NoError(t, json.Unmarshal(titleResp.SoftwareTitle.SoftwarePackage.Configuration, &gotPlist)) + require.Equal(t, validPlist2, gotPlist) + + // Update with empty configuration → cleared. + body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "team_id": {fmt.Sprintf("%d", teamResp.Team.ID)}, + "configuration": {""}, + }) + s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers) + + _, err = s.ds.GetInHouseAppConfiguration(ctx, installerID) + require.True(t, fleet.IsNotFound(err), "expected configuration cleared, got %v", err) + + // Update with invalid plist → 422. + body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "team_id": {fmt.Sprintf("%d", teamResp.Team.ID)}, + "configuration": {"not actually a plist"}, + }) + s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusUnprocessableEntity, headers) + + // Update with disallowed Fleet variable → 422. + body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "team_id": {fmt.Sprintf("%d", teamResp.Team.ID)}, + "configuration": {`K$FLEET_VAR_NDES_SCEP_CHALLENGE`}, + }) + s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusUnprocessableEntity, headers) + + // Upload with invalid plist → 422. + invalidPayload := &fleet.UploadSoftwareInstallerPayload{ + TeamID: &teamResp.Team.ID, + Filename: "ipa_test2.ipa", + Version: "1.0.0", + StorageID: uuid.New().String(), + Configuration: []byte("not actually a plist"), + } + s.uploadSoftwareInstaller(t, invalidPayload, http.StatusUnprocessableEntity, "invalid plist") + }) } func genDistributedReqWithLabelResults(host *fleet.Host, labelResults map[uint]*bool) submitDistributedQueryResultsRequestShim { diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 33a63bd233..93163f774f 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -37,6 +37,8 @@ type uploadSoftwareInstallerRequest struct { LabelsExcludeAny []string LabelsIncludeAll []string AutomaticInstall bool + // Configuration is the in-house app's managed app configuration as raw XML bytes (iOS / iPadOS only). + Configuration []byte } type updateSoftwareInstallerRequest struct { @@ -53,6 +55,8 @@ type updateSoftwareInstallerRequest struct { LabelsIncludeAll []string Categories []string DisplayName *string + // Configuration is the in-house app's managed app configuration as raw XML bytes (iOS / iPadOS only). nil means leave unchanged. + Configuration []byte } type uploadSoftwareInstallerResponse struct { @@ -136,6 +140,10 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.UninstallScript = &uninstallScriptMultipart[0] } + if cfg, ok := r.MultipartForm.Value["configuration"]; ok && len(cfg) > 0 { + decoded.Configuration = []byte(cfg[0]) + } + val, ok = r.MultipartForm.Value["self_service"] if ok && len(val) > 0 && val[0] != "" { parsed, err := strconv.ParseBool(val[0]) @@ -250,6 +258,7 @@ func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s LabelsIncludeAll: req.LabelsIncludeAll, Categories: req.Categories, DisplayName: req.DisplayName, + Configuration: req.Configuration, } if req.File != nil { ff, err := req.File.Open() @@ -358,6 +367,10 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.PostInstallScript = val[0] } + if cfg, ok := r.MultipartForm.Value["configuration"]; ok && len(cfg) > 0 { + decoded.Configuration = []byte(cfg[0]) + } + val, ok = r.MultipartForm.Value["self_service"] if ok && len(val) > 0 && val[0] != "" { parsed, err := strconv.ParseBool(val[0]) @@ -459,6 +472,7 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s LabelsExcludeAny: req.LabelsExcludeAny, LabelsIncludeAll: req.LabelsIncludeAll, AutomaticInstall: req.AutomaticInstall, + Configuration: req.Configuration, } installer, err := svc.UploadSoftwareInstaller(ctx, payload) diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 7ea03ddc6b..562223ca3c 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "net/http" "time" @@ -139,7 +140,6 @@ func getSoftwareTitleEndpoint(ctx context.Context, request interface{}, svc flee func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint) (*fleet.SoftwareTitle, error) { if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil { - fmt.Println("auth") return nil, err } @@ -230,6 +230,18 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return nil, ctxerr.Wrap(ctx, err, "get VPP app status summary") } meta.Status = summary + + // Wrap iOS / iPadOS plist as a JSON string for the response. + if len(meta.Configuration) > 0 { + switch meta.Platform { + case fleet.IOSPlatform, fleet.IPadOSPlatform: + wrapped, err := json.Marshal(string(meta.Configuration)) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "wrapping VPP configuration for response") + } + meta.Configuration = wrapped + } + } } software.AppStoreApp = meta } @@ -250,6 +262,15 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint PendingInstall: summary.Pending, FailedInstall: summary.Failed, } + + // Wrap iOS / iPadOS plist as a JSON string for the response. + if len(meta.Configuration) > 0 { + wrapped, err := json.Marshal(string(meta.Configuration)) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "wrapping in-house app configuration for response") + } + meta.Configuration = wrapped + } } software.SoftwarePackage = meta } diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 4ee06b16dc..44119daa47 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -881,6 +881,9 @@ func (ts *withServer) uploadSoftwareInstallerWithErrorNameReason( if payload.AutomaticInstall { require.NoError(t, w.WriteField("automatic_install", "true")) } + if payload.Configuration != nil { + require.NoError(t, w.WriteField("configuration", string(payload.Configuration))) + } w.Close()