iOS/iPadOS managed config: service wiring (#43965) (#44932)

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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

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

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: jkatz01 <yehonatankatz@gmail.com>
Co-authored-by: Jonathan Katz <44128041+jkatz01@users.noreply.github.com>
This commit is contained in:
Carlo
2026-05-12 13:59:16 -04:00
committed by GitHub
co-authored by jkatz01 Jonathan Katz
parent a9d3282904
commit 47cc3256c1
9 changed files with 483 additions and 14 deletions
+16
View File
@@ -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
}
+19
View File
@@ -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
}
+63 -11
View File
@@ -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
}
+2 -2
View File
@@ -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(`"-": "-"`)
@@ -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 = `<dict><key>ServerURL</key><string>https://example.com</string></dict>`
const validPlist2 = `<dict><key>ServerURL</key><string>https://other.example.com</string><key>HostUUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`
// 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(`<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`),
}, 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(`<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`),
}, 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)
})
}
@@ -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 = `<dict><key>K</key><string>v</string></dict>`
const validPlist2 = `<dict><key>K2</key><string>v2</string></dict>`
// 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": {`<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`},
})
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 {
+14
View File
@@ -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)
+22 -1
View File
@@ -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
}
+3
View File
@@ -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()