<!-- 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 -->
163 lines
5.4 KiB
Go
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), `"`), `"`)
|
|
}
|