Skip to content

DDMV: Support Fleet variables in DDM - #43222

Merged
mna merged 38 commits into
mainfrom
mna-43047-ddm-vars-logic
Apr 20, 2026
Merged

DDMV: Support Fleet variables in DDM#43222
mna merged 38 commits into
mainfrom
mna-43047-ddm-vars-logic

Conversation

@mna

@mna mna commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Related issue: Resolves #43047

Checklist for submitter

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

  • 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

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.

@codecov

codecov Bot commented Apr 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.48322% with 82 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.92%. Comparing base (85552a1) to head (bcef9ae).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
server/service/apple_mdm.go 53.62% 52 Missing and 12 partials ⚠️
server/datastore/mysql/apple_mdm.go 83.87% 7 Missing and 8 partials ⚠️
server/datastore/mysql/mdm.go 91.66% 0 Missing and 2 partials ⚠️
server/datastore/mysql/microsoft_mdm.go 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #43222    +/-   ##
========================================
  Coverage   66.92%   66.92%            
========================================
  Files        2600     2599     -1     
  Lines      208713   208862   +149     
  Branches     9339     9309    -30     
========================================
+ Hits       139671   139788   +117     
- Misses      56329    56351    +22     
- Partials    12713    12723    +10     
Flag Coverage Δ
backend 68.70% <72.48%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai

coderabbitai Bot commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Added support for Fleet variables in Apple DDM declarations and propagated changes across service, datastore, mock, and test layers. Datastore APIs for creating/updating declarations now accept a usesFleetVars list; declaration and profile variable associations plus a variables_updated_at timestamp are persisted and incorporated into host-token generation and resend logic. Service-side validation enforces an allowlist and premium license for DDM variables, expands variables per-host when serving declarations (marking failed expansions), and records variable-driven updates. Multiple unit, integration, and gitops tests and test mocks were added or updated.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: adding support for Fleet variables in DDM declarations.
Linked Issues check ✅ Passed Code changes comprehensively implement all primary objectives from #43047: enabling Fleet variable uploads in DDM, restricting to allowlisted variables, supporting host-side variable replacement with JSON escaping, updating token generation with variables_updated_at, triggering resends on variable changes, and maintaining backwards compatibility.
Out of Scope Changes check ✅ Passed All code modifications are scoped to the stated objectives of supporting Fleet variables in DDM: datastore method signatures updated, DDM endpoint variable substitution logic added, token generation modified, and resend logic updated appropriately.
Description check ✅ Passed The PR description includes a changes file entry, covers testing (automated tests added/updated, manual QA completed), and addresses all required security/validation concerns.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mna-43047-ddm-vars-logic

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/service/mdm.go (1)

2164-2174: ⚠️ Potential issue | 🟠 Major

Prevent silent variable-association key collisions.

At Line 2164 the TODO is valid: profileVarsByProfIdentifier mixes Apple keys (Identifier) and Windows keys (Name) in one map. At Line 2320 (and earlier Line 2306), same-string collisions overwrite entries silently, which can persist wrong Fleet-variable associations.

💡 Proposed safeguard (fail fast on collisions)
 func validateFleetVariables(ctx context.Context, ds fleet.Datastore, appConfig *fleet.AppConfig, lic *fleet.LicenseInfo, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
 	windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration,
 ) (map[string][]string, error) {
@@
 	profileVarsByProfIdentifier := make(map[string][]string)
+	upsertVars := func(key string, vars []string) error {
+		if len(vars) == 0 {
+			return nil
+		}
+		if _, exists := profileVarsByProfIdentifier[key]; exists {
+			return fleet.NewInvalidArgumentError(
+				"profile",
+				fmt.Sprintf("Couldn't set profile. Duplicate Fleet variable key collision for %q across profile types.", key),
+			)
+		}
+		profileVarsByProfIdentifier[key] = vars
+		return nil
+	}
 	for _, p := range appleProfiles {
 		profileVars, err := validateConfigProfileFleetVariables(string(p.Mobileconfig), lic, groupedCAs)
 		if err != nil {
 			return nil, ctxerr.Wrap(ctx, err, "validating config profile Fleet variables")
 		}
-		profileVarsByProfIdentifier[p.Identifier] = profileVars
+		if err := upsertVars(p.Identifier, profileVars); err != nil {
+			return nil, ctxerr.Wrap(ctx, err, "validating config profile Fleet variables")
+		}
 	}
@@
-		if len(windowsVars) > 0 {
-			profileVarsByProfIdentifier[p.Name] = windowsVars
-		}
+		if err := upsertVars(p.Name, windowsVars); err != nil {
+			return nil, ctxerr.Wrap(ctx, err, "validating Windows profile Fleet variables")
+		}
 	}
