DDMV: Support Fleet variables in DDM (#43222)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #43047 

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

- [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
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually
See
https://github.com/fleetdm/fleet/issues/42960#issuecomment-4244206563
and subsequent comments.


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

* **New Features**
* Apple DDM declarations support a vetted subset of Fleet variables with
per-host substitution; premium license required. Declaration tokens and
resend behavior now reflect variable changes; unresolved host
substitutions mark that host’s declaration as failed.

* **Bug Fixes**
* Clearer errors for unsupported or license-restricted Fleet variables
and more consistent DDM resend/update semantics when variables change.

* **Tests**
* Added extensive unit and integration tests covering Fleet variable
validation, substitution, token changes, resends, and failure states.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Martin Angers 2026-04-20 09:14:52 -04:00 committed by GitHub
parent 8d2684447c
commit 2a8803884b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1312 additions and 130 deletions

View file

@ -0,0 +1 @@
- Added support for Fleet variables in Apple's declaration profiles (DDM).

View file

@ -202,7 +202,7 @@ func TestApplyAsGitOpsDeprecatedKeys(t *testing.T) {
ds.SetAsideLabelsFunc = func(ctx context.Context, notOnTeamID *uint, names []string, user fleet.User) error {
return nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}

View file

@ -298,7 +298,7 @@ func TestApplyTeamSpecs(t *testing.T) {
}
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}
@ -755,7 +755,7 @@ func TestApplyAppConfig(t *testing.T) {
return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}
@ -1486,7 +1486,7 @@ func TestApplyAsGitOps(t *testing.T) {
ds.SetAsideLabelsFunc = func(ctx context.Context, notOnTeamID *uint, names []string, user fleet.User) error {
return nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}

View file

@ -2610,7 +2610,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
require.ElementsMatch(t, names, []string{fleet.BuiltinLabelMacOS14Plus})
return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}

View file

@ -1637,13 +1637,13 @@ func TestGitOpsFullTeam(t *testing.T) {
ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, profile fleet.MDMAppleConfigProfile, vars []fleet.FleetVarName) (*fleet.MDMAppleConfigProfile, error) {
return &profile, nil
}
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
return declaration, nil
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}
@ -5869,7 +5869,7 @@ func TestGitOpsAppleOSUpdates(t *testing.T) {
defaultTeamConfig = config
return nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
return &fleet.MDMAppleDeclaration{DeclarationUUID: "test-uuid"}, nil
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) {
@ -6154,7 +6154,7 @@ func TestGitOpsWindowsOSUpdates(t *testing.T) {
defaultTeamConfig = config
return nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
return &fleet.MDMAppleDeclaration{DeclarationUUID: "test-uuid"}, nil
}

View file

@ -100,7 +100,7 @@ func setupEmptyGitOpsMocks(ds *mock.Store) {
) (fleet.MDMProfilesUpdates, error) {
return fleet.MDMProfilesUpdates{}, nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
return &fleet.MDMAppleDeclaration{}, nil
}
ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error {

View file

@ -450,7 +450,7 @@ func SetupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
savedTeams[team.Name] = &team
return team, nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (
*fleet.MDMAppleDeclaration, error,
) {
declaration.DeclarationUUID = uuid.NewString()

View file

@ -4222,7 +4222,7 @@ labels:
- name: Test Fleet Label
label_membership_type: dynamic
query: SELECT 1
`, fleetName)
fullFleetFile, err := os.CreateTemp(t.TempDir(), "*.yml")
@ -4518,3 +4518,44 @@ settings:
require.Empty(t, teamMeta.LabelsExcludeAny)
require.Empty(t, teamMeta.LabelsIncludeAll)
}
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetGitopsDDMUnsupportedFleetVariable() {
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Create a DDM declaration with an unsupported Fleet variable
declDir := t.TempDir()
declFile := path.Join(declDir, "decl-unsupported-var.json")
err := os.WriteFile(declFile, []byte(`{
"Type": "com.apple.configuration.management.test",
"Identifier": "com.example.unsupported-var",
"Payload": {"Value": "$FLEET_VAR_BOZO"}
}`), 0o644)
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
macos_settings:
custom_settings:
- path: %s
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`, declFile))
require.NoError(t, err)
t.Setenv("FLEET_URL", s.Server.URL)
// Applying a DDM declaration with an unsupported Fleet variable should fail
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()})
require.ErrorContains(t, err, "Fleet variable $FLEET_VAR_BOZO is not supported in DDM profiles")
}

View file

