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()