@@
-		if len(declVars) > 0 {
-			profileVarsByProfIdentifier[p.Identifier] = declVars
-		}
+		if err := upsertVars(p.Identifier, declVars); err != nil {
+			return nil, ctxerr.Wrap(ctx, err, "validating declaration Fleet variables")
+		}
 	}
 	return profileVarsByProfIdentifier, nil
 }

Also applies to: 2310-2321

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/mdm.go` around lines 2164 - 2174, The code silently overwrites
variable associations because Apple profile Identifier and Windows declaration
Name share the same map key; update the map building and consumption to detect
and fail-fast on collisions by including the source/platform in the key or by
storing source metadata and returning an error when the same key is seen from a
different source. Specifically, when populating profilesVariablesByIdentifierMap
(and the earlier profileVarsByProfIdentifier usage), change the logic to either
prefix keys with the platform (e.g., "apple:"+identifier vs "windows:"+name) or
keep a struct value that records {key, sourcePlatform} and check for existing
entries with a different platform, returning an error; ensure the downstream
conversion that builds profilesVariablesByIdentifier (the loop using
profilesVariablesByIdentifierMap and the conversion to
fleet.MDMProfileIdentifierFleetVariables) uses the updated keys/values and
surfaces the collision error instead of silently overwriting.
🧹 Nitpick comments (5)
cmd/fleetctl/fleetctl/gitops_test.go (1)

5869-5871: Assert usesFleetVars stays empty in these OS-update tests.

These cases cover standard Apple/Windows OS update declarations. As written, they would still pass if those declarations started being tagged as Fleet-variable-backed, which weakens the backwards-compatibility coverage this PR is supposed to keep.

♻️ Suggested assertion
 ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) {
+	require.Empty(t, usesFleetVars)
 	return &fleet.MDMAppleDeclaration{DeclarationUUID: "test-uuid"}, nil
 }

Also applies to: 6154-6156

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/fleetctl/fleetctl/gitops_test.go` around lines 5869 - 5871, The mock
setter SetOrUpdateMDMAppleDeclarationFunc currently ignores the usesFleetVars
argument; update that mock (and the analogous
SetOrUpdateMDMWindowsDeclarationFunc used later) to assert usesFleetVars is
empty by returning an error or failing the test if len(usesFleetVars) != 0,
e.g., check the usesFleetVars slice at the start of
SetOrUpdateMDMAppleDeclarationFunc and fail the test when it's non-empty so the
OS-update tests ensure no Fleet-variable-backed flags are introduced.
cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go (1)

226-264: Add a no-variable control case.

This only proves the free-tier apply fails; it doesn't prove the failure is caused by Fleet-variable usage specifically. A sibling case using the same DDM declaration with a literal value would make this test much more precise and avoid false positives from unrelated DDM rejection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go` around lines
226 - 264, The test only verifies that applying a DDM declaration fails on
free-tier but doesn't prove Fleet-variable usage is the cause; add a sibling
case in TestFleetGitopsDDMFleetVarsRequiresPremium that writes or uses a second
decl file (e.g., create declLiteralFile similar to declFile but with
Payload.Value set to a literal string instead of
"$FLEET_VAR_HOST_HARDWARE_SERIAL") and call fleetctl.RunAppNoChecks with that
literal declaration (using the same fleetctlConfig and globalFile setup) and
assert it does not fail with "missing or invalid license" (or assert no error),
keeping the original Fleet-variable run asserting the license error; reference
declFile, declLiteralFile, TestFleetGitopsDDMFleetVarsRequiresPremium and
fleetctl.RunAppNoChecks to locate where to add the new case.
server/service/apple_mdm_test.go (1)

6348-6350: makeDecl currently stringifies JSON array inputs, reducing test fidelity.

Line 6349 uses %q, so values like ["$FLEET_VAR_..."] become a single JSON string, not an array. That makes the “multiple supported variables” and “all supported variables” cases less representative.