@ -222,3 +222,43 @@ func (s *integrationGitopsTestSuite) TestFleetGitopsWithFleetSecrets() {
require.NoError(t, err)
assert.Contains(t, string(winProfile.SyncML), "${FLEET_SECRET_"+secretName2+"}")
}
func (s *integrationGitopsTestSuite) TestFleetGitopsDDMFleetVarsRequiresPremium() {
t := s.T()
fleetctlConfig := s.createFleetctlConfig()
// Create a DDM declaration with a Fleet variable
declDir := t.TempDir()
declFile := path.Join(declDir, "decl-fleetvar.json")
err := os.WriteFile(declFile, []byte(`{
"Type": "com.apple.configuration.management.test",
"Identifier": "com.example.fleetvar-test",
"Payload": {"Value": "$FLEET_VAR_HOST_HARDWARE_SERIAL"}
}`), 0o644)
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
macos_settings:
custom_settings:
- path: %s
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`, declFile))
require.NoError(t, err)
t.Setenv("FLEET_URL", s.Server.URL)
// Applying a DDM declaration with Fleet variables should fail without a premium license
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()})
require.ErrorContains(t, err, "missing or invalid license")
}

View file

@ -1338,7 +1338,7 @@ func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *ui
{LabelName: labelName, LabelID: lblIDs[labelName]},
}
decl, err := svc.ds.SetOrUpdateMDMAppleDeclaration(ctx, d)
decl, err := svc.ds.SetOrUpdateMDMAppleDeclaration(ctx, d, nil)
if err != nil {
return err
}

View file

@ -235,7 +235,7 @@ func TestGetOrCreatePreassignTeam(t *testing.T) {
require.ElementsMatch(t, names, []string{fleet.BuiltinLabelMacOS14Plus})
return map[string]uint{names[0]: 1}, nil
}
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
declaration.DeclarationUUID = uuid.NewString()
return declaration, nil
}

View file

@ -265,7 +265,7 @@ INSERT INTO
}
if _, err := batchSetProfileVariableAssociationsDB(ctx, tx, []fleet.MDMProfileUUIDFleetVariables{
{ProfileUUID: profUUID, FleetVariables: usesFleetVars},
}, "darwin"); err != nil {
}, "darwin", false); err != nil {
return ctxerr.Wrap(ctx, err, "inserting darwin profile variable associations")
}
@ -5103,7 +5103,7 @@ WHERE h.uuid = ? AND (
}
func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint,
incomingDeclarations []*fleet.MDMAppleDeclaration,
incomingDeclarations []*fleet.MDMAppleDeclaration, profilesVariablesByIdentifier []fleet.MDMProfileIdentifierFleetVariables,
) (updatedDB bool, err error) {
// First, build a list of names (which are usually filenames) for the incoming declarations.
// We will keep the existing ones if there's a match and no change.
@ -5151,7 +5151,12 @@ func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.E
return false, ctxerr.Wrap(ctx, err, "update declaration label associations")
}
return deletedDeclarations || insertedOrUpdatedDeclarations || updatedLabels, nil
updatedVars, err := ds.updateDeclarationsVariableAssociations(ctx, tx, incomingDeclarationsMap, teamIDOrZero, profilesVariablesByIdentifier)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "update declaration variable associations")
}
return deletedDeclarations || insertedOrUpdatedDeclarations || updatedLabels || updatedVars, nil
}
func (ds *Datastore) updateDeclarationsLabelAssociations(ctx context.Context, tx sqlx.ExtContext,
@ -5219,6 +5224,53 @@ func (ds *Datastore) updateDeclarationsLabelAssociations(ctx context.Context, tx
return updatedDB, err
}
func (ds *Datastore) updateDeclarationsVariableAssociations(ctx context.Context, tx sqlx.ExtContext,
incomingDeclarationsMap map[string]*fleet.MDMAppleDeclaration, teamID uint,
profilesVariablesByIdentifier []fleet.MDMProfileIdentifierFleetVariables,
) (updatedDB bool, err error) {
if len(incomingDeclarationsMap) == 0 {
return false, nil
}
incomingNames := make([]string, 0, len(incomingDeclarationsMap))
for _, p := range incomingDeclarationsMap {
incomingNames = append(incomingNames, p.Name)
}
// Load declaration UUIDs by name (same approach as updateDeclarationsLabelAssociations)
currentDecls, err := ds.getExistingDeclarations(ctx, tx, incomingNames, teamID)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "load declarations for variable associations")
}
// Build a map from identifier to variables, filtering for declaration entries only
varsByIdentifier := make(map[string][]fleet.FleetVarName, len(profilesVariablesByIdentifier))
for _, pv := range profilesVariablesByIdentifier {
if name, ok := strings.CutPrefix(pv.Identifier, fleet.MDMAppleDeclarationUUIDPrefix); ok {
varsByIdentifier[name] = pv.FleetVariables
}
}
// Map declaration UUIDs to their variables, including declarations without
// variables so stale associations get cleared.
var profilesVarsToUpsert []fleet.MDMProfileUUIDFleetVariables
for _, decl := range currentDecls {
vars := varsByIdentifier[decl.Identifier]
profilesVarsToUpsert = append(profilesVarsToUpsert, fleet.MDMProfileUUIDFleetVariables{
ProfileUUID: decl.DeclarationUUID,
FleetVariables: vars,
})
}
if len(profilesVarsToUpsert) > 0 {
if updatedDB, err = batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, "darwin", true); err != nil {
return false, ctxerr.Wrap(ctx, err, "inserting declaration variable associations")
}
}
return updatedDB, nil
}
func (ds *Datastore) insertOrUpdateDeclarations(ctx context.Context, tx sqlx.ExtContext, incomingDeclarations []*fleet.MDMAppleDeclaration,
teamID uint,
) (updatedDB bool, err error) {
@ -5310,6 +5362,7 @@ func (ds *Datastore) getExistingDeclarations(ctx context.Context, tx sqlx.ExtCon
const loadExistingDecls = `
SELECT
name,
identifier,
declaration_uuid,
raw_json
FROM
@ -5340,7 +5393,7 @@ func (ds *Datastore) teamIDPtrToUint(tmID *uint) uint {
return 0
}
func (ds *Datastore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
func (ds *Datastore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
const stmt = `
INSERT INTO mdm_apple_declarations (
declaration_uuid,
@ -5360,10 +5413,10 @@ INSERT INTO mdm_apple_declarations (
)
)`
return ds.insertOrUpsertMDMAppleDeclaration(ctx, stmt, declaration)
return ds.insertOrUpsertMDMAppleDeclaration(ctx, stmt, declaration, usesFleetVars)
}
func (ds *Datastore) SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
func (ds *Datastore) SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
const stmt = `
INSERT INTO mdm_apple_declarations (
declaration_uuid,
@ -5387,10 +5440,10 @@ ON DUPLICATE KEY UPDATE
uploaded_at = IF(raw_json = VALUES(raw_json) AND name = VALUES(name) AND IFNULL(secrets_updated_at = VALUES(secrets_updated_at), TRUE), uploaded_at, NOW(6)),
raw_json = VALUES(raw_json)`
return ds.insertOrUpsertMDMAppleDeclaration(ctx, stmt, declaration)
return ds.insertOrUpsertMDMAppleDeclaration(ctx, stmt, declaration, usesFleetVars)
}
func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insOrUpsertStmt string, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insOrUpsertStmt string, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
declUUID := fleet.MDMAppleDeclarationUUIDPrefix + uuid.NewString()
var tmID uint
@ -5455,6 +5508,12 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO
return ctxerr.Wrap(ctx, err, "inserting mdm declaration label associations")
}
if _, err := batchSetProfileVariableAssociationsDB(ctx, tx, []fleet.MDMProfileUUIDFleetVariables{
{ProfileUUID: declUUID, FleetVariables: usesFleetVars},
}, "darwin", true); err != nil {
return ctxerr.Wrap(ctx, err, "inserting declaration variable associations")
}
return nil
})
if err != nil {
@ -5613,7 +5672,7 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont
func (ds *Datastore) MDMAppleDDMDeclarationsToken(ctx context.Context, hostUUID string) (*fleet.MDMAppleDDMDeclarationsToken, error) {
const stmt = `
SELECT
COALESCE(MD5(CONCAT(COUNT(0), GROUP_CONCAT(HEX(mad.token)
COALESCE(MD5(CONCAT(COUNT(0), GROUP_CONCAT(CONCAT(HEX(mad.token), IFNULL(hmad.variables_updated_at, ''))
ORDER BY
mad.uploaded_at DESC, mad.declaration_uuid ASC separator ''))), '') AS token,
COALESCE(MAX(mad.created_at), NOW()) AS latest_created_timestamp
@ -5643,7 +5702,8 @@ func (ds *Datastore) MDMAppleDDMDeclarationItems(ctx context.Context, hostUUID s
const stmt = `
SELECT
HEX(mad.token) as token,
mad.identifier, mad.declaration_uuid, status, operation_type, mad.uploaded_at
mad.identifier, mad.declaration_uuid, status, operation_type, mad.uploaded_at,
hmad.variables_updated_at
FROM
host_mdm_apple_declarations hmad
JOIN mdm_apple_declarations mad ON mad.declaration_uuid = hmad.declaration_uuid
@ -5665,7 +5725,7 @@ func (ds *Datastore) MDMAppleDDMDeclarationsResponse(ctx context.Context, identi
// declarations are removed, but the join would provide an extra layer of safety.
const stmt = `
SELECT
mad.raw_json, HEX(mad.token) as token
mad.declaration_uuid, mad.raw_json, HEX(mad.token) as token, hmad.variables_updated_at
FROM
host_mdm_apple_declarations hmad
JOIN mdm_apple_declarations mad ON hmad.declaration_uuid = mad.declaration_uuid
@ -5783,14 +5843,15 @@ func mdmAppleBatchSetPendingHostDeclarationsDB(
) (updatedDB bool, err error) {
baseStmt := `
INSERT INTO host_mdm_apple_declarations
(host_uuid, status, operation_type, token, secrets_updated_at, declaration_uuid, declaration_identifier, declaration_name)
(host_uuid, status, operation_type, token, secrets_updated_at, variables_updated_at, declaration_uuid, declaration_identifier, declaration_name)
VALUES
%s
ON DUPLICATE KEY UPDATE
status = VALUES(status),
operation_type = VALUES(operation_type),
token = VALUES(token),
secrets_updated_at = VALUES(secrets_updated_at)
secrets_updated_at = VALUES(secrets_updated_at),
variables_updated_at = VALUES(variables_updated_at)
`
profilesToInsert := make(map[string]*fleet.MDMAppleHostDeclaration)
@ -5806,6 +5867,7 @@ func mdmAppleBatchSetPendingHostDeclarationsDB(
COALESCE(detail, '') AS detail,
token,
secrets_updated_at,
variables_updated_at,
declaration_uuid,
declaration_identifier,
declaration_name
@ -5859,18 +5921,19 @@ func mdmAppleBatchSetPendingHostDeclarationsDB(
generateValueArgs := func(d *fleet.MDMAppleHostDeclaration) (string, []any) {
profilesToInsert[fmt.Sprintf("%s\n%s", d.HostUUID, d.DeclarationUUID)] = &fleet.MDMAppleHostDeclaration{
HostUUID: d.HostUUID,
DeclarationUUID: d.DeclarationUUID,
Name: d.Name,
Identifier: d.Identifier,
Status: status,
OperationType: d.OperationType,
Detail: d.Detail,
Token: d.Token,
SecretsUpdatedAt: d.SecretsUpdatedAt,
HostUUID: d.HostUUID,
DeclarationUUID: d.DeclarationUUID,
Name: d.Name,
Identifier: d.Identifier,
Status: status,
OperationType: d.OperationType,
Detail: d.Detail,
Token: d.Token,
SecretsUpdatedAt: d.SecretsUpdatedAt,
VariablesUpdatedAt: d.VariablesUpdatedAt,
}
valuePart := "(?, ?, ?, ?, ?, ?, ?, ?),"
args := []any{d.HostUUID, status, d.OperationType, d.Token, d.SecretsUpdatedAt, d.DeclarationUUID, d.Identifier, d.Name}
valuePart := "(?, ?, ?, ?, ?, ?, ?, ?, ?),"
args := []any{d.HostUUID, status, d.OperationType, d.Token, d.SecretsUpdatedAt, d.VariablesUpdatedAt, d.DeclarationUUID, d.Identifier, d.Name}
return valuePart, args
}
@ -6000,13 +6063,65 @@ func mdmAppleGetHostsWithChangedDeclarationsDB(ctx context.Context, tx sqlx.ExtC
if err := sqlx.SelectContext(ctx, tx, &decls, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "running sql statement")
}
// For declarations that use Fleet variables and are being installed, set
// VariablesUpdatedAt so the host row tracks when variable values were last
// computed. This allows re-delivery when variable values change.
if err := setVariablesUpdatedAtForDeclarations(ctx, tx, decls); err != nil {
return nil, ctxerr.Wrap(ctx, err, "setting variables_updated_at for declarations")
}
return decls, nil
}
// setVariablesUpdatedAtForDeclarations looks up which declarations have Fleet
// variables and sets VariablesUpdatedAt on the install entries.
func setVariablesUpdatedAtForDeclarations(ctx context.Context, tx sqlx.ExtContext, decls []*fleet.MDMAppleHostDeclaration) error {
var installDeclUUIDs []string
for _, d := range decls {
if d.OperationType == fleet.MDMOperationTypeInstall {
installDeclUUIDs = append(installDeclUUIDs, d.DeclarationUUID)
}
}
if len(installDeclUUIDs) == 0 {
return nil
}
stmt, args, err := sqlx.In(
`SELECT DISTINCT apple_declaration_uuid FROM mdm_configuration_profile_variables WHERE apple_declaration_uuid IN (?)`,
installDeclUUIDs,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "sqlx.In declaration variables lookup")
}
var declsWithVars []string
if err := sqlx.SelectContext(ctx, tx, &declsWithVars, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "selecting declarations with variables")
}
if len(declsWithVars) == 0 {
return nil
}
hasVars := make(map[string]struct{}, len(declsWithVars))
for _, uuid := range declsWithVars {
hasVars[uuid] = struct{}{}
}
now := time.Now().UTC()
for _, d := range decls {
if _, ok := hasVars[d.DeclarationUUID]; ok && d.OperationType == fleet.MDMOperationTypeInstall {
d.VariablesUpdatedAt = &now
}
}
return nil
}
// MDMAppleStoreDDMStatusReport updates the status of the host's declarations.
func (ds *Datastore) MDMAppleStoreDDMStatusReport(ctx context.Context, hostUUID string, updates []*fleet.MDMAppleHostDeclaration) error {
getHostDeclarationsStmt := `
SELECT host_uuid, status, operation_type, HEX(token) as token, secrets_updated_at, declaration_uuid, declaration_identifier, declaration_name
SELECT host_uuid, status, operation_type, HEX(token) as token, secrets_updated_at, variables_updated_at, declaration_uuid, declaration_identifier, declaration_name
FROM host_mdm_apple_declarations
WHERE host_uuid = ?
`
@ -6042,7 +6157,7 @@ ON DUPLICATE KEY UPDATE
for _, c := range current {
// Skip updates for 'remove' operations because it is possible that IT admin removed a profile and then re-added it.
// Pending removes are cleaned up after we update status of installs.
if u, ok := updatesByToken[c.Token]; ok && u.OperationType != fleet.MDMOperationTypeRemove {
if u, ok := updatesByToken[fleet.EffectiveDDMToken(c.Token, c.VariablesUpdatedAt)]; ok && u.OperationType != fleet.MDMOperationTypeRemove {
insertVals.WriteString("(?, ?, ?, ?, ?, ?, ?, UNHEX(?), ?),")
args = append(args, hostUUID, c.DeclarationUUID, u.Status, u.OperationType, u.Detail, c.Identifier, c.Name, c.Token,
c.SecretsUpdatedAt)
@ -6069,6 +6184,12 @@ ON DUPLICATE KEY UPDATE
return ctxerr.Wrap(ctx, err, "updating host declarations")
}
func (ds *Datastore) SetHostMDMAppleDeclarationStatus(ctx context.Context, hostUUID string, declarationUUID string, status *fleet.MDMDeliveryStatus, detail string, variablesUpdatedAt *time.Time) error {
stmt := `UPDATE host_mdm_apple_declarations SET status = ?, detail = ?, variables_updated_at = COALESCE(?, variables_updated_at) WHERE host_uuid = ? AND declaration_uuid = ?`
_, err := ds.writer(ctx).ExecContext(ctx, stmt, status, detail, variablesUpdatedAt, hostUUID, declarationUUID)
return ctxerr.Wrap(ctx, err, "set host declaration status")
}
func (ds *Datastore) MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUUID string, status *fleet.MDMDeliveryStatus, detail string) error {
stmt := `
UPDATE host_mdm_apple_declarations

View file

@ -105,7 +105,7 @@ func testMDMAppleBatchSetHostDeclarationState(t *testing.T, ds *Datastore) {
Name: "Test Declaration " + string(rune('A'+i)),
Identifier: "com.example.test.declaration." + string(rune('A'+i)),
RawJSON: []byte(`{"Type":"com.apple.test.declaration","Identifier":"com.example.test.declaration.` + string(rune('A'+i)) + `"}`),
})
}, nil)
require.NoError(t, err)
}
removeDeclarations := make([]*fleet.MDMAppleDeclaration, 3)
@ -189,7 +189,7 @@ func testMDMAppleBatchSetHostDeclarationState(t *testing.T, ds *Datastore) {
Name: "Test Install Declaration " + string(rune('A'+i)),
Identifier: "com.example.test.install." + string(rune('A'+i)),
RawJSON: []byte(`{"Type":"com.apple.test.declaration","Identifier":"com.example.test.install.` + string(rune('A'+i)) + `"}`),
})
}, nil)
require.NoError(t, err)
}

View file

