Files
fleet/server/mdm/profiles/android_appconfig.go
Nico f5ca4b5b0d Add Android support for custom host vitals (#49696)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49421

Custom host vitals (`$FLEET_HOST_VITAL_<id>`) already worked in scripts
and Apple/Windows configuration profiles, but Android configuration
profiles and managed app configuration explicitly rejected them at
upload to keep parity with `$FLEET_SECRET_*`. This left admins unable to
inject per-host vitals (e.g. an asset tag) into Android MDM
configuration the same way they can for every other platform.

For more context, prior PRs:
- https://github.com/fleetdm/fleet/pull/49334
- https://github.com/fleetdm/fleet/pull/49586

# Checklist for submitter

- [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.

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

- Created an "Asset tag" host vital.
- Enrolled an Android device.
- Initially the test profile showed as "Failed" because no value was set
for the vital.
- Set a value for the vital, saw that it went from Enforcing to
Verified.

<img width="1446" height="510" alt="Screenshot 2026-07-24 at 8 57 46 AM"
src="https://github.com/user-attachments/assets/c0e2348c-e521-48f3-85cd-6f884689b2cd"
/>
<img width="1520" height="936" alt="Screenshot 2026-07-24 at 8 56 56 AM"
src="https://github.com/user-attachments/assets/169b9545-ec7a-429b-8f45-0e2740f61c77"
/>
<img width="1607" height="1136" alt="Screenshot 2026-07-24 at 8 57
30 AM"
src="https://github.com/user-attachments/assets/a8213745-b224-4a36-a54d-32152a15c377"
/>

Also tested the rejection cases:
- trying to upload a profile with an invalid custom host vital id
(either a non-numeric value, a numeric but non-existent ID, and
referencing a vital as a JSON key instead of a value)
- deleting a vital referenced in a profile



https://github.com/user-attachments/assets/e8b4acde-ddf4-41c0-b00a-5ab4945d0bc2



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

## Summary by CodeRabbit

* **New Features**
* Android app configurations and profiles now support custom host vital
placeholders (`$FLEET_HOST_VITAL_<id>`).
* Custom host vital values are expanded per device during Android
delivery.
* Managed Android profiles/configurations are automatically resent when
a referenced vital value changes.

* **Bug Fixes**
* Added validation for malformed, missing, or undefined vital references
during Android app association and profile/config uploads.
  * Prevented deletion of vitals referenced by Android profiles.
* Improved error handling and delivery failure details when a device
lacks a required vital value.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 08:22:57 -03:00

163 lines
5.4 KiB
Go

package profiles
import (
"bytes"
"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 and $FLEET_HOST_VITAL_<id>
// custom host vitals in Android managed app configuration.
type AndroidAppConfigSubstitutionHost struct {
HostID uint
UUID string
HardwareSerial string
Platform string
}
// SubstituteFleetVarsAndVitalsInAndroidAppConfig replaces every supported
// $FLEET_VAR_* token and $FLEET_HOST_VITAL_<id> custom host vital reference 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, or a *fleet.MissingCustomHostVitalValueError if a
// referenced vital has no value set for this host.
func SubstituteFleetVarsAndVitalsInAndroidAppConfig(
ctx context.Context,
ds fleet.Datastore,
config []byte,
host AndroidAppConfigSubstitutionHost,
) ([]byte, error) {
if len(config) == 0 {
return config, nil
}
contents := string(config)
used := variables.Find(contents)
hasHostVitals := len(fleet.FindCustomHostVitalIDs(contents)) > 0
if len(used) == 0 && !hasHostVitals {
return config, nil
}
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}
}
}
if hasHostVitals {
expanded, err := ds.ExpandCustomHostVitals(ctx, host.HostID, contents)
if err != nil {
return nil, err
}
contents = expanded
}
return []byte(contents), nil
}
// ContainsFleetVarOrCustomHostVital reports whether content has a $FLEET_VAR_*
// token or a $FLEET_HOST_VITAL_<id> token. Checks bytes for the vital prefix
// before falling back to fleet.FindCustomHostVitalIDs, which needs a string, to
// avoid that conversion's allocation in the common case where content has neither.
func ContainsFleetVarOrCustomHostVital(content []byte) bool {
if variables.ContainsBytes(content) {
return true
}
if !bytes.Contains(content, []byte(fleet.CustomHostVitalPrefix)) {
return false
}
return len(fleet.FindCustomHostVitalIDs(string(content))) > 0
}
// 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), `"`), `"`)
}