♻️ Suggested test helper adjustment
-	makeDecl := func(value string) string {
-		return fmt.Sprintf(`{"Type": "com.apple.configuration.management.test", "Identifier": "com.example.test", "Payload": {"Value": %q}}`, value)
-	}
+	makeDeclString := func(value string) string {
+		return fmt.Sprintf(`{"Type":"com.apple.configuration.management.test","Identifier":"com.example.test","Payload":{"Value":%q}}`, value)
+	}
+	makeDeclRaw := func(rawValue string) string {
+		return fmt.Sprintf(`{"Type":"com.apple.configuration.management.test","Identifier":"com.example.test","Payload":{"Value":%s}}`, rawValue)
+	}
@@
-		vars, err := validateDeclarationFleetVariables(
-			makeDecl(`["$FLEET_VAR_HOST_HARDWARE_SERIAL", "$FLEET_VAR_HOST_END_USER_IDP_USERNAME"]`), premiumLic)
+		vars, err := validateDeclarationFleetVariables(
+			makeDeclRaw(`["$FLEET_VAR_HOST_HARDWARE_SERIAL", "$FLEET_VAR_HOST_END_USER_IDP_USERNAME"]`), premiumLic)
@@
-		vars, err := validateDeclarationFleetVariables(
-			makeDecl("["+strings.Join(jsonVars, ", ")+"]"), premiumLic)
+		vars, err := validateDeclarationFleetVariables(
+			makeDeclRaw("["+strings.Join(jsonVars, ", ")+"]"), premiumLic)

Also applies to: 6371-6373, 6384-6386

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/apple_mdm_test.go` around lines 6348 - 6350, The test helper
makeDecl is currently using fmt.Sprintf with %q which JSON-escapes arrays (e.g.
["$FLEET_VAR_..."]) into a single string, breaking tests that expect actual JSON
arrays; update makeDecl to insert the raw value without quoting (use %s) or
better, build the payload via encoding/json (marshal a struct or map) so that
inputs like ["$FLEET_VAR_..."] remain arrays; update the other occurrences noted
(the similar helpers at the other locations) to use the same approach so the
“multiple supported variables” and “all supported variables” cases get real JSON
arrays rather than stringified arrays.
cmd/fleetctl/fleetctl/apply_test.go (1)

758-761: Consider asserting usesFleetVars in at least one path.

Line 758 correctly includes the new parameter; adding a targeted assertion here would strengthen regression coverage for variable propagation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/fleetctl/fleetctl/apply_test.go` around lines 758 - 761, Add an assertion
that the new parameter usesFleetVars is propagated correctly inside the mock
implementation assigned to SetOrUpdateMDMAppleDeclarationFunc: in the test path
where you expect certain FleetVarName values, check usesFleetVars contains (or
equals) the expected slice before returning the declaration (e.g., via a test
helper or t.Fatalf/t.Helper), so the mock validates variable propagation for
SetOrUpdateMDMAppleDeclarationFunc.
server/service/integration_mdm_ddm_test.go (1)

1744-1746: Consider adding assertion for mdmDevice3 after team declaration change.

After uploading the new IdP username declaration to the team (Line 1730-1731), the test verifies mdmDevice1 (team member) gets DDM sync and mdmDevice2 (global) doesn't. However, mdmDevice3 is also on the same team and should receive the new declaration—yet there's no assertion for it here.

Either add checkDDMSync(mdmDevice3) if host3 should also receive the command, or checkNoCommands(mdmDevice3) with a comment explaining why it wouldn't.

💡 Suggested addition
 	// Host1 gets DDM sync (declaration set changed)
 	checkDDMSync(mdmDevice1)
 	checkNoCommands(mdmDevice2)