@ -410,7 +410,7 @@ func testDeleteMDMAppleConfigProfile(t *testing.T, ds *Datastore) {
require.NoError(t, err)
testDecl := declForTest("D1", "D1", "{}")
dbDecl, err := ds.NewMDMAppleDeclaration(ctx, testDecl)
dbDecl, err := ds.NewMDMAppleDeclaration(ctx, testDecl, nil)
require.NoError(t, err)
// delete for a non-existing team does nothing
@ -5966,7 +5966,7 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) {
Identifier: "decl-1",
Name: "decl-1",
RawJSON: json.RawMessage(`{"Identifier": "decl-1"}`),
})
}, nil)
require.NoError(t, err)
updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil)
require.NoError(t, err)
@ -6005,7 +6005,7 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) {
Identifier: "decl-2",
Name: "decl-2",
RawJSON: json.RawMessage(`{"Identifier": "decl-2"}`),
})
}, nil)
require.NoError(t, err)
updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil)
require.NoError(t, err)
@ -6043,7 +6043,7 @@ func testMDMAppleSetPendingDeclarationsAs(t *testing.T, ds *Datastore) {
Identifier: fmt.Sprintf("decl-%d", i),
Name: fmt.Sprintf("decl-%d", i),
RawJSON: json.RawMessage(fmt.Sprintf(`{"Identifier": "decl-%d"}`, i)),
})
}, nil)
require.NoError(t, err)
}
@ -6094,7 +6094,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) {
Identifier: "i1",
Name: "d1",
RawJSON: json.RawMessage(`{"Identifier": "i1"}`),
})
}, nil)
require.NoError(t, err)
// try to create same name, different identifier fails
@ -6102,7 +6102,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) {
Identifier: "i1b",
Name: "d1",
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
})
}, nil)
require.Error(t, err)
var existsErr *existsError
require.ErrorAs(t, err, &existsErr)
@ -6112,7 +6112,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) {
Identifier: "i1",
Name: "d1b",
RawJSON: json.RawMessage(`{"Identifier": "i1"}`),
})
}, nil)
require.Error(t, err)
require.ErrorAs(t, err, &existsErr)
@ -6122,7 +6122,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) {
Name: "d1",
TeamID: &tm1.ID,
RawJSON: json.RawMessage(`{"Identifier": "i1"}`),
})
}, nil)
require.NoError(t, err)
require.NotEqual(t, d1.DeclarationUUID, d1tm1.DeclarationUUID)
@ -6136,7 +6136,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) {
Name: "d1",
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: l1.Name, LabelID: l1.ID}},
})
}, nil)
require.NoError(t, err)
require.Equal(t, d1.DeclarationUUID, d1Ori.DeclarationUUID)
require.NotEqual(t, d1.DeclarationUUID, d1tm1.DeclarationUUID)
@ -6152,7 +6152,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) {
Name: "d1",
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: l2.Name, LabelID: l2.ID}},
})
}, nil)
require.NoError(t, err)
require.Equal(t, d1.DeclarationUUID, d1Ori.DeclarationUUID)
@ -6168,7 +6168,7 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) {
TeamID: &tm1.ID,
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: l1.Name, LabelID: l1.ID}},
})
}, nil)
require.NoError(t, err)
require.Equal(t, d1tm1B.DeclarationUUID, d1tm1.DeclarationUUID)
@ -6197,7 +6197,7 @@ func testDeleteMDMAppleDeclarationWithPendingInstalls(t *testing.T, ds *Datastor
Identifier: "decl-1",
Name: "decl-1",
RawJSON: json.RawMessage(`{"Identifier": "decl-1"}`),
})
}, nil)
require.NoError(t, err)
host, err := ds.NewHost(ctx, &fleet.Host{
@ -9709,8 +9709,8 @@ func testSetMDMAppleProfilesWithVariables(t *testing.T, ds *Datastore) {
&profA,
&profB,
}, nil, nil, nil, []fleet.MDMProfileIdentifierFleetVariables{
{Identifier: profA.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsernameLocalPart}},
{Identifier: profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername}},
{Identifier: fleet.MDMAppleProfileUUIDPrefix + profA.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsernameLocalPart}},
{Identifier: fleet.MDMAppleProfileUUIDPrefix + profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername}},
})
require.NoError(t, err)
require.Equal(t, fleet.MDMProfilesUpdates{AppleConfigProfile: true}, updates)
@ -9727,9 +9727,9 @@ func testSetMDMAppleProfilesWithVariables(t *testing.T, ds *Datastore) {
&profB,
&profD,
}, nil, nil, nil, []fleet.MDMProfileIdentifierFleetVariables{
{Identifier: profA.Identifier, FleetVariables: nil},
{Identifier: profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}},
{Identifier: profD.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}},
{Identifier: fleet.MDMAppleProfileUUIDPrefix + profA.Identifier, FleetVariables: nil},
{Identifier: fleet.MDMAppleProfileUUIDPrefix + profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}},
{Identifier: fleet.MDMAppleProfileUUIDPrefix + profD.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}},
})
require.NoError(t, err)
require.Equal(t, fleet.MDMProfilesUpdates{AppleConfigProfile: true}, updates)
@ -9754,9 +9754,9 @@ func testSetMDMAppleProfilesWithVariables(t *testing.T, ds *Datastore) {
},
nil,
[]fleet.MDMProfileIdentifierFleetVariables{
{Identifier: profA.Identifier, FleetVariables: nil},
{Identifier: profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}},
{Identifier: profD.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}},
{Identifier: fleet.MDMAppleProfileUUIDPrefix + profA.Identifier, FleetVariables: nil},
{Identifier: fleet.MDMAppleProfileUUIDPrefix + profB.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}},
{Identifier: fleet.MDMAppleProfileUUIDPrefix + profD.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}},
})
require.NoError(t, err)
require.Equal(t, fleet.MDMProfilesUpdates{AppleConfigProfile: true, AppleDeclaration: true, WindowsConfigProfile: true}, updates)
@ -9769,7 +9769,7 @@ func testSetMDMAppleProfilesWithVariables(t *testing.T, ds *Datastore) {
updates, err = ds.BatchSetMDMProfiles(ctx, &tm1.ID, []*fleet.MDMAppleConfigProfile{
&profE,
}, nil, nil, nil, []fleet.MDMProfileIdentifierFleetVariables{
{Identifier: profE.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}},
{Identifier: fleet.MDMAppleProfileUUIDPrefix + profE.Identifier, FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}},
})
require.NoError(t, err)
require.Equal(t, fleet.MDMProfilesUpdates{AppleConfigProfile: true}, updates)

View file

