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:
@@ -0,0 +1 @@
|
||||
- Added support for `$FLEET_VAR_HOST_*` variables in Android managed app configuration.
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user