diff --git a/changes/45353-android-var-software-configs b/changes/45353-android-var-software-configs new file mode 100644 index 0000000000..06f23a12ca --- /dev/null +++ b/changes/45353-android-var-software-configs @@ -0,0 +1 @@ +- Added support for `$FLEET_VAR_HOST_*` variables in Android managed app configuration. \ No newline at end of file diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index 2dc363bbe8..857b856d62 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -377,6 +377,11 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, } appStoreApp.SelfService = true + if payload.Configuration != nil { + if err := fleet.ValidateAndroidAppConfiguration(payload.Configuration); err != nil { + return nil, nil, err + } + } appStoreApp.Configuration = payload.Configuration incomingAndroidApps = append(incomingAndroidApps, appStoreApp) case fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform: diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go index 26658dbcd3..a6160bff35 100644 --- a/server/datastore/mysql/android.go +++ b/server/datastore/mysql/android.go @@ -409,11 +409,15 @@ func (ds *Datastore) AndroidHostLite(ctx context.Context, enterpriseSpecificID s func (ds *Datastore) AndroidHostLiteByHostUUID(ctx context.Context, hostUUID string) (*fleet.AndroidHost, error) { type liteHost struct { - TeamID *uint `db:"team_id"` + TeamID *uint `db:"team_id"` + Platform string `db:"platform"` + HardwareSerial string `db:"hardware_serial"` *android.Device } stmt := `SELECT h.team_id, + h.platform, + h.hardware_serial, ad.id, ad.host_id, ad.device_id, @@ -433,9 +437,11 @@ func (ds *Datastore) AndroidHostLiteByHostUUID(ctx context.Context, hostUUID str } result := &fleet.AndroidHost{ Host: &fleet.Host{ - ID: host.Device.HostID, - UUID: hostUUID, - TeamID: host.TeamID, + ID: host.Device.HostID, + UUID: hostUUID, + TeamID: host.TeamID, + Platform: host.Platform, + HardwareSerial: host.HardwareSerial, }, Device: host.Device, } diff --git a/server/fleet/android.go b/server/fleet/android.go index d5224fecdc..ee994f12c0 100644 --- a/server/fleet/android.go +++ b/server/fleet/android.go @@ -6,11 +6,13 @@ import ( "errors" "fmt" "reflect" + "slices" "strings" "sync" "time" "github.com/fleetdm/fleet/v4/server/mdm" + "github.com/fleetdm/fleet/v4/server/variables" "google.golang.org/api/androidmanagement/v1" ) @@ -194,6 +196,20 @@ func IsAndroidPolicyFieldValid(fieldName string) bool { return policyFieldsCache[fieldName] } +// FleetVarsSupportedInAndroidAppConfig is the allow-list of Fleet variables that +// can appear in an Android managed app configuration JSON. +var FleetVarsSupportedInAndroidAppConfig = []FleetVarName{ + FleetVarHostUUID, + FleetVarHostHardwareSerial, + FleetVarHostPlatform, + FleetVarHostEndUserEmailIDP, + FleetVarHostEndUserIDPUsername, + FleetVarHostEndUserIDPUsernameLocalPart, + FleetVarHostEndUserIDPGroups, + FleetVarHostEndUserIDPDepartment, + FleetVarHostEndUserIDPFullname, +} + var validAndroidWorkProfileWidgets = map[string]struct{}{ "WORK_PROFILE_WIDGETS_UNSPECIFIED": {}, "WORK_PROFILE_WIDGETS_ALLOWED": {}, @@ -232,5 +248,13 @@ func ValidateAndroidAppConfiguration(config json.RawMessage) error { return &BadRequestError{Message: fmt.Sprintf(`Couldn't update configuration. "%s" is not a supported value for "workProfileWidget".`, cfg.WorkProfileWidgets)} } + for _, name := range variables.Find(string(config)) { + if !slices.Contains(FleetVarsSupportedInAndroidAppConfig, FleetVarName(name)) { + return &BadRequestError{ + Message: fmt.Sprintf("Couldn't update configuration. Unsupported variable $FLEET_VAR_%s.", name), + } + } + } + return nil } diff --git a/server/fleet/android_test.go b/server/fleet/android_test.go index 1e827643d6..0ea554b138 100644 --- a/server/fleet/android_test.go +++ b/server/fleet/android_test.go @@ -112,6 +112,44 @@ func TestValidateAndroidAppConfiguration(t *testing.T) { expectError: true, errorMsg: `Couldn't update configuration. Only "managedConfiguration" and "workProfileWidgets" are supported as top-level keys.`, }, + // Fleet variable tests + { + name: "valid - supported Fleet variable HOST_UUID", + config: json.RawMessage(`{"managedConfiguration": {"deviceId": "$FLEET_VAR_HOST_UUID"}}`), + expectError: false, + }, + { + name: "valid - supported Fleet variable with braces", + config: json.RawMessage(`{"managedConfiguration": {"deviceId": "${FLEET_VAR_HOST_UUID}"}}`), + expectError: false, + }, + { + name: "valid - multiple supported Fleet variables", + config: json.RawMessage(`{"managedConfiguration": {"uuid": "$FLEET_VAR_HOST_UUID", "serial": "$FLEET_VAR_HOST_HARDWARE_SERIAL", "user": "$FLEET_VAR_HOST_END_USER_IDP_USERNAME"}}`), + expectError: false, + }, + { + name: "valid - HOST_PLATFORM variable", + config: json.RawMessage(`{"managedConfiguration": {"platform": "$FLEET_VAR_HOST_PLATFORM"}}`), + expectError: false, + }, + { + name: "valid - all IDP variables", + config: json.RawMessage(`{"managedConfiguration": {"email": "$FLEET_VAR_HOST_END_USER_EMAIL_IDP", "user": "$FLEET_VAR_HOST_END_USER_IDP_USERNAME", "local": "$FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART", "groups": "$FLEET_VAR_HOST_END_USER_IDP_GROUPS", "dept": "$FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT", "name": "$FLEET_VAR_HOST_END_USER_IDP_FULL_NAME"}}`), + expectError: false, + }, + { + name: "invalid - unsupported Fleet variable NDES_SCEP_CHALLENGE", + config: json.RawMessage(`{"managedConfiguration": {"challenge": "$FLEET_VAR_NDES_SCEP_CHALLENGE"}}`), + expectError: true, + errorMsg: "Couldn't update configuration. Unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE.", + }, + { + name: "invalid - unsupported Fleet variable CUSTOM_SCEP", + config: json.RawMessage(`{"managedConfiguration": {"url": "$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_MyCA"}}`), + expectError: true, + errorMsg: "Couldn't update configuration. Unsupported variable $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_MyCA.", + }, } for _, tt := range tests { diff --git a/server/mdm/profiles/android_appconfig.go b/server/mdm/profiles/android_appconfig.go new file mode 100644 index 0000000000..ba1eee2c31 --- /dev/null +++ b/server/mdm/profiles/android_appconfig.go @@ -0,0 +1,135 @@ +package profiles + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/variables" +) + +// ErrUnresolvableAndroidAppConfigVar signals that one of the $FLEET_VAR_* +// tokens referenced in an Android managed app configuration could not be +// resolved for the target host. +var ErrUnresolvableAndroidAppConfigVar = errors.New("android: unresolvable Fleet variable in managed app configuration") + +// UnresolvableAndroidAppConfigVarError carries the unresolved variable name +// and a user-facing detail message. +type UnresolvableAndroidAppConfigVarError struct { + FleetVar string + Detail string +} + +func (e *UnresolvableAndroidAppConfigVarError) Error() string { + if e.Detail != "" { + return e.Detail + } + return fmt.Sprintf("android: unresolvable Fleet variable $FLEET_VAR_%s", e.FleetVar) +} + +func (e *UnresolvableAndroidAppConfigVarError) Is(target error) bool { + return target == ErrUnresolvableAndroidAppConfigVar +} + +// AndroidAppConfigSubstitutionHost carries the host context needed to +// substitute host-scoped $FLEET_VAR_* tokens in Android managed app +// configuration. +type AndroidAppConfigSubstitutionHost struct { + UUID string + HardwareSerial string + Platform string +} + +// SubstituteFleetVarsInAndroidAppConfig replaces every supported $FLEET_VAR_* +// token in config with the resolved value for the given host, returning the +// substituted bytes. End-user IDP fields are looked up via ds. +// Returns ErrUnresolvableAndroidAppConfigVar (wrapped) if the host can't +// supply a referenced variable. +func SubstituteFleetVarsInAndroidAppConfig( + ctx context.Context, + ds fleet.Datastore, + config []byte, + host AndroidAppConfigSubstitutionHost, +) ([]byte, error) { + if len(config) == 0 { + return config, nil + } + used := variables.Find(string(config)) + if len(used) == 0 { + return config, nil + } + + contents := string(config) + idpUUIDCache := map[string]uint{} + + for _, name := range used { + switch fleet.FleetVarName(name) { + case fleet.FleetVarHostUUID: + contents = replaceJSONSafe(contents, name, host.UUID) + + case fleet.FleetVarHostHardwareSerial: + if host.HardwareSerial == "" { + return nil, &UnresolvableAndroidAppConfigVarError{ + FleetVar: name, + Detail: fmt.Sprintf("There is no serial number for this host. Fleet couldn't populate $FLEET_VAR_%s.", name), + } + } + contents = replaceJSONSafe(contents, name, host.HardwareSerial) + + case fleet.FleetVarHostPlatform: + contents = replaceJSONSafe(contents, name, host.Platform) + + case fleet.FleetVarHostEndUserEmailIDP: + emails, err := ds.GetHostEmails(ctx, host.UUID, fleet.DeviceMappingMDMIdpAccounts) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host idp email for android app config") + } + if len(emails) == 0 { + return nil, &UnresolvableAndroidAppConfigVarError{ + FleetVar: name, + Detail: fmt.Sprintf("There is no IdP email for this host. Fleet couldn't populate $FLEET_VAR_%s.", name), + } + } + contents = replaceJSONSafe(contents, name, emails[0]) + + case fleet.FleetVarHostEndUserIDPUsername, + fleet.FleetVarHostEndUserIDPUsernameLocalPart, + fleet.FleetVarHostEndUserIDPGroups, + fleet.FleetVarHostEndUserIDPDepartment, + fleet.FleetVarHostEndUserIDPFullname: + var detail string + value, _, ok, err := ResolveHostEndUserIDPValue( + ctx, ds, name, host.UUID, idpUUIDCache, + func(d string) error { detail = d; return nil }, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "resolve host idp variable for android app config") + } + if !ok { + return nil, &UnresolvableAndroidAppConfigVarError{FleetVar: name, Detail: detail} + } + contents = replaceJSONSafe(contents, name, value) + + default: + return nil, &UnresolvableAndroidAppConfigVarError{FleetVar: name} + } + } + + return []byte(contents), nil +} + +// replaceJSONSafe replaces a Fleet variable in contents with a JSON-safe value. +func replaceJSONSafe(contents, variableName, value string) string { + return variables.Replace(contents, variableName, jsonEscapeString(value)) +} + +// jsonEscapeString returns value with JSON special characters escaped, +// suitable for embedding inside a JSON string literal. +func jsonEscapeString(s string) string { + b, _ := json.Marshal(s) // json.Marshal for strings never errors + return strings.TrimSuffix(strings.TrimPrefix(string(b), `"`), `"`) +} diff --git a/server/mdm/profiles/android_appconfig_test.go b/server/mdm/profiles/android_appconfig_test.go new file mode 100644 index 0000000000..d10ea4a739 --- /dev/null +++ b/server/mdm/profiles/android_appconfig_test.go @@ -0,0 +1,198 @@ +package profiles + +import ( + "context" + "encoding/json" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/stretchr/testify/require" +) + +func TestSubstituteFleetVarsInAndroidAppConfig(t *testing.T) { + ctx := t.Context() + host := AndroidAppConfigSubstitutionHost{ + UUID: "host-uuid-1", + HardwareSerial: "ABC123", + Platform: "android", + } + emptyDS := new(mock.Store) + + t.Run("nil config returns nil", func(t *testing.T) { + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, emptyDS, nil, host) + require.NoError(t, err) + require.Nil(t, got) + }) + + t.Run("empty config returns empty", func(t *testing.T) { + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, emptyDS, []byte{}, host) + require.NoError(t, err) + require.Empty(t, got) + }) + + t.Run("config without variables returns unchanged", func(t *testing.T) { + cfg := []byte(`{"managedConfiguration": {"key": "plain"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, emptyDS, cfg, host) + require.NoError(t, err) + require.Equal(t, cfg, got) + }) + + t.Run("HOST_UUID substituted", func(t *testing.T) { + cfg := []byte(`{"managedConfiguration": {"deviceId": "$FLEET_VAR_HOST_UUID"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, emptyDS, cfg, host) + require.NoError(t, err) + require.Contains(t, string(got), "host-uuid-1") + require.NotContains(t, string(got), "$FLEET_VAR_HOST_UUID") + // Verify it's still valid JSON + require.True(t, json.Valid(got)) + }) + + t.Run("HOST_UUID with braces substituted", func(t *testing.T) { + cfg := []byte(`{"managedConfiguration": {"deviceId": "${FLEET_VAR_HOST_UUID}"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, emptyDS, cfg, host) + require.NoError(t, err) + require.Contains(t, string(got), "host-uuid-1") + require.NotContains(t, string(got), "${FLEET_VAR_HOST_UUID}") + }) + + t.Run("HOST_HARDWARE_SERIAL substituted", func(t *testing.T) { + cfg := []byte(`{"managedConfiguration": {"serial": "$FLEET_VAR_HOST_HARDWARE_SERIAL"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, emptyDS, cfg, host) + require.NoError(t, err) + require.Contains(t, string(got), "ABC123") + }) + + t.Run("HOST_HARDWARE_SERIAL empty returns error", func(t *testing.T) { + noSerialHost := host + noSerialHost.HardwareSerial = "" + cfg := []byte(`{"managedConfiguration": {"serial": "$FLEET_VAR_HOST_HARDWARE_SERIAL"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, emptyDS, cfg, noSerialHost) + require.ErrorIs(t, err, ErrUnresolvableAndroidAppConfigVar) + require.Nil(t, got) + }) + + t.Run("HOST_PLATFORM substituted", func(t *testing.T) { + cfg := []byte(`{"managedConfiguration": {"platform": "$FLEET_VAR_HOST_PLATFORM"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, emptyDS, cfg, host) + require.NoError(t, err) + require.Contains(t, string(got), "android") + }) + + t.Run("HOST_END_USER_EMAIL_IDP resolves via datastore", func(t *testing.T) { + ds := new(mock.Store) + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + require.Equal(t, "host-uuid-1", hostUUID) + return []string{"user@example.com"}, nil + } + cfg := []byte(`{"managedConfiguration": {"email": "$FLEET_VAR_HOST_END_USER_EMAIL_IDP"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, ds, cfg, host) + require.NoError(t, err) + require.Contains(t, string(got), "user@example.com") + require.True(t, json.Valid(got)) + }) + + t.Run("HOST_END_USER_EMAIL_IDP missing returns error", func(t *testing.T) { + ds := new(mock.Store) + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return nil, nil + } + cfg := []byte(`{"managedConfiguration": {"email": "$FLEET_VAR_HOST_END_USER_EMAIL_IDP"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, ds, cfg, host) + require.ErrorIs(t, err, ErrUnresolvableAndroidAppConfigVar) + require.Nil(t, got) + }) + + t.Run("multiple variables substituted independently", func(t *testing.T) { + ds := new(mock.Store) + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return []string{"user@example.com"}, nil + } + cfg := []byte(`{"managedConfiguration": {"uuid": "$FLEET_VAR_HOST_UUID", "serial": "$FLEET_VAR_HOST_HARDWARE_SERIAL", "email": "$FLEET_VAR_HOST_END_USER_EMAIL_IDP"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, ds, cfg, host) + require.NoError(t, err) + s := string(got) + require.Contains(t, s, "host-uuid-1") + require.Contains(t, s, "ABC123") + require.Contains(t, s, "user@example.com") + require.True(t, json.Valid(got)) + }) + + t.Run("JSON special chars in value are escaped", func(t *testing.T) { + ds := new(mock.Store) + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return []string{`user"with\special`}, nil + } + cfg := []byte(`{"managedConfiguration": {"email": "$FLEET_VAR_HOST_END_USER_EMAIL_IDP"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, ds, cfg, host) + require.NoError(t, err) + require.True(t, json.Valid(got), "result must be valid JSON: %s", string(got)) + // Parse and verify the value round-trips correctly + var parsed map[string]map[string]string + require.NoError(t, json.Unmarshal(got, &parsed)) + require.Equal(t, `user"with\special`, parsed["managedConfiguration"]["email"]) + }) + + t.Run("IDP username substituted", func(t *testing.T) { + ds := new(mock.Store) + ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, identifiers []string) ([]uint, error) { + return []uint{42}, nil + } + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + return &fleet.ScimUser{UserName: "jdoe@example.com", GivenName: new("John"), FamilyName: new("Doe")}, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + cfg := []byte(`{"managedConfiguration": {"user": "$FLEET_VAR_HOST_END_USER_IDP_USERNAME"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, ds, cfg, host) + require.NoError(t, err) + require.Contains(t, string(got), "jdoe@example.com") + require.True(t, json.Valid(got)) + }) + + t.Run("IDP username local part substituted", func(t *testing.T) { + ds := new(mock.Store) + ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, identifiers []string) ([]uint, error) { + return []uint{42}, nil + } + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + return &fleet.ScimUser{UserName: "jdoe@example.com", GivenName: new("John"), FamilyName: new("Doe")}, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + cfg := []byte(`{"managedConfiguration": {"user": "$FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, ds, cfg, host) + require.NoError(t, err) + require.Contains(t, string(got), "jdoe") + require.NotContains(t, string(got), "@example.com") + require.True(t, json.Valid(got)) + }) + + t.Run("unsupported variable returns error", func(t *testing.T) { + cfg := []byte(`{"managedConfiguration": {"chal": "$FLEET_VAR_NDES_SCEP_CHALLENGE"}}`) + got, err := SubstituteFleetVarsInAndroidAppConfig(ctx, emptyDS, cfg, host) + require.ErrorIs(t, err, ErrUnresolvableAndroidAppConfigVar) + require.Nil(t, got) + }) +} + +func TestJsonEscapeString(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"plain", "plain"}, + {`has "quotes"`, `has \"quotes\"`}, + {"has\\backslash", "has\\\\backslash"}, + {"has\nnewline", "has\\nnewline"}, + {"has\ttab", "has\\ttab"}, + {"normal-uuid-123", "normal-uuid-123"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + require.Equal(t, tt.expected, jsonEscapeString(tt.input)) + }) + } +} diff --git a/server/mdm/profiles/profile_variables.go b/server/mdm/profiles/profile_variables.go index f65d325119..5baa8e41a2 100644 --- a/server/mdm/profiles/profile_variables.go +++ b/server/mdm/profiles/profile_variables.go @@ -83,16 +83,31 @@ func ReplaceHostEndUserIDPVariables(ctx context.Context, ds fleet.Datastore, hostIDForUUIDCache map[string]uint, onError func(errMsg string) error, ) (replacedContents string, replacedVariable bool, err error) { - user, ok, err := getHostEndUserIDPUser(ctx, ds, hostUUID, fleetVar, hostIDForUUIDCache, onError) + value, rx, ok, err := ResolveHostEndUserIDPValue(ctx, ds, fleetVar, hostUUID, hostIDForUUIDCache, onError) if err != nil { return "", false, err } if !ok { return "", false, nil } + replacedContents = ReplaceFleetVariableInXML(rx, profileContents, value) + return replacedContents, true, nil +} + +// ResolveHostEndUserIDPValue resolves the raw string value for the given IDP fleet variable and host. +func ResolveHostEndUserIDPValue(ctx context.Context, ds fleet.Datastore, + fleetVar string, hostUUID string, + hostIDForUUIDCache map[string]uint, + onError func(errMsg string) error, +) (value string, rx *regexp.Regexp, ok bool, err error) { + user, userOK, err := getHostEndUserIDPUser(ctx, ds, hostUUID, fleetVar, hostIDForUUIDCache, onError) + if err != nil { + return "", nil, false, err + } + if !userOK { + return "", nil, false, nil + } - var rx *regexp.Regexp - var value string switch fleetVar { case string(fleet.FleetVarHostEndUserIDPUsername): rx = fleet.FleetVarHostEndUserIDPUsernameRegexp @@ -109,10 +124,10 @@ func ReplaceHostEndUserIDPVariables(ctx context.Context, ds fleet.Datastore, case string(fleet.FleetVarHostEndUserIDPFullname): rx = fleet.FleetVarHostEndUserIDPFullnameRegexp value = strings.TrimSpace(user.IdpFullName) + default: + return "", nil, false, nil } - replacedContents = ReplaceFleetVariableInXML(rx, profileContents, value) - - return replacedContents, true, nil + return value, rx, true, nil } func getHostEndUserIDPUser(ctx context.Context, ds fleet.Datastore, diff --git a/server/service/integration_android_software_test.go b/server/service/integration_android_software_test.go index 8e7f71c082..655cd30bed 100644 --- a/server/service/integration_android_software_test.go +++ b/server/service/integration_android_software_test.go @@ -1403,6 +1403,82 @@ func (s *integrationMDMTestSuite) TestAndroidWebAppsDuplicateName() { }, http.StatusOK, &addResp2) } +func (s *integrationMDMTestSuite) TestAndroidAppConfigFleetVariables() { + t := s.T() + + s.enableAndroidMDM(t) + s.setVPPTokenForTeam(0) + + s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) { + return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: "Duo"}, nil + } + + // ---- Supported variables should be accepted ---- + + supportedConfig := json.RawMessage(`{"managedConfiguration": {"deviceId": "$FLEET_VAR_HOST_UUID", "serial": "${FLEET_VAR_HOST_HARDWARE_SERIAL}"}}`) + var addResp addAppStoreAppResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", + &addAppStoreAppRequest{ + AppStoreID: "com.duo.security", + Platform: fleet.AndroidPlatform, + Configuration: supportedConfig, + }, + http.StatusOK, &addResp, + ) + + // Verify the config was stored correctly with variables (not yet substituted) + var titleResp getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addResp.TitleID), + &getSoftwareTitleRequest{ID: addResp.TitleID}, + http.StatusOK, &titleResp, + ) + require.Contains(t, string(titleResp.SoftwareTitle.AppStoreApp.Configuration), "$FLEET_VAR_HOST_UUID") + require.Contains(t, string(titleResp.SoftwareTitle.AppStoreApp.Configuration), "${FLEET_VAR_HOST_HARDWARE_SERIAL}") + + // ---- Unsupported variables should be rejected ---- + + unsupportedConfig := json.RawMessage(`{"managedConfiguration": {"chal": "$FLEET_VAR_NDES_SCEP_CHALLENGE"}}`) + r := s.Do("POST", "/api/latest/fleet/software/app_store_apps", + &addAppStoreAppRequest{ + AppStoreID: "com.unsupported.var", + Platform: fleet.AndroidPlatform, + Configuration: unsupportedConfig, + }, + http.StatusBadRequest, + ) + require.Contains(t, extractServerErrorText(r.Body), "Unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE") + + // ---- Batch (GitOps) path: unsupported variables rejected ---- + + batchUnsupported := []fleet.VPPBatchPayload{ + { + AppStoreID: "com.batch.unsupported", + Platform: fleet.AndroidPlatform, + SelfService: true, + Configuration: json.RawMessage(`{"managedConfiguration": {"url": "$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_MyCA"}}`), + }, + } + s.Do("POST", "/api/latest/fleet/software/app_store_apps/batch", + &batchAssociateAppStoreAppsRequest{Apps: batchUnsupported}, + http.StatusBadRequest, + ) + + // ---- Batch (GitOps) path: supported variables accepted ---- + + batchSupported := []fleet.VPPBatchPayload{ + { + AppStoreID: "com.batch.supported", + Platform: fleet.AndroidPlatform, + SelfService: true, + Configuration: json.RawMessage(`{"managedConfiguration": {"uuid": "$FLEET_VAR_HOST_UUID"}}`), + }, + } + s.Do("POST", "/api/latest/fleet/software/app_store_apps/batch", + &batchAssociateAppStoreAppsRequest{Apps: batchSupported, DryRun: true}, + http.StatusOK, + ) +} + func (s *integrationMDMTestSuite) TestAndroidPubSubStatusReport_MissingHardwareInfo() { ctx := context.Background() t := s.T() diff --git a/server/worker/software_worker.go b/server/worker/software_worker.go index a77f33a6c3..2dad71c8f9 100644 --- a/server/worker/software_worker.go +++ b/server/worker/software_worker.go @@ -10,7 +10,9 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/android" + "github.com/fleetdm/fleet/v4/server/mdm/profiles" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/variables" "github.com/google/uuid" "google.golang.org/api/androidmanagement/v1" "google.golang.org/api/googleapi" @@ -154,6 +156,12 @@ func (v *SoftwareWorker) makeAndroidAppAvailable(ctx context.Context, applicatio } } + needsPerHostSubstitution := config != nil && variables.ContainsBytes(config) + + if needsPerHostSubstitution { + return v.makeAndroidAppAvailablePerHost(ctx, applicationID, configByAppID, hosts, enterpriseName, appConfigChanged) + } + appPolicies, err := buildApplicationPolicyWithConfig(ctx, []string{applicationID}, configByAppID, "AVAILABLE") if err != nil { return ctxerr.Wrap(ctx, err, "building application policies with config") @@ -192,6 +200,72 @@ func (v *SoftwareWorker) makeAndroidAppAvailable(ctx context.Context, applicatio return nil } +// makeAndroidAppAvailablePerHost handles the case where the app config +// contains $FLEET_VAR_HOST_* tokens that must be substituted per-host. +func (v *SoftwareWorker) makeAndroidAppAvailablePerHost( + ctx context.Context, + applicationID string, + configByAppID map[string][]byte, + hosts map[string]string, + enterpriseName string, + appConfigChanged bool, +) error { + // Batch-fetch host details for substitution. + hostUUIDs := make([]string, 0, len(hosts)) + for uuid := range hosts { + hostUUIDs = append(hostUUIDs, uuid) + } + filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: new("admin")}} + hostDetails, err := v.Datastore.ListHostsLiteByUUIDs(ctx, filter, hostUUIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "list hosts lite by uuids for fleet var substitution") + } + hostByUUID := make(map[string]*fleet.Host, len(hostDetails)) + for _, h := range hostDetails { + hostByUUID[h.UUID] = h + } + + for hostUUID := range hosts { + h, ok := hostByUUID[hostUUID] + if !ok { + continue // host may have been deleted since the job was queued + } + + subHost := profiles.AndroidAppConfigSubstitutionHost{ + UUID: h.UUID, + HardwareSerial: h.HardwareSerial, + Platform: h.Platform, + } + + substituted, err := v.substituteFleetVarsInConfigs(ctx, configByAppID, subHost) + if err != nil { + return ctxerr.Wrapf(ctx, err, "substitute fleet vars for host %s", hostUUID) + } + + appPolicies, err := buildApplicationPolicyWithConfig(ctx, []string{applicationID}, substituted, "AVAILABLE") + if err != nil { + return ctxerr.Wrapf(ctx, err, "building application policies with config for host %s", hostUUID) + } + + singleHost := map[string]string{hostUUID: hosts[hostUUID]} + policyRequestsByHost, err := v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appPolicies, singleHost) + if err != nil { + return ctxerr.Wrapf(ctx, err, "add app to android policy for host %s", hostUUID) + } + + if appConfigChanged { + for uuid, policyRequest := range policyRequestsByHost { + err := v.Datastore.SetAndroidAppInstallPendingApplyConfig(ctx, uuid, applicationID, policyRequest.PolicyVersion.V) + if err != nil { + return ctxerr.Wrapf(ctx, err, "set android app install pending apply config for host %s and app %s", uuid, applicationID) + } + } + } + } + + return nil +} + // this is called when an app is removed from Fleet. func (v *SoftwareWorker) makeAndroidAppUnavailable(ctx context.Context, applicationID string, hostUUIDToPolicyID map[string]string, enterpriseName string) error { // Update Android MDM policy to remove the app from the hosts @@ -280,6 +354,11 @@ func (v *SoftwareWorker) makeAndroidAppsAvailableForHost(ctx context.Context, ho return ctxerr.Wrap(ctx, err, "bulk get android app configurations") } + configsByAppID, err = v.substituteFleetVarsInConfigs(ctx, configsByAppID, androidHostToSubstitutionHost(androidHost)) + if err != nil { + return ctxerr.Wrap(ctx, err, "substitute fleet vars in app configs") + } + appPolicies, err := buildApplicationPolicyWithConfig(ctx, appIDs, configsByAppID, "AVAILABLE") if err != nil { return ctxerr.Wrap(ctx, err, "building application policies with config") @@ -339,6 +418,11 @@ func (v *SoftwareWorker) runAndroidSetupExperience(ctx context.Context, return ctxerr.Wrap(ctx, err, "bulk get android app configurations") } + configsByAppID, err = v.substituteFleetVarsInConfigs(ctx, configsByAppID, androidHostToSubstitutionHost(host)) + if err != nil { + return ctxerr.Wrap(ctx, err, "substitute fleet vars in app configs") + } + appPolicies, err := buildApplicationPolicyWithConfig(ctx, appIDs, configsByAppID, "PREINSTALLED") if err != nil { return ctxerr.Wrap(ctx, err, "building application policies with config") @@ -391,6 +475,11 @@ func (v *SoftwareWorker) bulkMakeAndroidAppsAvailableForHost(ctx context.Context return ctxerr.Wrap(ctx, err, "bulk get android app configurations") } + configsByAppID, err = v.substituteFleetVarsInConfigs(ctx, configsByAppID, androidHostToSubstitutionHost(host)) + if err != nil { + return ctxerr.Wrap(ctx, err, "substitute fleet vars in app configs") + } + appPolicies, err := buildApplicationPolicyWithConfig(ctx, applicationIDs, configsByAppID, "AVAILABLE") if err != nil { return ctxerr.Wrap(ctx, err, "building application policies with config") @@ -443,6 +532,45 @@ func buildApplicationPolicyWithConfig(ctx context.Context, appIDs []string, return appPolicies, nil } +func (v *SoftwareWorker) substituteFleetVarsInConfigs( + ctx context.Context, + configsByAppID map[string][]byte, + host profiles.AndroidAppConfigSubstitutionHost, +) (map[string][]byte, error) { + if len(configsByAppID) == 0 { + return configsByAppID, nil + } + + hasVars := false + for _, cfg := range configsByAppID { + if variables.ContainsBytes(cfg) { + hasVars = true + break + } + } + if !hasVars { + return configsByAppID, nil + } + + result := make(map[string][]byte, len(configsByAppID)) + for appID, cfg := range configsByAppID { + substituted, err := profiles.SubstituteFleetVarsInAndroidAppConfig(ctx, v.Datastore, cfg, host) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "substitute fleet vars in android app config for app %s", appID) + } + result[appID] = substituted + } + return result, nil +} + +func androidHostToSubstitutionHost(h *fleet.AndroidHost) profiles.AndroidAppConfigSubstitutionHost { + return profiles.AndroidAppConfigSubstitutionHost{ + UUID: h.Host.UUID, + HardwareSerial: h.Host.HardwareSerial, + Platform: h.Host.Platform, + } +} + func QueueRunAndroidSetupExperience(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, hostUUID string, hostEnrollTeamID *uint, enterpriseName string, ) error { @@ -586,6 +714,11 @@ func (v *SoftwareWorker) bulkSetAndroidAppsAvailableForHosts(ctx context.Context return ctxerr.Wrap(ctx, err, "bulk get android app configurations") } + configsByAppID, err = v.substituteFleetVarsInConfigs(ctx, configsByAppID, androidHostToSubstitutionHost(androidHost)) + if err != nil { + return ctxerr.Wrap(ctx, err, "substitute fleet vars in app configs") + } + appPolicies, err := buildApplicationPolicyWithConfig(ctx, appIDs, configsByAppID, "AVAILABLE") if err != nil { return ctxerr.Wrap(ctx, err, "building application policies with config")