Added FLEET_VAR_HOST to android configs (#47642)

**Related issue:** Resolves #45353

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually

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

## Release Notes

* **New Features**
* Added support for `$FLEET_VAR_HOST_*` variables in Android managed app
configurations, including host UUID, hardware serial, platform, and
end-user IdP details.

* **Improvements**
* Android app configurations are now validated to reject unsupported
Fleet variables.
* Fleet variables are substituted with real per-host values during
Android app configuration deployment, including batch/GitOps and
host-specific workflows.

* **Tests**
* Added unit and integration coverage for supported/unsupported
variables, substitution behavior, and JSON escaping.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Konstantin Sykulev
2026-06-17 11:10:03 -05:00
committed by GitHub
parent 8e3f17804e
commit ead9d40293
10 changed files with 641 additions and 10 deletions
@@ -0,0 +1 @@
- Added support for `$FLEET_VAR_HOST_*` variables in Android managed app configuration.
+5
View File
@@ -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:
+10 -4
View File
@@ -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,
}
+24
View File
@@ -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
}
+38
View File
@@ -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 {
+135
View File
@@ -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), `"`), `"`)
}
@@ -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))
})
}
}
+21 -6
View File
@@ -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,
@@ -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()
+133
View File
@@ -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")