+	checkDDMSync(mdmDevice3) // host3 is also on the team and should receive the new declaration
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/integration_mdm_ddm_test.go` around lines 1744 - 1746, The
test updated the team declaration but only asserts on mdmDevice1 and mdmDevice2;
add an assertion for mdmDevice3 to reflect expected behavior after the team IdP
username declaration change: either call checkDDMSync(mdmDevice3) if host3 is on
the same team and should receive the DDM sync, or call
checkNoCommands(mdmDevice3) with a brief comment explaining why it should not
receive commands; place this new assertion alongside the existing
checkDDMSync(mdmDevice1)/checkNoCommands(mdmDevice2) lines to keep the intent
clear.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/datastore/mysql/apple_mdm.go`:
- Around line 5673-5675: The MDMAppleDDMDeclarationsToken upsert currently
preserves a stale hmad.variables_updated_at value when a declaration stops using
Fleet variables; update the upsert that writes hmad.variables_updated_at so it
explicitly clears that column (set to NULL) when the declaration no longer uses
Fleet variables (use a CASE/conditional or COALESCE with a check like "WHEN not
using variables THEN NULL ELSE <timestamp> END"), and keep the token-hash
expression (MD5(CONCAT(... GROUP_CONCAT(CONCAT(HEX(mad.token),
IFNULL(hmad.variables_updated_at, '')) ...)))) as-is so NULL becomes an empty
string for hashing; apply the same fix to the analogous upsert block referenced
in the review (lines around the second occurrence 5847-5852).

In `@server/service/apple_mdm.go`:
- Around line 1146-1149: The handler markDeclarationFailed should not rotate the
variables_updated_at timestamp when a substitution/fetch-time failure occurs;
instead of creating varsUpdatedAt = time.Now().UTC() and passing its pointer to
SetHostMDMAppleDeclarationStatus, pass nil for the varsUpdatedAt parameter so
the existing timestamp is preserved; update the call sites in
markDeclarationFailed (and any similar failure paths) to call
svc.ds.SetHostMDMAppleDeclarationStatus(ctx, hostUUID, d.DeclarationUUID,
&status, detail, nil) while keeping status and detail updates unchanged.
- Around line 917-920: The current code always allocates an empty slice for
varNames from declVars which breaks the repository convention of using nil to
indicate "no Fleet variables referenced"; update the varNames construction near
declVars so that varNames remains nil when len(declVars)==0 and only initialize
(make and append) when len(declVars)>0, and apply the same nil-vs-empty-slice
change for the other occurrence referenced in the comment (the call to
batchSetProfileVariableAssociationsDB / usesFleetVars). Ensure you keep the same
element conversion fleet.FleetVarName(v) when populating the slice.

---

Outside diff comments:
In `@server/service/mdm.go`:
- Around line 2164-2174: The code silently overwrites variable associations
because Apple profile Identifier and Windows declaration Name share the same map
key; update the map building and consumption to detect and fail-fast on
collisions by including the source/platform in the key or by storing source
metadata and returning an error when the same key is seen from a different
source. Specifically, when populating profilesVariablesByIdentifierMap (and the
earlier profileVarsByProfIdentifier usage), change the logic to either prefix
keys with the platform (e.g., "apple:"+identifier vs "windows:"+name) or keep a
struct value that records {key, sourcePlatform} and check for existing entries
with a different platform, returning an error; ensure the downstream conversion
that builds profilesVariablesByIdentifier (the loop using
profilesVariablesByIdentifierMap and the conversion to
fleet.MDMProfileIdentifierFleetVariables) uses the updated keys/values and
surfaces the collision error instead of silently overwriting.

---

Nitpick comments:
In `@cmd/fleetctl/fleetctl/apply_test.go`:
- Around line 758-761: Add an assertion that the new parameter usesFleetVars is
propagated correctly inside the mock implementation assigned to
SetOrUpdateMDMAppleDeclarationFunc: in the test path where you expect certain
FleetVarName values, check usesFleetVars contains (or equals) the expected slice
before returning the declaration (e.g., via a test helper or t.Fatalf/t.Helper),
so the mock validates variable propagation for
SetOrUpdateMDMAppleDeclarationFunc.

In `@cmd/fleetctl/fleetctl/gitops_test.go`:
- Around line 5869-5871: The mock setter SetOrUpdateMDMAppleDeclarationFunc
currently ignores the usesFleetVars argument; update that mock (and the
analogous SetOrUpdateMDMWindowsDeclarationFunc used later) to assert
usesFleetVars is empty by returning an error or failing the test if
len(usesFleetVars) != 0, e.g., check the usesFleetVars slice at the start of
SetOrUpdateMDMAppleDeclarationFunc and fail the test when it's non-empty so the
OS-update tests ensure no Fleet-variable-backed flags are introduced.

In `@cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go`:
- Around line 226-264: The test only verifies that applying a DDM declaration
fails on free-tier but doesn't prove Fleet-variable usage is the cause; add a
sibling case in TestFleetGitopsDDMFleetVarsRequiresPremium that writes or uses a
second decl file (e.g., create declLiteralFile similar to declFile but with
Payload.Value set to a literal string instead of
"$FLEET_VAR_HOST_HARDWARE_SERIAL") and call fleetctl.RunAppNoChecks with that
literal declaration (using the same fleetctlConfig and globalFile setup) and
assert it does not fail with "missing or invalid license" (or assert no error),
keeping the original Fleet-variable run asserting the license error; reference
declFile, declLiteralFile, TestFleetGitopsDDMFleetVarsRequiresPremium and
fleetctl.RunAppNoChecks to locate where to add the new case.

In `@server/service/apple_mdm_test.go`:
- Around line 6348-6350: The test helper makeDecl is currently using fmt.Sprintf
with %q which JSON-escapes arrays (e.g. ["$FLEET_VAR_..."]) into a single
string, breaking tests that expect actual JSON arrays; update makeDecl to insert
the raw value without quoting (use %s) or better, build the payload via
encoding/json (marshal a struct or map) so that inputs like ["$FLEET_VAR_..."]
remain arrays; update the other occurrences noted (the similar helpers at the
other locations) to use the same approach so the “multiple supported variables”
and “all supported variables” cases get real JSON arrays rather than stringified
arrays.

In `@server/service/integration_mdm_ddm_test.go`:
- Around line 1744-1746: The test updated the team declaration but only asserts
on mdmDevice1 and mdmDevice2; add an assertion for mdmDevice3 to reflect
expected behavior after the team IdP username declaration change: either call
checkDDMSync(mdmDevice3) if host3 is on the same team and should receive the DDM
sync, or call checkNoCommands(mdmDevice3) with a brief comment explaining why it
should not receive commands; place this new assertion alongside the existing
checkDDMSync(mdmDevice1)/checkNoCommands(mdmDevice2) lines to keep the intent
clear.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cc6c11fe-1212-4b26-8404-a1b733896c6d

📥 Commits

Reviewing files that changed from the base of the PR and between 15ff5fb and aab833f.

📒 Files selected for processing (32)
  • changes/43222-support-fleet-variables-in-ddm
  • cmd/fleetctl/fleetctl/apply_deprecated_test.go
  • cmd/fleetctl/fleetctl/apply_test.go
  • cmd/fleetctl/fleetctl/get_test.go
  • cmd/fleetctl/fleetctl/gitops_test.go
  • cmd/fleetctl/fleetctl/testing_utils.go
  • cmd/fleetctl/fleetctl/testing_utils/testing_utils.go
  • cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go
  • cmd/fleetctl/integrationtest/gitops/gitops_integration_test.go
  • ee/server/service/mdm.go
  • ee/server/service/mdm_external_test.go
  • server/datastore/mysql/apple_mdm.go
  • server/datastore/mysql/apple_mdm_ddm_test.go
  • server/datastore/mysql/apple_mdm_test.go
  • server/datastore/mysql/hosts_test.go
  • server/datastore/mysql/mdm.go
  • server/datastore/mysql/microsoft_mdm.go
  • server/datastore/mysql/microsoft_mdm_test.go
  • server/datastore/mysql/scim.go
  • server/datastore/mysql/secret_variables_test.go
  • server/datastore/mysql/teams_test.go
  • server/fleet/apple_mdm.go
  • server/fleet/apple_mdm_test.go
  • server/fleet/datastore.go
  • server/fleet/mdm.go
  • server/mock/datastore_mock.go
  • server/service/apple_mdm.go
  • server/service/apple_mdm_ddm_test.go
  • server/service/apple_mdm_test.go
  • server/service/integration_core_test.go
  • server/service/integration_mdm_ddm_test.go
  • server/service/mdm.go

Comment thread server/datastore/mysql/apple_mdm.go
Comment thread server/service/apple_mdm.go
Comment thread server/service/apple_mdm.go Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 server/service/apple_mdm.go:6203-6213 — When host variable values change (e.g. via SCIM sync), the overall DeclarationsToken correctly incorporates variables_updated_at, causing the device to re-fetch declaration-items. However, each declaration's individual ServerToken in that manifest is always HEX(mad.token)—the static content hash—which never changes when only variable values change. Per Apple's DDM protocol, a device re-downloads a declaration only when its per-declaration ServerToken differs from its cached value; since the token is unchanged, the device uses its cached content and the updated variable values are never delivered to the device.

    Extended reasoning...

    The bug in brief

    Apple DDM uses a two-level token comparison. First the device compares the overall DeclarationsToken from the /declaration-items endpoint; if changed, it fetches the manifest. Then, for every declaration listed in the manifest, the device compares the per-declaration ServerToken with its locally cached value. Only declarations whose ServerToken has changed are re-downloaded. If the per-declaration ServerToken is unchanged, the device silently reuses its cached copy—no new network request is made.

    The specific code path

    In handleDeclarationItems (server/service/apple_mdm.go ~6203-6213), Fleet builds the overall DeclarationsToken by concatenating HEX(mad.token) + variables_updated_at for each declaration and MD5-hashing the result. This means the overall token correctly changes whenever variable values are recomputed. However, when the function builds the configurations/activations slice returned to the device, each entry is populated as d.ServerToken = HEX(mad.token)—the static content hash only—without incorporating variablesUpdatedAt. The struct comment even documents this limitation: "ServerToken field is just for the static token of the DDM."

    Why existing code doesn't prevent it

    The overall DeclarationsToken change will successfully wake the device and cause it to re-fetch the /declaration-items manifest. At that point the device sees the same per-declaration ServerToken as before (because the content hash HEX(mad.token) has not changed—only the Fleet variable values changed). The device therefore concludes it already has the latest version of every declaration and downloads nothing new. The device never requests the /declaration/configuration/ endpoint, so Fleet's correctly-rendered variable substitution is never served.

    Impact

    Any Fleet variable that can change after initial enrollment—HOST_END_USER_IDP_USERNAME, HOST_END_USER_IDP_USERNAME_LOCAL_PART, HOST_END_USER_IDP_GROUPS, HOST_END_USER_IDP_DEPARTMENT, HOST_END_USER_IDP_FULLNAME—will silently deliver stale values to the device even after triggerResendProfilesUsingVariables resets the declaration status and setVariablesUpdatedAtForDeclarations records a new timestamp. The feature appears to work (DDM sync is triggered, Fleet marks the declaration as installed) but the device configuration is wrong.

    Why the test misses this

    TestAppleDDMFleetVariables in server/service/integration_mdm_ddm_test.go directly calls s.Do("GET", "/mdm/apple/declarative/.../declaration/configuration/...", ...) to verify variable substitution. This bypasses the device-side two-level token comparison entirely—the test never exercises the path where a device decides whether to re-download a declaration. A correct end-to-end test would need to simulate the device's token-comparison logic: (1) GET declaration-items, (2) compare per-declaration ServerTokens against a cached set, (3) only fetch declarations with changed tokens.

    How to fix

    The per-declaration ServerToken written into the manifest should be derived from both the content hash and the variables_updated_at timestamp, for example: d.ServerToken = HEX(md5(HEX(mad.token) + IFNULL(variables_updated_at, ''))). This mirrors exactly what is already done for the overall DeclarationsToken. A simpler alternative is to concatenate them as a string (e.g. HEX(mad.token)+"_"+variables_updated_at.Unix()) before returning the declaration item.

Comment thread server/service/mdm.go
Comment thread server/service/apple_mdm.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/service/integration_mdm_ddm_test.go`:
- Around line 1446-1450: The test currently maps declarations by declaration
token (declsByToken) but the behavior under test depends on per-host ServerToken
changes driven by host_mdm_apple_declarations.variables_updated_at; update the
assertions to capture and compare the host-specific ServerToken values rather
than the declaration/token-level values: locate where declsByToken is built and
where dbDeclUUID, dbDeclSerial, dbDeclPlain are referenced and instead fetch the
ServerToken for the specific host (e.g., via the host's ServerToken field or by
querying the host_mdm_apple_declarations entry), then assert that the
ServerToken for the variable-backed declarations (dbDeclUUID, dbDeclSerial)
changes after variable resend while the ServerToken (or Plain.json) for
dbDeclPlain remains stable; apply the same change to the other occurrences
around the blocks originally at lines 1466-1470 and 1638-1642.
- Around line 1607-1616: The test currently bypasses the resend-selection logic
by directly setting host_mdm_apple_declarations.status = NULL via
mysql.ExecAdhocSQL; instead invoke the actual resend-selection code so the
junction/query path is exercised—replace the direct UPDATE with a call to the
function or SQL used by the code under test (e.g.,
triggerResendProfilesUsingVariables(ctx, ...) or the same SELECT/INSERT/UPDATE
query that marks declarations for resend) so that the logic which decides which
declarations to mark pending runs (and keep references to
host_mdm_apple_declarations.status, dbDeclUUID.DeclarationUUID,
dbDeclSerial.DeclarationUUID to locate the same rows).
- Around line 1396-1411: The tests currently only cover declWithUUID,
declWithSerial and declPlain which are JSON-safe; add a new test case (e.g.,
declWithEscaped or declWithSpecialChars) that injects a Fleet variable
containing characters that require JSON string escaping (quotes, backslashes,
newlines) and assert the substituted declaration embeds the escaped string (not
the raw unescaped value). Locate the declaration payloads near
declWithUUID/declWithSerial/declPlain and add the new payload and corresponding
assertions where other declarations are validated (also add the similar escaping
case noted for lines 1472-1490) so the code path that performs variable
substitution is verified to produce JSON-string-escaped output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: de34b3f2-a069-497e-bab8-f39f4dbd0b2c

📥 Commits

Reviewing files that changed from the base of the PR and between 32e31af and ce3e8d4.

📒 Files selected for processing (1)
  • server/service/integration_mdm_ddm_test.go

Comment thread server/service/integration_mdm_ddm_test.go
Comment thread server/service/integration_mdm_ddm_test.go Outdated
Comment thread server/service/integration_mdm_ddm_test.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
server/service/integration_mdm_ddm_test.go (3)

1396-1411: ⚠️ Potential issue | 🟠 Major

Add one escaping-sensitive substitution case.

These fixtures only exercise UUID/serial substitutions, which are already JSON-safe. A regression in JSON string-escaping for quotes, backslashes, or newlines would still pass this test, even though that escaping is part of the feature contract.

Also applies to: 1460-1479, 1704-1716

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/integration_mdm_ddm_test.go` around lines 1396 - 1411, The
test only covers JSON-safe substitutions (declWithUUID, declWithSerial,
declPlain); add an escaping-sensitive substitution case that exercises JSON
string-escaping (e.g., a declaration payload that references a variable
containing quotes, backslashes or newlines) so the code path that emits escaped
JSON strings is validated; update the fixtures near
declWithUUID/declWithSerial/declPlain (and the similar blocks at the other
mentioned locations) to include a declaration using the escaping-sensitive
variable substitution and assert the resulting served payload contains properly
escaped sequences.

1608-1617: ⚠️ Potential issue | 🟠 Major

This still bypasses the resend-selection path under test.

Setting host_mdm_apple_declarations.status = NULL only checks reconciliation after rows are already pending. It never exercises the logic that decides which declarations should be resent when variables change, so the new junction/query path can regress without failing here.

Also applies to: 1679-1687

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/integration_mdm_ddm_test.go` around lines 1608 - 1617, The
test is bypassing the resend-selection logic by directly setting
host_mdm_apple_declarations.status = NULL; instead, simulate a variable value
change or invoke the real selection path: call
triggerResendProfilesUsingVariables (or the public function that handles
variable-change selection) or perform the same update to the variables table
that triggerResendProfilesUsingVariables watches so the code path that decides
which declarations to resend is exercised; update the test at the block
referencing host_mdm_apple_declarations (and the similar block at 1679-1687) to
trigger the selection logic instead of directly nulling status.

1495-1499: ⚠️ Potential issue | 🟠 Major

Assert per-host ServerToken movement, not just the collection token.

The assertions here prove DeclarationsToken changed, but they still don’t verify that com.fleet.var.uuid and com.fleet.var.serial got new host-specific ServerTokens while com.fleet.plain stayed stable. A regression in EffectiveDDMToken or declaration-items could slip through.

Suggested assertion shape
+	r, err = mdmDevice1.DeclarativeManagement("declaration-items")
+	require.NoError(t, err)
+	beforeItems := parseDeclarationItemsResp(t, r)
+	beforeTokensByID := map[string]string{}
+	for _, d := range beforeItems.Declarations.Configurations {
+		beforeTokensByID[d.Identifier] = d.ServerToken
+	}
+
 	// 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)
@@
 	r, err = mdmDevice1.DeclarativeManagement("tokens")
 	require.NoError(t, err)
 	tokens = parseTokensResp(t, r)
 	assert.NotEqual(t, lastSyncDeclToken, tokens.SyncTokens.DeclarationsToken)
+
+	r, err = mdmDevice1.DeclarativeManagement("declaration-items")
+	require.NoError(t, err)
+	afterItems := parseDeclarationItemsResp(t, r)
+	afterTokensByID := map[string]string{}
+	for _, d := range afterItems.Declarations.Configurations {
+		afterTokensByID[d.Identifier] = d.ServerToken
+	}
+	assert.NotEqual(t, beforeTokensByID["com.fleet.var.uuid"], afterTokensByID["com.fleet.var.uuid"])
+	assert.NotEqual(t, beforeTokensByID["com.fleet.var.serial"], afterTokensByID["com.fleet.var.serial"])
+	assert.Equal(t, beforeTokensByID["com.fleet.plain"], afterTokensByID["com.fleet.plain"])

Also applies to: 1625-1643

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/integration_mdm_ddm_test.go` around lines 1495 - 1499, The
test currently only checks that the collection-level DeclarationsToken changed;
update the assertions after calling mdmDevice1.DeclarativeManagement (and mirror
the same change for the similar block around lines 1625-1643) to verify per-host
ServerToken movement: parse the itemsResp (from parseDeclarationItemsResp) and
assert that the ServerToken for the declarations with keys "com.fleet.var.uuid"
and "com.fleet.var.serial" increased/changed compared to the values stored in
declsByToken for that host, while the ServerToken for "com.fleet.plain" remains
equal to its previous value; use the existing lastSyncDeclToken/declsByToken
fixtures to compare prior vs current host-specific ServerToken values rather
than only checking the top-level DeclarationsToken.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/service/apple_mdm.go`:
- Around line 6303-6317: The JSON unmarshal failure after
replaceDeclarationFleetVariables isn't being handled as a host-level declaration
failure, so parse errors return an error to the device instead of marking the
declaration failed for that host; update the error path after json.Unmarshal (in
the same block that handles replaceDeclarationFleetVariables) to call
svc.markDeclarationFailed(ctx, hostUUID, d, err.Error()) and return nil, nil if
markDeclarationFailed succeeds (wrap and return the markDeclarationFailed error
if it fails), mirroring the existing handling for
replaceDeclarationFleetVariables; reference replaceDeclarationFleetVariables,
json.Unmarshal, and svc.markDeclarationFailed to locate the code to change.

In `@server/service/integration_mdm_ddm_test.go`:
- Around line 1795-1799: The test only asserts variables_updated_at is non-nil
for VarUUID but does not confirm VarSerial's timestamp remained unchanged;
update the test to record the pre-update variables_updated_at for the VarSerial
declaration (use getHostDeclVarsUpdatedAt for VarSerial.json for host1 and
host3), perform the VarUUID update, then assert the post-update
variables_updated_at for VarSerial equals the previously recorded timestamps
(i.e., unchanged). Apply the same change to the analogous assertions around the
VarSerial/VarUUID check at the later block (the 1845-1848 area).

---

Duplicate comments:
In `@server/service/integration_mdm_ddm_test.go`:
- Around line 1396-1411: The test only covers JSON-safe substitutions
(declWithUUID, declWithSerial, declPlain); add an escaping-sensitive
substitution case that exercises JSON string-escaping (e.g., a declaration
payload that references a variable containing quotes, backslashes or newlines)
so the code path that emits escaped JSON strings is validated; update the
fixtures near declWithUUID/declWithSerial/declPlain (and the similar blocks at
the other mentioned locations) to include a declaration using the
escaping-sensitive variable substitution and assert the resulting served payload
contains properly escaped sequences.
- Around line 1608-1617: The test is bypassing the resend-selection logic by
directly setting host_mdm_apple_declarations.status = NULL; instead, simulate a
variable value change or invoke the real selection path: call
triggerResendProfilesUsingVariables (or the public function that handles
variable-change selection) or perform the same update to the variables table
that triggerResendProfilesUsingVariables watches so the code path that decides
which declarations to resend is exercised; update the test at the block
referencing host_mdm_apple_declarations (and the similar block at 1679-1687) to
trigger the selection logic instead of directly nulling status.
- Around line 1495-1499: The test currently only checks that the
collection-level DeclarationsToken changed; update the assertions after calling
mdmDevice1.DeclarativeManagement (and mirror the same change for the similar
block around lines 1625-1643) to verify per-host ServerToken movement: parse the
itemsResp (from parseDeclarationItemsResp) and assert that the ServerToken for
the declarations with keys "com.fleet.var.uuid" and "com.fleet.var.serial"
increased/changed compared to the values stored in declsByToken for that host,
while the ServerToken for "com.fleet.plain" remains equal to its previous value;
use the existing lastSyncDeclToken/declsByToken fixtures to compare prior vs
current host-specific ServerToken values rather than only checking the top-level
DeclarationsToken.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fea1d28d-1545-42de-8c82-a4de7e28f69e

📥 Commits

Reviewing files that changed from the base of the PR and between ce3e8d4 and ce2c6c8.

📒 Files selected for processing (4)
  • server/datastore/mysql/apple_mdm.go
  • server/fleet/apple_mdm.go
  • server/service/apple_mdm.go
  • server/service/integration_mdm_ddm_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/datastore/mysql/apple_mdm.go

Comment thread server/service/apple_mdm.go
Comment thread server/service/integration_mdm_ddm_test.go Outdated
@mna

mna commented Apr 14, 2026

Copy link
Copy Markdown
Contributor Author

Note that there's one bug found in QA that I will fix in a follow-up PR : we can't return an empty payload in the /declaration/configuration/ endpoint, the host doesn't like it and it replies with a failure that overrides the failed error that we want to surface (E.g. host has no IdP user). Instead, this check should be done in the activation phase, and not send the DDM profile to activate it it can't be delivered.

It's already a big PR with most of it working properly, so I'll address that in a follow-up.

@JordanMontgomery JordanMontgomery left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good. The one thing that might be worth adding(perhaps in the followup PR already opened) could be a specific, explicit test that EffectiveDDMToken matches the mysql representation for a given value, though the existing tests implicitly check that.

@mna
mna merged commit 2a88038 into main Apr 20, 2026
42 of 44 checks passed
@mna
mna deleted the mna-43047-ddm-vars-logic branch April 20, 2026 13:14
mna added a commit that referenced this pull request Apr 20, 2026
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #43047 

Follow-up to #43222

# Checklist for submitter

- [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] QA'd all new/changed functionality manually
See
#42960 (comment)


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

* **Bug Fixes**
* Improved Apple MDM declaration handling: declarations with unresolved
per-device variables are now attempted per host, marked failed when
resolution fails, and omitted from device configuration/activation
manifests.
* Declarations that fail resolution still factor into declaration token
computation to keep token behavior consistent.

* **Tests**
* Updated tests to reflect per-device resolution failures and adjusted
validation flow.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DDM Vars: DDM logic alterations for variables

4 participants