@ -4974,7 +4974,7 @@ func testListHostsProfileUUIDAndStatus(t *testing.T, ds *Datastore) {
// no team Apple declaration profile //
/////////////////////////////////////
noTeamDeclaration, err := ds.NewMDMAppleDeclaration(ctx, declForTest("test-decleration", "com.fleetdm.fleet.mdm.test-decl", "{}"))
noTeamDeclaration, err := ds.NewMDMAppleDeclaration(ctx, declForTest("test-decleration", "com.fleetdm.fleet.mdm.test-decl", "{}"), nil)
require.NoError(t, err)
// verified status

View file

@ -452,7 +452,7 @@ func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macPro
return ctxerr.Wrap(ctx, err, "batch set apple profiles")
}
if updates.AppleDeclaration, err = ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil {
if updates.AppleDeclaration, err = ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations, profilesVariablesByIdentifier); err != nil {
return ctxerr.Wrap(ctx, err, "batch set apple declarations")
}
@ -2021,18 +2021,21 @@ func batchSetProfileVariableAssociationsDB(
tx sqlx.ExtContext,
profileVariablesByUUID []fleet.MDMProfileUUIDFleetVariables,
platform string,
forAppleDeclarations bool,
) (didUpdate bool, err error) {
if len(profileVariablesByUUID) == 0 {
return false, nil
}
var platformPrefix string
switch platform {
case "darwin":
platformPrefix = "apple"
case "windows":
platformPrefix = "windows"
case "android":
var columnName string
switch {
case platform == "darwin" && forAppleDeclarations:
columnName = "apple_declaration_uuid"
case platform == "darwin":
columnName = "apple_profile_uuid"
case platform == "windows":
columnName = "windows_profile_uuid"
case platform == "android":
return false, nil // Early return here, to avoid failing but still utilizing the shared batchSet method.
default:
return false, fmt.Errorf("unsupported platform %s", platform)
@ -2050,7 +2053,7 @@ func batchSetProfileVariableAssociationsDB(
}
// delete variables associated with those profiles
clearVarsForProfilesStmt := fmt.Sprintf(`DELETE FROM mdm_configuration_profile_variables WHERE %s_profile_uuid IN (?)`, platformPrefix)
clearVarsForProfilesStmt := fmt.Sprintf(`DELETE FROM mdm_configuration_profile_variables WHERE %s IN (?)`, columnName)
clearVarsForProfilesStmt, args, err := sqlx.In(clearVarsForProfilesStmt, profileUUIDsToDelete)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "sqlx.In delete variables for profiles")
@ -2117,13 +2120,13 @@ func batchSetProfileVariableAssociationsDB(
executeUpsertBatch := func(valuePart string, args []any) error {
stmt := fmt.Sprintf(`
INSERT INTO mdm_configuration_profile_variables (
%s_profile_uuid,
%s,
fleet_variable_id
)
VALUES %s
ON DUPLICATE KEY UPDATE
fleet_variable_id = VALUES(fleet_variable_id)
`, platformPrefix, strings.TrimSuffix(valuePart, ","))
`, columnName, strings.TrimSuffix(valuePart, ","))
_, err := tx.ExecContext(ctx, stmt, args...)
return err
@ -2574,12 +2577,23 @@ func (ds *Datastore) batchSetLabelAndVariableAssociations(ctx context.Context, t
return false, ctxerr.Wrap(ctx, err, fmt.Sprintf("inserting %s profile label associations", platform))
}
// save fleet variables associated with Windows profiles (both new and updated)
// save fleet variables associated with profiles (both new and updated)
// Note: currentProfiles contains all incoming profiles (new AND updated), not just new ones
// Process ALL profiles to ensure stale variable associations are cleared for profiles that no longer have variables
var varPrefix string
switch platform {
case "darwin":
varPrefix = fleet.MDMAppleProfileUUIDPrefix
case "windows":
varPrefix = fleet.MDMWindowsProfileUUIDPrefix
case "android":
varPrefix = fleet.MDMAndroidProfileUUIDPrefix
}
profileVariablesByName := make(map[string][]fleet.FleetVarName, len(profilesVariablesByIdentifier))
for _, pv := range profilesVariablesByIdentifier {
profileVariablesByName[pv.Identifier] = pv.FleetVariables
if name, ok := strings.CutPrefix(pv.Identifier, varPrefix); ok {
profileVariablesByName[name] = pv.FleetVariables
}
}
// collect ALL profile UUIDs, including those without variables (to clear stale associations)
@ -2595,7 +2609,7 @@ func (ds *Datastore) batchSetLabelAndVariableAssociations(ctx context.Context, t
if len(profilesVarsToUpsert) > 0 {
var didUpdateVariableAssociations bool
if didUpdateVariableAssociations, err = batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, platform); err != nil {
if didUpdateVariableAssociations, err = batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, platform, false); err != nil {
return false, ctxerr.Wrap(ctx, err, fmt.Sprintf("inserting %s profile variable associations", platform))
}

View file

@ -2655,7 +2655,7 @@ INSERT INTO
FleetVariables: usesFleetVars,
},
}
if _, err := batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, "windows"); err != nil {
if _, err := batchSetProfileVariableAssociationsDB(ctx, tx, profilesVarsToUpsert, "windows", false); err != nil {
return ctxerr.Wrap(ctx, err, "inserting windows profile variable associations")
}
}

View file

@ -2648,7 +2648,7 @@ func testMDMWindowsConfigProfilesWithFleetVars(t *testing.T, ds *Datastore) {
// Mock the profilesVariablesByIdentifier that would be passed from service layer
profilesVars := []fleet.MDMProfileIdentifierFleetVariables{
{Identifier: "team_profile_1", FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostUUID}},
{Identifier: fleet.MDMWindowsProfileUUIDPrefix + "team_profile_1", FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostUUID}},
}
_, err = ds.BatchSetMDMProfiles(ctx, ptr.Uint(1), nil, []*fleet.MDMWindowsConfigProfile{teamProf1WithVarsAgain, teamProf2NoChange}, nil, nil, profilesVars)
@ -3680,7 +3680,7 @@ func testSetMDMWindowsProfilesWithVariables(t *testing.T, ds *Datastore) {
_, err := batchSetProfileVariableAssociationsDB(ctx, ds.writer(ctx), []fleet.MDMProfileUUIDFleetVariables{
{ProfileUUID: globalProfiles[0], FleetVariables: nil},
{ProfileUUID: globalProfiles[1], FleetVariables: nil},
}, "windows")
}, "windows", false)
require.NoError(t, err)
checkProfileVariables(globalProfiles[0], 0, nil)
@ -3690,7 +3690,7 @@ func testSetMDMWindowsProfilesWithVariables(t *testing.T, ds *Datastore) {
_, err = batchSetProfileVariableAssociationsDB(ctx, ds.writer(ctx), []fleet.MDMProfileUUIDFleetVariables{
{ProfileUUID: globalProfiles[0], FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarName(string(fleet.FleetVarDigiCertDataPrefix) + "ZZZ")}},
{ProfileUUID: globalProfiles[1], FleetVariables: []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPGroups}},
}, "windows")
}, "windows", false)
require.NoError(t, err)
checkProfileVariables(globalProfiles[0], 0, []fleet.FleetVarName{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarDigiCertDataPrefix})

View file

@ -1317,12 +1317,33 @@ func triggerResendProfilesUsingVariables(ctx context.Context, tx sqlx.ExtContext
fv.name IN (:affected_vars)
`
const declarationUpdateStatusQuery = `
UPDATE
host_mdm_apple_declarations hmad
JOIN hosts h
ON h.uuid = hmad.host_uuid
JOIN mdm_apple_declarations mad
ON (mad.team_id = h.team_id OR (COALESCE(mad.team_id, 0) = 0 AND h.team_id IS NULL)) AND
mad.declaration_uuid = hmad.declaration_uuid
JOIN mdm_configuration_profile_variables mcpv
ON mcpv.apple_declaration_uuid = mad.declaration_uuid
JOIN fleet_variables fv
ON mcpv.fleet_variable_id = fv.id
SET
hmad.status = NULL
WHERE
h.id IN (:host_ids) AND
hmad.operation_type = :operation_type_install AND
hmad.status IS NOT NULL AND
fv.name IN (:affected_vars)
`
vars := make([]any, len(affectedVars))
for i, v := range affectedVars {
vars[i] = "FLEET_VAR_" + string(v)
}
for _, query := range []string{appleUpdateStatusQuery, windowsUpdateStatusQuery} {
for _, query := range []string{appleUpdateStatusQuery, windowsUpdateStatusQuery, declarationUpdateStatusQuery} {
updateStmt, args, err := sqlx.Named(query, map[string]any{
"host_ids": hostIDs,
"operation_type_install": fleet.MDMOperationTypeInstall,

View file

@ -618,7 +618,7 @@ func testDeleteUsedSecretVariable(t *testing.T, ds *Datastore) {
Identifier: "decl-1",
Name: "decl-1",
RawJSON: json.RawMessage(`{"Identifier": "${FLEET_SECRET_FOOBAR}"}`),
})
}, nil)
require.NoError(t, err)
// Attempt to delete the variable, should fail.
@ -640,7 +640,7 @@ func testDeleteUsedSecretVariable(t *testing.T, ds *Datastore) {
Name: "decl-1",
RawJSON: json.RawMessage(`{"Identifier": "${FLEET_SECRET_FOOBAR}"}`),
TeamID: &foobarTeam.ID,
})
}, nil)
require.NoError(t, err)
// Attempt to delete the variable, should fail.

View file

@ -122,7 +122,7 @@ func testTeamsGetSetDelete(t *testing.T, ds *Datastore) {
Name: "decl-1",
TeamID: &team.ID,
RawJSON: json.RawMessage(`{"Type": "com.apple.configuration.test", "Identifier": "decl-1"}`),
})
}, nil)
require.NoError(t, err)
teamLabel, err := ds.NewLabel(t.Context(), &fleet.Label{

View file

@ -720,9 +720,25 @@ type MDMAppleDeclaration struct {
LabelsIncludeAny []ConfigurationProfileLabel `db:"-" json:"labels_include_any,omitempty"`
LabelsExcludeAny []ConfigurationProfileLabel `db:"-" json:"labels_exclude_any,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
SecretsUpdatedAt *time.Time `db:"secrets_updated_at" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
SecretsUpdatedAt *time.Time `db:"secrets_updated_at" json:"-"`
VariablesUpdatedAt *time.Time `db:"variables_updated_at" json:"-"`
}
// EffectiveDDMToken computes the per-declaration token that incorporates both
// the static content hash and the host-specific variables_updated_at timestamp.
// When variablesUpdatedAt is nil (declaration has no Fleet variables), the
// effective token equals the static token unchanged.
func EffectiveDDMToken(staticToken string, variablesUpdatedAt *time.Time) string {
if variablesUpdatedAt == nil {
return staticToken
}
// Must match MySQL's DATETIME(6) string representation used in
// MDMAppleDDMDeclarationsToken's IFNULL(hmad.variables_updated_at, '').
hasher := md5.New() // nolint:gosec // used for declarative management token
hasher.Write([]byte(staticToken + variablesUpdatedAt.Format("2006-01-02 15:04:05.000000")))
return hex.EncodeToString(hasher.Sum(nil))
}
type MDMAppleRawDeclaration struct {
@ -815,11 +831,16 @@ type MDMAppleHostDeclaration struct {
// SecretsUpdatedAt is the timestamp when the secrets were last updated or when this declaration was uploaded.
SecretsUpdatedAt *time.Time `db:"secrets_updated_at" json:"-"`
// VariablesUpdatedAt tracks when the Fleet variable values for this host
// were last computed. Non-null only for declarations that use Fleet variables.
VariablesUpdatedAt *time.Time `db:"variables_updated_at" json:"-"`
}
func (p MDMAppleHostDeclaration) Equal(other MDMAppleHostDeclaration) bool {
statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status
secretsEqual := p.SecretsUpdatedAt == nil && other.SecretsUpdatedAt == nil || p.SecretsUpdatedAt != nil && other.SecretsUpdatedAt != nil && p.SecretsUpdatedAt.Equal(*other.SecretsUpdatedAt)
varsEqual := p.VariablesUpdatedAt == nil && other.VariablesUpdatedAt == nil || p.VariablesUpdatedAt != nil && other.VariablesUpdatedAt != nil && p.VariablesUpdatedAt.Equal(*other.VariablesUpdatedAt)
return statusEqual &&
p.HostUUID == other.HostUUID &&
p.DeclarationUUID == other.DeclarationUUID &&
@ -828,7 +849,8 @@ func (p MDMAppleHostDeclaration) Equal(other MDMAppleHostDeclaration) bool {
p.OperationType == other.OperationType &&
p.Detail == other.Detail &&
p.Token == other.Token &&
secretsEqual
secretsEqual &&
varsEqual
}
func NewMDMAppleDeclaration(raw []byte, teamID *uint, name string, declType, ident string) *MDMAppleDeclaration {
@ -896,6 +918,10 @@ type MDMAppleDDMDeclarationItem struct {
Status *string `db:"status"`
OperationType *string `db:"operation_type"`
UploadedAt time.Time `db:"uploaded_at"`
// VariablesUpdatedAt is not part of the DDM profile, but part of the host-ddm tuple, as the variables'
// values depend on the host. It is used to compute the token for the DDM for a specific host, as the
// ServerToken field is just for the static token of the DDM.
VariablesUpdatedAt *time.Time `db:"variables_updated_at"`
}
// MDMAppleDDMDeclarationResponse represents a declaration in the datastore. It is used for the DDM

View file

@ -555,6 +555,8 @@ func TestMDMAppleHostDeclarationEqual(t *testing.T) {
fieldsInEqualMethod++
items[1].SecretsUpdatedAt = items[0].SecretsUpdatedAt
fieldsInEqualMethod++
items[1].VariablesUpdatedAt = items[0].VariablesUpdatedAt
fieldsInEqualMethod++
assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleHostDeclaration.Equal needs to be updated for new/updated field(s)")
assert.True(t, items[0].Equal(items[1]))

View file

@ -1764,6 +1764,10 @@ type Datastore interface {
// It also takes care of cleaning up all host declarations that are
// pending removal.
MDMAppleStoreDDMStatusReport(ctx context.Context, hostUUID string, updates []*MDMAppleHostDeclaration) error
// SetHostMDMAppleDeclarationStatus updates the status and detail of a
// single declaration for a host. If variablesUpdatedAt is non-nil, it also
// sets the variables_updated_at timestamp.
SetHostMDMAppleDeclarationStatus(ctx context.Context, hostUUID string, declarationUUID string, status *MDMDeliveryStatus, detail string, variablesUpdatedAt *time.Time) error
// MDMAppleSetPendingDeclarationsAs updates all ("pending", "install")
// declarations for a host to be ("verifying", status), where status is
// the provided value.
@ -2013,10 +2017,10 @@ type Datastore interface {
macDeclarations []*MDMAppleDeclaration, androidProfiles []*MDMAndroidConfigProfile, profilesVariables []MDMProfileIdentifierFleetVariables) (updates MDMProfilesUpdates, err error)
// NewMDMAppleDeclaration creates and returns a new MDM Apple declaration.
NewMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration) (*MDMAppleDeclaration, error)
NewMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration, usesFleetVars []FleetVarName) (*MDMAppleDeclaration, error)
// SetOrUpdateMDMAppleDeclaration upserts the MDM Apple declaration.
SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration) (*MDMAppleDeclaration, error)
SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration, usesFleetVars []FleetVarName) (*MDMAppleDeclaration, error)
///////////////////////////////////////////////////////////////////////////////
// Host Script Results

View file

@ -1231,7 +1231,7 @@ type HostMDMCommand struct {
// MDMProfileUUIDFleetVariables represents the Fleet variables used by a
// profile identified by its UUID.
type MDMProfileUUIDFleetVariables struct {
// ProfileUUID is the UUID of the profile.
// ProfileUUID is the UUID of the profile or declaration.
ProfileUUID string
// FleetVariables is the (deduplicated) list of Fleet variables used by the
// profile, without the "FLEET_VAR_" prefix (as returned by
@ -1242,8 +1242,10 @@ type MDMProfileUUIDFleetVariables struct {
// MDMProfileIdentifierFleetVariables represents the Fleet variables used by a
// profile identified by its identifier.
type MDMProfileIdentifierFleetVariables struct {
// Identifier is the identifier of the profile (which is unique by team for
// Apple profiles).
// Identifier is the identifier of the profile. Because the profile identifier is not guaranteed
// to be unique across platforms and types of profiles (e.g. Apple profiles vs declarations vs Windows
// profiles), it must be prefixed with the same letter used for the UUID prefix of the profile type.
// E.g. fleet.MDMAppleDeclarationUUIDPrefix.
Identifier string
// FleetVariables is the (deduplicated) list of Fleet variables used by the
// profile, without the "FLEET_VAR_" prefix (as returned by

View file

@ -1159,6 +1159,8 @@ type MDMAppleHostDeclarationsGetAndClearResyncFunc func(ctx context.Context) (ho
type MDMAppleStoreDDMStatusReportFunc func(ctx context.Context, hostUUID string, updates []*fleet.MDMAppleHostDeclaration) error
type SetHostMDMAppleDeclarationStatusFunc func(ctx context.Context, hostUUID string, declarationUUID string, status *fleet.MDMDeliveryStatus, detail string, variablesUpdatedAt *time.Time) error
type MDMAppleSetPendingDeclarationsAsFunc func(ctx context.Context, hostUUID string, status *fleet.MDMDeliveryStatus, detail string) error
type MDMAppleSetRemoveDeclarationsAsPendingFunc func(ctx context.Context, hostUUID string, declarationUUIDs []string) error
@ -1297,9 +1299,9 @@ type SetOrUpdateMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.M
type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, profilesVariables []fleet.MDMProfileIdentifierFleetVariables) (updates fleet.MDMProfilesUpdates, err error)
type NewMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error)
type NewMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error)
type SetOrUpdateMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error)
type SetOrUpdateMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error)
type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error)
@ -3568,6 +3570,9 @@ type DataStore struct {
MDMAppleStoreDDMStatusReportFunc MDMAppleStoreDDMStatusReportFunc
MDMAppleStoreDDMStatusReportFuncInvoked bool
SetHostMDMAppleDeclarationStatusFunc SetHostMDMAppleDeclarationStatusFunc
SetHostMDMAppleDeclarationStatusFuncInvoked bool
MDMAppleSetPendingDeclarationsAsFunc MDMAppleSetPendingDeclarationsAsFunc
MDMAppleSetPendingDeclarationsAsFuncInvoked bool
@ -8603,6 +8608,13 @@ func (s *DataStore) MDMAppleStoreDDMStatusReport(ctx context.Context, hostUUID s
return s.MDMAppleStoreDDMStatusReportFunc(ctx, hostUUID, updates)
}
func (s *DataStore) SetHostMDMAppleDeclarationStatus(ctx context.Context, hostUUID string, declarationUUID string, status *fleet.MDMDeliveryStatus, detail string, variablesUpdatedAt *time.Time) error {
s.mu.Lock()
s.SetHostMDMAppleDeclarationStatusFuncInvoked = true
s.mu.Unlock()
return s.SetHostMDMAppleDeclarationStatusFunc(ctx, hostUUID, declarationUUID, status, detail, variablesUpdatedAt)
}
func (s *DataStore) MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUUID string, status *fleet.MDMDeliveryStatus, detail string) error {
s.mu.Lock()
s.MDMAppleSetPendingDeclarationsAsFuncInvoked = true
@ -9086,18 +9098,18 @@ func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProf
return s.BatchSetMDMProfilesFunc(ctx, tmID, macProfiles, winProfiles, macDeclarations, androidProfiles, profilesVariables)
}
func (s *DataStore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
func (s *DataStore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
s.mu.Lock()
s.NewMDMAppleDeclarationFuncInvoked = true
s.mu.Unlock()
return s.NewMDMAppleDeclarationFunc(ctx, declaration)
return s.NewMDMAppleDeclarationFunc(ctx, declaration, usesFleetVars)
}
func (s *DataStore) SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
func (s *DataStore) SetOrUpdateMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
s.mu.Lock()
s.SetOrUpdateMDMAppleDeclarationFuncInvoked = true
s.mu.Unlock()
return s.SetOrUpdateMDMAppleDeclarationFunc(ctx, declaration)
return s.SetOrUpdateMDMAppleDeclarationFunc(ctx, declaration, usesFleetVars)
}
func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {

View file

@ -49,6 +49,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/profiles"
nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
@ -74,6 +75,19 @@ var fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{
fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform,
}
// fleetVarsSupportedInDDMDeclarations is the list of Fleet variables
// supported in Apple DDM declarations.
var fleetVarsSupportedInDDMDeclarations = []fleet.FleetVarName{
fleet.FleetVarHostHardwareSerial,
fleet.FleetVarHostEndUserIDPUsername,
fleet.FleetVarHostEndUserIDPUsernameLocalPart,
fleet.FleetVarHostEndUserIDPGroups,
fleet.FleetVarHostEndUserIDPDepartment,
fleet.FleetVarHostEndUserIDPFullname,
fleet.FleetVarHostUUID,
fleet.FleetVarHostPlatform,
}
type getMDMAppleCommandResultsRequest struct {
CommandUUID string `query:"command_uuid,optional"`
}
@ -860,9 +874,11 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat
return nil, err
}
// Get license for team lookup and variable validation
lic, _ := license.FromContext(ctx)
var teamName string
if teamID > 0 {
lic, _ := license.FromContext(ctx)
if lic == nil || !lic.IsPremium() {
return nil, ctxerr.Wrap(ctx, fleet.ErrMissingLicense)
}
@ -874,7 +890,7 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat
}
var tmID *uint
if teamID >= 1 {
if teamID > 0 {
tmID = &teamID
}
@ -888,8 +904,19 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat
return nil, fleet.NewInvalidArgumentError("profile", err.Error())
}
if err := validateDeclarationFleetVariables(dataWithSecrets); err != nil {
return nil, err
declVars, err := validateDeclarationFleetVariables(dataWithSecrets, lic)
if err != nil {
var badReqErr *fleet.BadRequestError
if errors.As(err, &badReqErr) {
badReqErr.Message = "Couldn't upload profile. " + badReqErr.Message
err = badReqErr
}
return nil, ctxerr.Wrap(ctx, err, "validating declaration Fleet variables")
}
varNames := make([]fleet.FleetVarName, 0, len(declVars))
for _, v := range declVars {
varNames = append(varNames, fleet.FleetVarName(v))
}
// TODO(roberto): Maybe GetRawDeclarationValues belongs inside NewMDMAppleDeclaration? We can refactor this in a follow up.
@ -918,7 +945,7 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat
d.LabelsIncludeAll = validatedLabels
}
decl, err := svc.ds.NewMDMAppleDeclaration(ctx, d)
decl, err := svc.ds.NewMDMAppleDeclaration(ctx, d, varNames)
if err != nil {
return nil, err
}
@ -948,11 +975,177 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat
return decl, nil
}
func validateDeclarationFleetVariables(contents string) error {
if variables.Contains(contents) {
return &fleet.BadRequestError{Message: "Fleet variables ($FLEET_VAR_*) are not currently supported in DDM profiles"}
func validateDeclarationFleetVariables(contents string, lic license.LicenseChecker) ([]string, error) {
fleetVars := variables.Find(contents)
if len(fleetVars) == 0 {
return nil, nil
}
return nil
// Check premium license
if lic == nil || !lic.IsPremium() {
return nil, fleet.ErrMissingLicense
}
// Validate against allowed list
for _, fleetVar := range fleetVars {
if !slices.Contains(fleetVarsSupportedInDDMDeclarations, fleet.FleetVarName(fleetVar)) {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in DDM profiles.", fleetVar),
}
}
}
return fleetVars, nil
}
// jsonEscapeString returns the JSON-escaped interior of a string value
// (without surrounding quotes), suitable for embedding inside a JSON string.
func jsonEscapeString(s string) string {
b, err := json.Marshal(s)
if err != nil {
// json.Marshal on a string should never fail, but return the
// original string as a fallback.
return s
}
// strip surrounding quotes
return string(b[1 : len(b)-1])
}
// replaceDeclarationFleetVariables replaces $FLEET_VAR_* placeholders in a
// DDM declaration with host-specific values. Values are JSON-string-escaped
// so they are safe inside JSON string fields.
func (svc *MDMAppleDDMService) replaceDeclarationFleetVariables(
ctx context.Context, contents string, hostUUID string,
) (string, error) {
fleetVars := variables.Find(contents)
if len(fleetVars) == 0 {
return contents, nil
}
var hostLite fleet.Host
hostLite.UUID = hostUUID
hostHydrated := false
hydrateHost := func() error {
if hostHydrated {
return nil
}
h, ok, err := profiles.HydrateHost(ctx, svc.ds, hostLite, func(n int) error {
return fmt.Errorf("unexpected number of hosts (%d) for UUID %s", n, hostUUID)
})
if err != nil {
return err
}
if !ok {
return fmt.Errorf("host not found for UUID %s", hostUUID)
}
hostLite = h
hostHydrated = true
return nil
}
var idpUser *fleet.HostEndUser
resolveIDPUser := func(varName string) (*fleet.HostEndUser, error) {
if idpUser != nil {
return idpUser, nil
}
if err := hydrateHost(); err != nil {
return nil, err
}
users, err := fleet.GetEndUsers(ctx, svc.ds, hostLite.ID)
if err != nil {
return nil, fmt.Errorf("get end users for host: %w", err)
}
if len(users) == 0 || users[0].IdpUserName == "" {
return nil, fmt.Errorf("There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_%s.", varName)
}
idpUser = &users[0]
return idpUser, nil
}
for _, fleetVar := range fleetVars {
var value string
switch fleet.FleetVarName(fleetVar) {
case fleet.FleetVarHostUUID:
value = hostUUID
case fleet.FleetVarHostHardwareSerial:
if err := hydrateHost(); err != nil {
return "", err
}
if strings.TrimSpace(hostLite.HardwareSerial) == "" {
return "", fmt.Errorf("There is no serial number for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
}
value = hostLite.HardwareSerial
case fleet.FleetVarHostPlatform:
if err := hydrateHost(); err != nil {
return "", err
}
value = hostLite.Platform
if value == "darwin" {
value = "macos"
}
case fleet.FleetVarHostEndUserIDPUsername:
user, err := resolveIDPUser(fleetVar)
if err != nil {
return "", err
}
value = user.IdpUserName
case fleet.FleetVarHostEndUserIDPUsernameLocalPart:
user, err := resolveIDPUser(fleetVar)
if err != nil {
return "", err
}
local, _, _ := strings.Cut(user.IdpUserName, "@")
value = local
case fleet.FleetVarHostEndUserIDPGroups:
user, err := resolveIDPUser(fleetVar)
if err != nil {
return "", err
}
if len(user.IdpGroups) == 0 {
return "", fmt.Errorf("There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
}
value = strings.Join(user.IdpGroups, ",")
case fleet.FleetVarHostEndUserIDPDepartment:
user, err := resolveIDPUser(fleetVar)
if err != nil {
return "", err
}
if user.Department == "" {
return "", fmt.Errorf("There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
}
value = user.Department
case fleet.FleetVarHostEndUserIDPFullname:
user, err := resolveIDPUser(fleetVar)
if err != nil {
return "", err
}
if strings.TrimSpace(user.IdpFullName) == "" {
return "", fmt.Errorf("There is no IdP full name for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
}
value = strings.TrimSpace(user.IdpFullName)
default:
return "", fmt.Errorf("Fleet variable $FLEET_VAR_%s is not supported in DDM declarations.", fleetVar)
}
contents = variables.Replace(contents, fleetVar, jsonEscapeString(value))
}
return contents, nil
}
// markDeclarationFailed marks a DDM declaration as failed for a specific host.
func (svc *MDMAppleDDMService) markDeclarationFailed(ctx context.Context, hostUUID string, d *fleet.MDMAppleDeclaration, detail string) error {
status := fleet.MDMDeliveryFailed
return svc.ds.SetHostMDMAppleDeclarationStatus(ctx, hostUUID, d.DeclarationUUID, &status, detail, nil)
}
func (svc *Service) batchValidateDeclarationLabels(ctx context.Context, labelNames []string, teamID uint) (map[string]fleet.ConfigurationProfileLabel, error) {
@ -5967,31 +6160,34 @@ func (svc *MDMAppleDDMService) handleDeclarationItems(ctx context.Context, hostU
}
continue
}
effectiveToken := fleet.EffectiveDDMToken(d.ServerToken, d.VariablesUpdatedAt)
configurations = append(configurations, fleet.MDMAppleDDMManifest{
Identifier: d.Identifier,
ServerToken: d.ServerToken,
ServerToken: effectiveToken,
})
activations = append(activations, fleet.MDMAppleDDMManifest{
Identifier: fmt.Sprintf("%s.activation", d.Identifier),
ServerToken: d.ServerToken,
ServerToken: effectiveToken,
})
}
// Calculate token based on count and concatenated tokens for install items
var count int
type tokenSorting struct {
token string
uploadedAt time.Time
declarationUUID string
token string
variablesUpdatedAt *time.Time
uploadedAt time.Time
declarationUUID string
}
var tokens []tokenSorting
for _, d := range di {
if d.OperationType != nil && *d.OperationType == string(fleet.MDMOperationTypeInstall) {
// Extract d.ServerToken and order by d.UploadedAt descending and then by d.DeclarationUUID ascending
sorting := tokenSorting{
token: d.ServerToken,
uploadedAt: d.UploadedAt,
declarationUUID: d.DeclarationUUID,
token: d.ServerToken,
variablesUpdatedAt: d.VariablesUpdatedAt,
uploadedAt: d.UploadedAt,
declarationUUID: d.DeclarationUUID,
}
tokens = append(tokens, sorting)
count++
@ -6007,6 +6203,11 @@ func (svc *MDMAppleDDMService) handleDeclarationItems(ctx context.Context, hostU
var tokenBuilder strings.Builder
for _, t := range tokens {
tokenBuilder.WriteString(t.token)
if t.variablesUpdatedAt != nil {
// Must match MySQL's DATETIME(6) string representation used in
// MDMAppleDDMDeclarationsToken's IFNULL(hmad.variables_updated_at, '').
tokenBuilder.WriteString(t.variablesUpdatedAt.Format("2006-01-02 15:04:05.000000"))
}
}
var token string
@ -6080,7 +6281,7 @@ func (svc *MDMAppleDDMService) handleActivationDeclaration(ctx context.Context,
},
"ServerToken": "%s",
"Type": "com.apple.activation.simple"
}`, parts[2], references, d.Token)
}`, parts[2], references, fleet.EffectiveDDMToken(d.Token, d.VariablesUpdatedAt))
return []byte(response), nil
}
@ -6099,11 +6300,21 @@ func (svc *MDMAppleDDMService) handleConfigurationDeclaration(ctx context.Contex
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expanding embedded secrets for identifier:%s hostUUID:%s", parts[2], hostUUID))
}
// Replace Fleet variables with host-specific values
expanded, err = svc.replaceDeclarationFleetVariables(ctx, expanded, hostUUID)
if err != nil {
// Mark this declaration as failed for this host, return empty 200
if err := svc.markDeclarationFailed(ctx, hostUUID, d, err.Error()); err != nil {
return nil, ctxerr.Wrap(ctx, err, "mark declaration as failed")
}
return nil, nil
}
var tempd map[string]any
if err := json.Unmarshal([]byte(expanded), &tempd); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshaling stored declaration")
}
tempd["ServerToken"] = d.Token
tempd["ServerToken"] = fleet.EffectiveDDMToken(d.Token, d.VariablesUpdatedAt) //nolint:nilaway // tempd is non-nil after successful json.Unmarshal
b, err := json.Marshal(tempd)
if err != nil {

View file

@ -49,7 +49,7 @@ func TestDeclarativeManagement_DeclarationItems(t *testing.T) {
TeamID: nil,
RawJSON: []byte(fmt.Sprintf(`{"Type":"com.apple.test.declaration","Identifier":"%s"}`, identifier)),
}
declaration, err := ds.NewMDMAppleDeclaration(context.Background(), declaration)
declaration, err := ds.NewMDMAppleDeclaration(context.Background(), declaration, nil)
require.NoError(t, err)
return declaration
}

View file

@ -887,7 +887,7 @@ func TestNewMDMAppleDeclaration(t *testing.T) {
_, err = svc.NewMDMAppleDeclaration(ctx, 0, b, nil, "name", fleet.LabelsIncludeAll)
assert.ErrorContains(t, err, "Only configuration declarations (com.apple.configuration.) are supported")
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
return d, nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
@ -973,7 +973,7 @@ func TestNewMDMAppleDeclarationSkipValidation(t *testing.T) {
ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, s string) (string, *time.Time, error) {
return s, nil, nil
}
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
return d, nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
@ -1016,7 +1016,7 @@ func TestNewMDMAppleDeclarationSkipValidation(t *testing.T) {
ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, s string) (string, *time.Time, error) {
return s, nil, nil
}
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
return d, nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
@ -1058,7 +1058,7 @@ func TestNewMDMAppleDeclarationSkipValidation(t *testing.T) {
ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, s string) (string, *time.Time, error) {
return s, nil, nil
}
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
return d, nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string,
@ -6338,6 +6338,103 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
}
}
func TestValidateDeclarationFleetVariables(t *testing.T) {
t.Parallel()
premiumLic := &fleet.LicenseInfo{Tier: fleet.TierPremium}
freeLic := &fleet.LicenseInfo{Tier: fleet.TierFree}
// helper to create a simple DDM declaration JSON with a value field
makeDecl := func(value string) string {
return fmt.Sprintf(`{"Type": "com.apple.configuration.management.test", "Identifier": "com.example.test", "Payload": {"Value": %q}}`, value)
}
t.Run("no variables, free license", func(t *testing.T) {
vars, err := validateDeclarationFleetVariables(makeDecl("static-value"), freeLic)
require.NoError(t, err)
require.Nil(t, vars)
})
t.Run("supported variable with premium license", func(t *testing.T) {
vars, err := validateDeclarationFleetVariables(makeDecl("$FLEET_VAR_HOST_HARDWARE_SERIAL"), premiumLic)
require.NoError(t, err)
require.Equal(t, []string{"HOST_HARDWARE_SERIAL"}, vars)
})
t.Run("supported variable with braces", func(t *testing.T) {
vars, err := validateDeclarationFleetVariables(makeDecl("${FLEET_VAR_HOST_UUID}"), premiumLic)
require.NoError(t, err)
require.Equal(t, []string{"HOST_UUID"}, vars)
})
t.Run("multiple supported variables", func(t *testing.T) {
vars, err := validateDeclarationFleetVariables(
makeDecl(`["$FLEET_VAR_HOST_HARDWARE_SERIAL", "$FLEET_VAR_HOST_END_USER_IDP_USERNAME"]`), premiumLic)
require.NoError(t, err)
require.ElementsMatch(t, []string{"HOST_HARDWARE_SERIAL", "HOST_END_USER_IDP_USERNAME"}, vars)
})
t.Run("all supported variables", func(t *testing.T) {
// Build the declaration content and expected results from the allowed list
var jsonVars, expectedVars []string
for _, v := range fleetVarsSupportedInDDMDeclarations {
jsonVars = append(jsonVars, fmt.Sprintf(`"$FLEET_VAR_%s"`, v))
expectedVars = append(expectedVars, string(v))
}
vars, err := validateDeclarationFleetVariables(
makeDecl("["+strings.Join(jsonVars, ", ")+"]"), premiumLic)
require.NoError(t, err)
require.ElementsMatch(t, expectedVars, vars)
})
t.Run("supported variable without premium license", func(t *testing.T) {
_, err := validateDeclarationFleetVariables(makeDecl("$FLEET_VAR_HOST_HARDWARE_SERIAL"), freeLic)
require.ErrorIs(t, err, fleet.ErrMissingLicense)
})
t.Run("supported variable with nil license", func(t *testing.T) {
_, err := validateDeclarationFleetVariables(makeDecl("$FLEET_VAR_HOST_UUID"), nil)
require.ErrorIs(t, err, fleet.ErrMissingLicense)
})
t.Run("unsupported variable", func(t *testing.T) {
_, err := validateDeclarationFleetVariables(makeDecl("$FLEET_VAR_NDES_SCEP_CHALLENGE"), premiumLic)
require.Error(t, err)
require.ErrorContains(t, err, "Fleet variable $FLEET_VAR_NDES_SCEP_CHALLENGE is not supported in DDM profiles")
})
t.Run("supported and unsupported variables", func(t *testing.T) {
_, err := validateDeclarationFleetVariables(
makeDecl(`["$FLEET_VAR_HOST_UUID", "$FLEET_VAR_DIGICERT_DATA_myCA"]`), premiumLic)
require.Error(t, err)
require.ErrorContains(t, err, "Fleet variable $FLEET_VAR_DIGICERT_DATA_myCA is not supported in DDM profiles")
})
}
func TestJSONEscapeString(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
expected string
}{
{"plain string", "hello", "hello"},
{"with double quotes", `say "hi"`, `say \"hi\"`},
{"with backslash", `path\to\file`, `path\\to\\file`},
{"with newline", "line1\nline2", `line1\nline2`},
{"with tab", "col1\tcol2", `col1\tcol2`},
{"empty string", "", ""},
{"unicode", "café ☕", "café ☕"},
{"control chars", "a\x00b", `a\u0000b`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, jsonEscapeString(tc.input))
})
}
}
//go:embed testdata/profiles/digicert-validation.mobileconfig
var digiCertValidationMobileconfig string

