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. [](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:
co-authored by
jkatz01
Jonathan Katz
parent
a9d3282904
commit
47cc3256c1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user