View file

@ -7723,6 +7723,26 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
createHostAndDeviceToken(t, s.ds, "some-token")
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "some-token"), nil, http.StatusPaymentRequired)
// uploading a DDM declaration with a Fleet variable returns a license error
// (single profile upload endpoint)
ddmWithFleetVar := []byte(`{
"Type": "com.apple.configuration.management.test",
"Identifier": "com.example.fleetvar-test",
"Payload": {"Value": "$FLEET_VAR_HOST_HARDWARE_SERIAL"}
}`)
body, headers := generateNewProfileMultipartRequest(t, "fleetvar-test.json", ddmWithFleetVar, s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusPaymentRequired, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Requires Fleet Premium license")
// uploading a DDM declaration with a Fleet variable returns a license error
// (batch profiles endpoint)
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: ddmWithFleetVar},
}}, http.StatusPaymentRequired)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Requires Fleet Premium license")
// software titles
// a normal request works fine
var resp listSoftwareTitlesResponse
@ -15052,7 +15072,7 @@ func (s *integrationTestSuite) TestSecretVariablesInUse() {
Name: "decl-1",
RawJSON: json.RawMessage(`{"Identifier": "${FLEET_SECRET_NAME1}"}`),
TeamID: &foobarTeam.ID,
})
}, nil)
require.NoError(t, err)
res = s.DoRaw("DELETE", fmt.Sprintf("/api/latest/fleet/custom_variables/%d", firstVariableID), nil, http.StatusConflict)

View file

@ -1302,6 +1302,569 @@ func (s *integrationMDMTestSuite) TestDDMTransactionRecording() {
})
}
func (s *integrationMDMTestSuite) TestAppleDDMFleetVariables() {
t := s.T()
ctx := t.Context()
// === Setup ===
// Create two MDM-enrolled hosts
host1, mdmDevice1 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
_, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// Set host1's serial to a value with characters that need JSON escaping,
// to verify that variable substitution produces valid JSON.
host1.HardwareSerial = `SER"IAL\123`
err := s.ds.UpdateHost(ctx, host1)
require.NoError(t, err)
// Create a team and transfer host1 into it; host2 stays global (control)
team := &fleet.Team{Name: t.Name() + "team1"}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{host1.ID}}, http.StatusOK)
// Helper: read declaration from DB by name
getDeclaration := func(t *testing.T, name string) fleet.MDMAppleDeclaration {
stmt := `
SELECT
declaration_uuid,
team_id,
identifier,
name,
raw_json,
HEX(token) as token,
created_at,
uploaded_at
FROM mdm_apple_declarations
WHERE name = ?`
var decl fleet.MDMAppleDeclaration
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &decl, stmt, name)
})
return decl
}
// Helper: read variables_updated_at for a host/declaration pair
getHostDeclVarsUpdatedAt := func(t *testing.T, hostUUID, declUUID string) *time.Time {
var result []time.Time
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &result,
`SELECT variables_updated_at FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ? AND variables_updated_at IS NOT NULL`,
hostUUID, declUUID)
})
if len(result) == 0 {
return nil
}
return &result[0]
}
checkNoCommands := func(d *mdmtest.TestAppleMDMClient) {
cmd, err := d.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
}
checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
cmd, err := d.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
cmd, err = d.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
require.Nil(t, cmd)
_, err = d.DeclarativeManagement("tokens")
require.NoError(t, err)
}
checkDeclarationItemsResp := func(t *testing.T, r fleet.MDMAppleDDMDeclarationItemsResponse, expectedDeclTok string,
expectedDeclsByToken map[string]fleet.MDMAppleDeclaration,
) {
require.Equal(t, expectedDeclTok, r.DeclarationsToken)
require.NotEmpty(t, r.Declarations.Activations)
require.Empty(t, r.Declarations.Assets)
require.Empty(t, r.Declarations.Management)
require.Len(t, r.Declarations.Configurations, len(expectedDeclsByToken))
for _, m := range r.Declarations.Configurations {
d, ok := expectedDeclsByToken[m.ServerToken]
require.True(t, ok, "server token %x not found for %s", m.ServerToken, m.Identifier)
require.Equal(t, d.Identifier, m.Identifier)
}
}
teamIDStr := fmt.Sprintf("%d", team.ID)
// Declaration payloads
declWithUUID := []byte(`{
"Type": "com.apple.configuration.management.test",
"Payload": {"Echo": "$FLEET_VAR_HOST_UUID"},
"Identifier": "com.fleet.var.uuid"
}`)
declWithSerial := []byte(`{
"Type": "com.apple.configuration.management.test",
"Payload": {"Echo": "$FLEET_VAR_HOST_HARDWARE_SERIAL"},
"Identifier": "com.fleet.var.serial"
}`)
declPlain := []byte(`{
"Type": "com.apple.configuration.management.test",
"Payload": {"Echo": "static-value"},
"Identifier": "com.fleet.plain"
}`)
// === Failing upload (unsupported variable) ===
badDecl := []byte(`{
"Type": "com.apple.configuration.management.test",
"Payload": {"Echo": "$FLEET_VAR_NDES_SCEP_CHALLENGE"},
"Identifier": "com.fleet.bad"
}`)
badReq := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "BadDecl.json", Contents: badDecl},
}}
badRes := s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", badReq, http.StatusBadRequest,
"team_id", teamIDStr)
errMsg := extractServerErrorText(badRes.Body)
require.Contains(t, errMsg, "$FLEET_VAR_NDES_SCEP_CHALLENGE is not supported in DDM")
// === Upload declarations with and without variables ===
profilesReq := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "VarUUID.json", Contents: declWithUUID},
{Name: "VarSerial.json", Contents: declWithSerial},
{Name: "Plain.json", Contents: declPlain},
}}
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent,
"team_id", teamIDStr)
// Verify raw JSON stored as-is (variables not expanded in storage)
dbDeclUUID := getDeclaration(t, "VarUUID.json")
assert.Contains(t, string(dbDeclUUID.RawJSON), "$FLEET_VAR_HOST_UUID")
dbDeclSerial := getDeclaration(t, "VarSerial.json")
assert.Contains(t, string(dbDeclSerial.RawJSON), "$FLEET_VAR_HOST_HARDWARE_SERIAL")
dbDeclPlain := getDeclaration(t, "Plain.json")
assert.Contains(t, string(dbDeclPlain.RawJSON), "static-value")
// === First sync — verify variable substitution ===
s.awaitTriggerProfileSchedule(t)
checkDDMSync(mdmDevice1)
checkNoCommands(mdmDevice2)
// Host1 fetches tokens
r, err := mdmDevice1.DeclarativeManagement("tokens")
require.NoError(t, err)
tokens := parseTokensResp(t, r)
lastSyncDeclToken := tokens.SyncTokens.DeclarationsToken
require.NotEmpty(t, lastSyncDeclToken)
// Fetch individual declarations and verify substitution
var gotParsed fleet.MDMAppleDDMDeclarationResponse
r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.uuid")
require.NoError(t, err)
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
assert.Contains(t, string(gotParsed.Payload), host1.UUID)
assert.NotContains(t, string(gotParsed.Payload), "$FLEET_VAR")
r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.serial")
require.NoError(t, err)
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
assert.NotContains(t, string(gotParsed.Payload), "$FLEET_VAR")
// Verify the serial (which contains " and \) is properly JSON-escaped:
// the payload must be valid JSON and unmarshal to the original value.
var serialPayload struct{ Echo string }
require.NoError(t, json.Unmarshal(gotParsed.Payload, &serialPayload))
assert.Equal(t, host1.HardwareSerial, serialPayload.Echo)
r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.plain")
require.NoError(t, err)
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
assert.Contains(t, string(gotParsed.Payload), "static-value")
// Verify variables_updated_at: set for variable decls, nil for plain
varsUpdatedUUID := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
require.NotNil(t, varsUpdatedUUID)
varsUpdatedSerial := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, varsUpdatedSerial)
varsUpdatedPlain := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclPlain.DeclarationUUID)
require.Nil(t, varsUpdatedPlain)
// Build expected declaration-items map with effective tokens (incorporating variables_updated_at)
declsByToken := map[string]fleet.MDMAppleDeclaration{
fleet.EffectiveDDMToken(dbDeclUUID.Token, varsUpdatedUUID): {Identifier: "com.fleet.var.uuid"},
fleet.EffectiveDDMToken(dbDeclSerial.Token, varsUpdatedSerial): {Identifier: "com.fleet.var.serial"},
dbDeclPlain.Token: {Identifier: "com.fleet.plain"},
}
// Host1 fetches declaration items
r, err = mdmDevice1.DeclarativeManagement("declaration-items")
require.NoError(t, err)
itemsResp := parseDeclarationItemsResp(t, r)
checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken)
// === No resend when unrelated declaration added ===
newDecl := []byte(`{
"Type": "com.apple.configuration.management.test",
"Payload": {"Echo": "new-stuff"},
"Identifier": "com.fleet.new"
}`)
profilesReqWithNew := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "VarUUID.json", Contents: declWithUUID},
{Name: "VarSerial.json", Contents: declWithSerial},
{Name: "Plain.json", Contents: declPlain},
{Name: "NewDecl.json", Contents: newDecl},
}}
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReqWithNew, http.StatusNoContent,
"team_id", teamIDStr)
dbNewDecl := getDeclaration(t, "NewDecl.json")
assert.Contains(t, string(dbNewDecl.RawJSON), "new-stuff")
s.awaitTriggerProfileSchedule(t)
// Host1 gets DDM sync (declaration set changed), host2 nothing
checkDDMSync(mdmDevice1)
checkNoCommands(mdmDevice2)
r, err = mdmDevice1.DeclarativeManagement("tokens")
require.NoError(t, err)
tokens = parseTokensResp(t, r)
lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken
require.NotEmpty(t, lastSyncDeclToken)
declsByToken = map[string]fleet.MDMAppleDeclaration{
fleet.EffectiveDDMToken(dbDeclUUID.Token, varsUpdatedUUID): {Identifier: "com.fleet.var.uuid"},
fleet.EffectiveDDMToken(dbDeclSerial.Token, varsUpdatedSerial): {Identifier: "com.fleet.var.serial"},
dbDeclPlain.Token: {Identifier: "com.fleet.plain"},
dbNewDecl.Token: {Identifier: "com.fleet.new"},
}
r, err = mdmDevice1.DeclarativeManagement("declaration-items")
require.NoError(t, err)
itemsResp = parseDeclarationItemsResp(t, r)
checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken)
// variables_updated_at did NOT change for existing variable declarations
varsUpdatedUUIDAfterAdd := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
require.NotNil(t, varsUpdatedUUIDAfterAdd)
assert.Equal(t, *varsUpdatedUUID, *varsUpdatedUUIDAfterAdd)
varsUpdatedSerialAfterAdd := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, varsUpdatedSerialAfterAdd)
assert.Equal(t, *varsUpdatedSerial, *varsUpdatedSerialAfterAdd)
// === No resend when unrelated declaration deleted ===
// new decl is not in profilesReq, so it will be deleted
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent,
"team_id", teamIDStr)
s.awaitTriggerProfileSchedule(t)
// Host1 gets DDM sync (declaration set changed), host2 nothing
checkDDMSync(mdmDevice1)
checkNoCommands(mdmDevice2)
declsByToken = map[string]fleet.MDMAppleDeclaration{
fleet.EffectiveDDMToken(dbDeclUUID.Token, varsUpdatedUUID): {Identifier: "com.fleet.var.uuid"},
fleet.EffectiveDDMToken(dbDeclSerial.Token, varsUpdatedSerial): {Identifier: "com.fleet.var.serial"},
dbDeclPlain.Token: {Identifier: "com.fleet.plain"},
}
r, err = mdmDevice1.DeclarativeManagement("tokens")
require.NoError(t, err)
tokens = parseTokensResp(t, r)
lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken
require.NotEmpty(t, lastSyncDeclToken)
r, err = mdmDevice1.DeclarativeManagement("declaration-items")
require.NoError(t, err)
itemsResp = parseDeclarationItemsResp(t, r)
checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken)
// variables_updated_at still unchanged
varsUpdatedUUIDAfterDel := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
require.NotNil(t, varsUpdatedUUIDAfterDel)
assert.Equal(t, *varsUpdatedUUID, *varsUpdatedUUIDAfterDel)
varsUpdatedSerialAfterDel := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, varsUpdatedSerialAfterDel)
assert.Equal(t, *varsUpdatedSerial, *varsUpdatedSerialAfterDel)
// === No resend on no-op GitOps batch upload ===
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent,
"team_id", teamIDStr)
s.awaitTriggerProfileSchedule(t)
// No commands for either host — nothing changed
checkNoCommands(mdmDevice1)
checkNoCommands(mdmDevice2)
// Token unchanged
r, err = mdmDevice1.DeclarativeManagement("tokens")
require.NoError(t, err)
tokens = parseTokensResp(t, r)
assert.Equal(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken)
// === Resend when variable values change ===
// Simulate variable value change: set status = NULL on variable declarations.
// This is the same operation triggerResendProfilesUsingVariables performs.
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`UPDATE host_mdm_apple_declarations SET status = NULL
WHERE host_uuid = ? AND declaration_uuid IN (?, ?)`,
host1.UUID, dbDeclUUID.DeclarationUUID, dbDeclSerial.DeclarationUUID,
)
return err
})
s.awaitTriggerProfileSchedule(t)
// Host1 gets DDM sync, host2 nothing
checkDDMSync(mdmDevice1)
checkNoCommands(mdmDevice2)
// variables_updated_at for variable declarations was updated (newer)
varsUpdatedUUIDAfterChange := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
require.NotNil(t, varsUpdatedUUIDAfterChange)
assert.True(t, varsUpdatedUUIDAfterChange.After(*varsUpdatedUUID),
"variables_updated_at should be newer after variable change, got %v vs original %v", varsUpdatedUUIDAfterChange, varsUpdatedUUID)
varsUpdatedSerialAfterChange := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, varsUpdatedSerialAfterChange)
assert.True(t, varsUpdatedSerialAfterChange.After(*varsUpdatedSerial),
"variables_updated_at should be newer after variable change, got %v vs original %v", varsUpdatedSerialAfterChange, varsUpdatedSerial)
// Plain declaration's variables_updated_at is still nil
varsUpdatedPlainAfterChange := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclPlain.DeclarationUUID)
require.Nil(t, varsUpdatedPlainAfterChange)
// Token changed
r, err = mdmDevice1.DeclarativeManagement("tokens")
require.NoError(t, err)
tokens = parseTokensResp(t, r)
assert.NotEqual(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken)
// Variables still substituted correctly
r, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.uuid")
require.NoError(t, err)
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
assert.Contains(t, string(gotParsed.Payload), host1.UUID)
// === Variable change on one host does not resend to teammate ===
// Create a third host on the same team as host1
host3, mdmDevice3 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{host3.ID}}, http.StatusOK)
// Let host3 complete its initial DDM sync
s.awaitTriggerProfileSchedule(t)
checkDDMSync(mdmDevice3)
checkNoCommands(mdmDevice1)
checkNoCommands(mdmDevice2)
// Record host3's variables_updated_at after initial sync
host3InitVarsUpdatedUUID := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUID.DeclarationUUID)
require.NotNil(t, host3InitVarsUpdatedUUID)
host3InitVarsUpdatedSerial := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, host3InitVarsUpdatedSerial)
// Verify stable state: no-op batch upload triggers no commands for anyone
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReq, http.StatusNoContent,
"team_id", teamIDStr)
s.awaitTriggerProfileSchedule(t)
checkNoCommands(mdmDevice1)
checkNoCommands(mdmDevice2)
checkNoCommands(mdmDevice3)
// Simulate variable change for host1 only
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`UPDATE host_mdm_apple_declarations SET status = NULL
WHERE host_uuid = ? AND declaration_uuid IN (?, ?)`,
host1.UUID, dbDeclUUID.DeclarationUUID, dbDeclSerial.DeclarationUUID,
)
return err
})
s.awaitTriggerProfileSchedule(t)
// Only host1 gets DDM sync; host3 (same team) and host2 (global) do not
checkDDMSync(mdmDevice1)
checkNoCommands(mdmDevice2)
checkNoCommands(mdmDevice3)
// Verify host3's variables_updated_at was not changed by host1's resend
varsUpdatedUUIDHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUID.DeclarationUUID)
require.NotNil(t, varsUpdatedUUIDHost3)
assert.Equal(t, *host3InitVarsUpdatedUUID, *varsUpdatedUUIDHost3)
varsUpdatedSerialHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, varsUpdatedSerialHost3)
assert.Equal(t, *host3InitVarsUpdatedSerial, *varsUpdatedSerialHost3)
// host3 fetches its own declarations — variables are correctly substituted
// with host3's own values
r, err = mdmDevice3.DeclarativeManagement("declaration/configuration/com.fleet.var.uuid")
require.NoError(t, err)
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
assert.Contains(t, string(gotParsed.Payload), host3.UUID)
assert.NotContains(t, string(gotParsed.Payload), host1.UUID)
r, err = mdmDevice3.DeclarativeManagement("declaration/configuration/com.fleet.var.serial")
require.NoError(t, err)
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
assert.Contains(t, string(gotParsed.Payload), host3.HardwareSerial)
assert.NotContains(t, string(gotParsed.Payload), host1.HardwareSerial)
// === Failed variable resolution (no IdP user for host) ===
declWithIdpUsername := []byte(`{
"Type": "com.apple.configuration.management.test",
"Payload": {"Echo": "$FLEET_VAR_HOST_END_USER_IDP_USERNAME"},
"Identifier": "com.fleet.var.idpusername"
}`)
profilesReqWithIdp := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "VarUUID.json", Contents: declWithUUID},
{Name: "VarSerial.json", Contents: declWithSerial},
{Name: "Plain.json", Contents: declPlain},
{Name: "VarIdpUsername.json", Contents: declWithIdpUsername},
}}
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReqWithIdp, http.StatusNoContent,
"team_id", teamIDStr)
dbDeclIdpUsername := getDeclaration(t, "VarIdpUsername.json")
s.awaitTriggerProfileSchedule(t)
// Host1 gets DDM sync (declaration set changed)
checkDDMSync(mdmDevice1)
checkNoCommands(mdmDevice2)
r, err = mdmDevice1.DeclarativeManagement("tokens")
require.NoError(t, err)
tokens = parseTokensResp(t, r)
lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken
require.NotEmpty(t, lastSyncDeclToken)
// Get current variables_updated_at for host1's declarations (may have changed since earlier captures)
latestVarsUpdatedUUID := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
latestVarsUpdatedSerial := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
latestVarsUpdatedIdp := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclIdpUsername.DeclarationUUID)
declsByToken = map[string]fleet.MDMAppleDeclaration{
fleet.EffectiveDDMToken(dbDeclUUID.Token, latestVarsUpdatedUUID): {Identifier: "com.fleet.var.uuid"},
fleet.EffectiveDDMToken(dbDeclSerial.Token, latestVarsUpdatedSerial): {Identifier: "com.fleet.var.serial"},
dbDeclPlain.Token: {Identifier: "com.fleet.plain"},
fleet.EffectiveDDMToken(dbDeclIdpUsername.Token, latestVarsUpdatedIdp): {Identifier: "com.fleet.var.idpusername"},
}
r, err = mdmDevice1.DeclarativeManagement("declaration-items")
require.NoError(t, err)
itemsResp = parseDeclarationItemsResp(t, r)
checkDeclarationItemsResp(t, itemsResp, lastSyncDeclToken, declsByToken)
// Host1 fetches the IdP username declaration — variable resolution fails
// because no IdP user exists for the host. The server returns an empty 200
// and marks the declaration as failed.
_, err = mdmDevice1.DeclarativeManagement("declaration/configuration/com.fleet.var.idpusername")
require.NoError(t, err)
// Verify the declaration is marked as failed with the expected detail message
var hostDecl fleet.MDMAppleHostDeclaration
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &hostDecl,
`SELECT status, detail FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ?`,
host1.UUID, dbDeclIdpUsername.DeclarationUUID)
})
require.NotNil(t, hostDecl.Status)
assert.Equal(t, fleet.MDMDeliveryFailed, *hostDecl.Status)
assert.Contains(t, hostDecl.Detail, "There is no IdP username for this host")
assert.Contains(t, hostDecl.Detail, "$FLEET_VAR_HOST_END_USER_IDP_USERNAME")
// === Updating variable declaration to non-variable clears variables_updated_at ===
// Drain host3's pending DDM sync from the IdP batch upload above
checkDDMSync(mdmDevice3)
// Capture current token for comparison
r, err = mdmDevice1.DeclarativeManagement("tokens")
require.NoError(t, err)
tokens = parseTokensResp(t, r)
lastSyncDeclToken = tokens.SyncTokens.DeclarationsToken
require.NotEmpty(t, lastSyncDeclToken)
// Verify variables_updated_at is non-nil for VarUUID and VarSerial on both team hosts
preVarsUpdatedUUIDHost1 := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUID.DeclarationUUID)
require.NotNil(t, preVarsUpdatedUUIDHost1)
preVarsUpdatedUUIDHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUID.DeclarationUUID)
require.NotNil(t, preVarsUpdatedUUIDHost3)
preVarsUpdatedSerialHost1 := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, preVarsUpdatedSerialHost1)
preVarsUpdatedSerialHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, preVarsUpdatedSerialHost3)
// Update VarUUID.json to remove the variable (same name/identifier, static content)
declUUIDNowStatic := []byte(`{
"Type": "com.apple.configuration.management.test",
"Payload": {"Echo": "static-uuid-replacement"},
"Identifier": "com.fleet.var.uuid"
}`)
profilesReqVarRemoved := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "VarUUID.json", Contents: declUUIDNowStatic},
{Name: "VarSerial.json", Contents: declWithSerial},
{Name: "Plain.json", Contents: declPlain},
{Name: "VarIdpUsername.json", Contents: declWithIdpUsername},
}}
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", profilesReqVarRemoved, http.StatusNoContent,
"team_id", teamIDStr)
// Re-read the declaration from DB — content and token should have changed
dbDeclUUIDUpdated := getDeclaration(t, "VarUUID.json")
assert.Contains(t, string(dbDeclUUIDUpdated.RawJSON), "static-uuid-replacement")
assert.NotContains(t, string(dbDeclUUIDUpdated.RawJSON), "$FLEET_VAR")
assert.NotEqual(t, dbDeclUUID.Token, dbDeclUUIDUpdated.Token)
// Declaration UUID stays the same (updated in place)
assert.Equal(t, dbDeclUUID.DeclarationUUID, dbDeclUUIDUpdated.DeclarationUUID)
s.awaitTriggerProfileSchedule(t)
// Both team hosts get DDM sync (declaration content changed)
checkDDMSync(mdmDevice1)
checkDDMSync(mdmDevice3)
// Global host gets nothing
checkNoCommands(mdmDevice2)
// Token changed (declaration requires re-delivery)
r, err = mdmDevice1.DeclarativeManagement("tokens")
require.NoError(t, err)
tokens = parseTokensResp(t, r)
assert.NotEqual(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken)
// variables_updated_at for VarUUID.json is now NULL (no more variables)
varsUpdatedUUIDAfterRemoval := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclUUIDUpdated.DeclarationUUID)
assert.Nil(t, varsUpdatedUUIDAfterRemoval, "variables_updated_at should be NULL after removing variable from declaration (host1)")
varsUpdatedUUIDAfterRemovalHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclUUIDUpdated.DeclarationUUID)
assert.Nil(t, varsUpdatedUUIDAfterRemovalHost3, "variables_updated_at should be NULL after removing variable from declaration (host3)")
// VarSerial.json still has variables — variables_updated_at unchanged on both hosts
varsUpdatedSerialAfterRemoval := getHostDeclVarsUpdatedAt(t, host1.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, varsUpdatedSerialAfterRemoval)
assert.Equal(t, *preVarsUpdatedSerialHost1, *varsUpdatedSerialAfterRemoval, "VarSerial variables_updated_at should be unchanged on host1")
varsUpdatedSerialAfterRemovalHost3 := getHostDeclVarsUpdatedAt(t, host3.UUID, dbDeclSerial.DeclarationUUID)
require.NotNil(t, varsUpdatedSerialAfterRemovalHost3)
assert.Equal(t, *preVarsUpdatedSerialHost3, *varsUpdatedSerialAfterRemovalHost3, "VarSerial variables_updated_at should be unchanged on host3")
}
func declarationForTest(identifier string) []byte {
return []byte(fmt.Sprintf(`
{

View file

@ -2293,23 +2293,30 @@ func validateFleetVariables(ctx context.Context, ds fleet.Datastore, appConfig *
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating config profile Fleet variables")
}
profileVarsByProfIdentifier[p.Identifier] = profileVars
profileVarsByProfIdentifier[fleet.MDMAppleProfileUUIDPrefix+p.Identifier] = profileVars
}
for _, p := range windowsProfiles {
windowsVars, err := validateWindowsProfileFleetVariables(string(p.SyncML), lic, groupedCAs)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating Windows profile Fleet variables")
}
// Collect Fleet variables for Windows profiles (use unique Name as identifier for Windows)
if len(windowsVars) > 0 {
profileVarsByProfIdentifier[p.Name] = windowsVars
profileVarsByProfIdentifier[fleet.MDMWindowsProfileUUIDPrefix+p.Name] = windowsVars
}
}
for _, p := range appleDecls {
err = validateDeclarationFleetVariables(string(p.RawJSON))
declVars, err := validateDeclarationFleetVariables(string(p.RawJSON), lic)
if err != nil {
var badReqErr *fleet.BadRequestError
if errors.As(err, &badReqErr) {
badReqErr.Message = "Couldn't set profile. " + badReqErr.Message
err = badReqErr
}
return nil, ctxerr.Wrap(ctx, err, "validating declaration Fleet variables")
}
if len(declVars) > 0 {
profileVarsByProfIdentifier[fleet.MDMAppleDeclarationUUIDPrefix+p.Identifier] = declVars
}
}
return profileVarsByProfIdentifier, nil
}