Skip to content

Add sticky MDM enrollment Redis key - #33935

Merged
MagnusHJensen merged 5 commits into
mainfrom
26879-add-sticky-enrollment-redis-key
Oct 9, 2025
Merged

Add sticky MDM enrollment Redis key#33935
MagnusHJensen merged 5 commits into
mainfrom
26879-add-sticky-enrollment-redis-key

Conversation

@MagnusHJensen

@MagnusHJensen MagnusHJensen commented Oct 7, 2025

Copy link
Copy Markdown
Member

Related issue: Resolves #26879

We decided to opt for a sticky enrollment approach, and I opted for using redis, so this PR also adds a redis key value store to the free service to use.

Checklist for submitter

If some of the following don't apply, delete the relevant line.

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

Testing

  • Added/updated automated tests

  • QA'd all new/changed functionality manually

Summary by CodeRabbit

  • Bug Fixes
    • Prevents Orbit enrollment from undoing team transfers triggered during MDM enrollment, preserving the correct team assignment on re-enrollment.
    • Introduces a temporary “sticky” enrollment period (~30 minutes) during Apple MDM check-in and Orbit enrollment to reduce unintended team changes.
    • Improves reliability of team-scoped enroll secrets and host transfers in short re-enrollment windows.

@MagnusHJensen
MagnusHJensen requested a review from a team as a code owner October 7, 2025 14:37
@MagnusHJensen MagnusHJensen changed the title add sticky mdm enrollment redis key Add sticky MDM enrolment Redis key Oct 7, 2025
@MagnusHJensen MagnusHJensen changed the title Add sticky MDM enrolment Redis key Add sticky MDM enrollment Redis key Oct 7, 2025
@codecov

codecov Bot commented Oct 7, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.23404% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.20%. Comparing base (a893aa0) to head (9a02754).
⚠️ Report is 50 commits behind head on main.

Files with missing lines Patch % Lines
server/service/orbit.go 58.33% 4 Missing and 1 partial ⚠️
server/service/osquery.go 58.33% 4 Missing and 1 partial ⚠️
cmd/fleet/serve.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #33935      +/-   ##
==========================================
+ Coverage   64.14%   64.20%   +0.05%     
==========================================
  Files        2052     2054       +2     
  Lines      206252   206694     +442     
  Branches     6885     6885              
==========================================
+ Hits       132297   132701     +404     
- Misses      63558    63574      +16     
- Partials    10397    10419      +22     
Flag Coverage Δ
backend 65.33% <87.23%> (+0.06%) ⬆️

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.

@MagnusHJensen

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Oct 7, 2025

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

Comment thread server/fleet/mdm.go
RefetchMDMUnenrollCriticalQueryDuration = 3 * time.Minute

StickyMDMEnrollmentKeyPrefix = "sticky_mdm_enrollment_" // + host UUID
StickyMDMEnrollmentTTL = 30 * time.Minute

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think 30 minutes is a fair small value, that way we ensure it's only within a short, reasonable timeframe we make the mdm team enrollment sticky, and avoid it causing weird behaviours if a host was to re-enroll orbit into a new team long enough after, where it isn't a mistake.

@coderabbitai

coderabbitai Bot commented Oct 7, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Introduces a Redis-backed key-value store to set/read a short-lived “sticky MDM enrollment” flag. Service constructors and tests are updated to inject this store. Orbit enrollment conditionally ignores team updates when the sticky flag is present. MDM Apple check-in sets the flag on enrollment. MySQL host enrollment honors an option to skip team updates.

Changes

Cohort / File(s) Summary
Service wiring and DI
server/service/service.go, cmd/fleet/serve.go, server/service/testing_utils.go, ee/server/integrationtest/hostidentity/suite.go, ee/server/service/mdm_external_test.go
Add fleet.KeyValueStore to Service; extend NewService signature; wire Redis-backed store (redis_key_value.New) in server and tests; adjust test helpers to set up Redis pool and pass store; update service/builders to include the store.
MDM Apple check-in sticky flag
server/service/apple_mdm.go, server/service/apple_mdm_test.go
Extend MDMAppleCheckinAndCommandService with keyValueStore; constructor now accepts store; upon Authenticate enrollment, set sticky key (TTL) and log on failure; tests initialize store/logger.
Orbit enrollment behavior
server/service/orbit.go, server/fleet/orbit.go
Read sticky key by HardwareUUID; pass WithEnrollOrbitIgnoreTeamUpdate(sticky!=nil) to datastore; add config field IgnoreTeamUpdate and exported option WithEnrollOrbitIgnoreTeamUpdate.
Datastore enrollment update
server/datastore/mysql/hosts.go
EnrollOrbit conditionally updates team_id depending on IgnoreTeamUpdate; adjust SQL and args accordingly; keep refetch update.
MDM constants
server/fleet/mdm.go
Add StickyMDMEnrollmentKeyPrefix and StickyMDMEnrollmentTTL (30m).
Integration tests
server/service/integration_mdm_test.go
Add Redis pool usage and KV store; new TestStickyMDMTeamEnrollment validating key lifecycle and team update behavior.
Changelog
changes/26879-add-sticky-enrollment-redis-key
Note fix: host transfer reversal by orbit enroll.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Device
  participant MDM as MDM Apple Check-in
  participant Service
  participant KV as KeyValueStore (Redis)
  participant DS as Datastore

  Note over MDM,Service: MDM enrollment check-in
  Device->>MDM: Authenticate (enroll)
  MDM->>Service: Record MDM enrolled activity
  Service->>KV: SET sticky_mdm_enrollment_<hardware_uuid> = "1" (TTL=30m)
  KV-->>Service: OK/Err (non-fatal)
  Service-->>Device: 200

  Note over Device,Service: Orbit enroll later
  Device->>Service: Orbit Enroll (hardware_uuid, enroll secret)
  Service->>KV: GET sticky_mdm_enrollment_<hardware_uuid>
  KV-->>Service: Value/null
  alt Sticky present
    Service->>DS: EnrollOrbit(..., IgnoreTeamUpdate=true)
  else No sticky
    Service->>DS: EnrollOrbit(..., IgnoreTeamUpdate=false)
  end
  DS-->>Service: Updated host
  Service-->>Device: Enroll OK
Loading
sequenceDiagram
  autonumber
  participant API as Hosts Transfer API
  participant KV as KeyValueStore (Redis)
  participant Orbit as Orbit Enroll
  participant DS as Datastore

  Note over API,KV: After transfer to Team B
  API->>KV: (No direct write in this PR)
  Note over Orbit,DS: Subsequent Orbit enroll
  Orbit->>KV: GET sticky_mdm_enrollment_<hardware_uuid>
  KV-->>Orbit: "1"
  Orbit->>DS: EnrollOrbit IgnoreTeamUpdate=true
  DS-->>Orbit: Host remains in Team B
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Out of Scope Changes Check ⚠️ Warning While the feature implementation aligns with the sticky enrollment objectives, the PR also introduces Redis-backed GCRA throttling stores in the test utilities, which are unrelated to the primary sticky MDM enrollment change and extend beyond the scoped requirements. Please separate or remove the Redis-backed throttling store changes from this PR so that the implementation remains focused on the sticky MDM enrollment feature.
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly and accurately describes the primary change—the addition of a Redis key for sticky MDM enrollment—without extraneous detail or ambiguity, making it clear to reviewers what the PR implements.
Linked Issues Check ✅ Passed The PR implements the core objectives from issue #26879 by introducing a Redis‐backed key/value store, defining a 30-minute TTL constant, setting a sticky enrollment key on MDM checkin, and using that key in orbit enrollment to ignore enrollment-based team updates during the window. Automated integration tests verify that sticky behavior is honored across enrollment flows.
Description Check ✅ Passed The description includes the linked issue reference, a brief summary of the approach, and a tailored checklist indicating that a changes file was added and automated tests and manual QA were completed. The author has removed irrelevant template sections per guidance, so the description aligns with the repository template.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 26879-add-sticky-enrollment-redis-key

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: 4

🧹 Nitpick comments (7)
server/datastore/mysql/hosts.go (1)

2254-2286: Conditional team_id update looks correct; verify both branches are tested

  • Placeholder/args alignment is sound in both branches; WHERE id = ? scopes the UPDATE properly. Good.
  • Please confirm tests cover: ignore=true/false and teamID nil/non‑nil so we don’t regress enrollment behavior.

Optionally:

  • Include team_id and reason in the skip log to aid debugging (e.g., sticky flag source/TTL).
server/fleet/orbit.go (1)

165-169: Add a short GoDoc for WithEnrollOrbitIgnoreTeamUpdate

Add a brief comment (e.g., “WithEnrollOrbitIgnoreTeamUpdate configures whether team_id is skipped during Orbit (re)enrollment updates.”) to keep exported API documented.

server/service/apple_mdm_test.go (1)

1180-1184: Good injection; consider a small helper and pool choice

  • Injecting keyValueStore and logger is correct.
  • Optional: extract a test helper to build MDMAppleCheckinAndCommandService to avoid duplication across tests.
  • Optional: NopRedis is fine here; ensure tests that validate sticky behavior use a real pool (e.g., redistest.SetupRedis) as done in integration tests.

Also applies to: 1250-1254

server/fleet/mdm.go (1)

29-31: Centralize sticky enrollment key construction

  • Multiple suffix values are used (HardwareUUID in Orbit, r.ID in Apple MDM, host.UUID in tests); wrap key construction in a helper (e.g. StickyMDMEnrollmentKey(id string)) to ensure consistency.
server/service/orbit.go (1)

181-185: Approve the fail-open error handling approach.

The code correctly reads the sticky MDM enrollment flag from Redis and logs errors without failing enrollment. This is appropriate since sticky enrollment is a protective measure rather than core functionality. However, consider adding:

  1. A comment explaining what the sticky enrollment flag is and why it's checked (e.g., "Sticky enrollment prevents race conditions where a host transferred to a team gets re-enrolled into 'No Team' due to timing issues with MDM profile delivery").
  2. Metrics/monitoring for Redis retrieval failures to detect consistent issues.

Apply this diff to add documentation:

+	// Check for sticky MDM enrollment flag. When set (e.g., after a host transfer),
+	// this prevents enrollment-based team changes for a time window to avoid race conditions
+	// with MDM profile delivery.
 	stickyEnrollment, err := svc.keyValueStore.Get(ctx, fleet.StickyMDMEnrollmentKeyPrefix+hostInfo.HardwareUUID)
 	if err != nil {
-		// We do not want to fail here, just log the error to notify
+		// Log error but continue enrollment (fail-open approach). If Redis is unavailable,
+		// enrollment proceeds without sticky behavior rather than blocking.
 		level.Error(svc.logger).Log("msg", "failed to get sticky enrollment", "err", err, "host_uuid", hostInfo.HardwareUUID)
 	}
server/service/apple_mdm.go (1)

3578-3584: Harden sticky-key set: add timeout and nil-guard

Avoid blocking the check-in path and guard against a nil store. Suggested change:

-        // Set sticky key for MDM enrollments to avoid updating team id on orbit enrollments
-        err = svc.keyValueStore.Set(r.Context, fleet.StickyMDMEnrollmentKeyPrefix+r.ID, "1", fleet.StickyMDMEnrollmentTTL)
-        if err != nil {
-            // We do not want to fail here, just log the error to notify
-            level.Error(svc.logger).Log("msg", "failed to set sticky mdm enrollment key", "err", err, "host_uuid", r.ID)
-        }
+        // Set sticky key for MDM enrollments to avoid updating team id on orbit enrollments.
+        if svc.keyValueStore != nil {
+          ctx, cancel := context.WithTimeout(r.Context, 500*time.Millisecond)
+          defer cancel()
+          if err := svc.keyValueStore.Set(ctx, fleet.StickyMDMEnrollmentKeyPrefix+r.ID, "1", fleet.StickyMDMEnrollmentTTL); err != nil {
+            // Non-fatal; log and continue.
+            level.Error(svc.logger).Log("msg", "failed to set sticky mdm enrollment key", "err", err, "host_uuid", r.ID)
+          }
+        } else {
+          level.Warn(svc.logger).Log("msg", "keyValueStore is nil; sticky mdm enrollment disabled", "host_uuid", r.ID)
+        }
server/service/testing_utils.go (1)

426-435: Consider conditional Redis pool initialization for efficiency.

The code unconditionally calls redistest.SetupRedis at line 428, even when opts[0].Pool will override redisPool at line 430. This creates an unused Redis pool when a custom pool is provided via options.

Consider initializing the Redis pool conditionally:

 	memLimitStore, _ := memstore.New(0)
 	var limitStore throttled.GCRAStore = memLimitStore
-	redisPool := redistest.SetupRedis(t, t.Name(), false, false, false) // We are good to initalize a redis pool here as it is only called by integration tests
+	var redisPool fleet.RedisPool
 	if len(opts) > 0 && opts[0].Pool != nil {
 		redisPool = opts[0].Pool
 		limitStore = &redis.ThrottledStore{
 			Pool:      opts[0].Pool,
 			KeyPrefix: "ratelimit::",
 		}
+	} else {
+		redisPool = redistest.SetupRedis(t, t.Name(), false, false, false) // We are good to initalize a redis pool here as it is only called by integration tests
 	}
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between adee5f7 and 6f53f32.

📒 Files selected for processing (13)
  • changes/26879-add-sticky-enrollment-redis-key (1 hunks)
  • cmd/fleet/serve.go (2 hunks)
  • ee/server/integrationtest/hostidentity/suite.go (2 hunks)
  • ee/server/service/mdm_external_test.go (1 hunks)
  • server/datastore/mysql/hosts.go (1 hunks)
  • server/fleet/mdm.go (1 hunks)
  • server/fleet/orbit.go (2 hunks)
  • server/service/apple_mdm.go (2 hunks)
  • server/service/apple_mdm_test.go (4 hunks)
  • server/service/integration_mdm_test.go (5 hunks)
  • server/service/orbit.go (1 hunks)
  • server/service/service.go (3 hunks)
  • server/service/testing_utils.go (3 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.go

⚙️ CodeRabbit configuration file

When reviewing SQL queries that are added or modified, ensure that appropriate filtering criteria are applied—especially when a query is intended to return data for a specific entity (e.g., a single host). Check for missing WHERE clauses or incorrect filtering that could lead to incorrect or non-deterministic results (e.g., returning the first row instead of the correct one). Flag any queries that may return unintended results due to lack of precise scoping.

Files:

  • server/fleet/mdm.go
  • server/fleet/orbit.go
  • server/service/orbit.go
  • server/service/service.go
  • server/service/apple_mdm_test.go
  • ee/server/integrationtest/hostidentity/suite.go
  • cmd/fleet/serve.go
  • server/service/testing_utils.go
  • server/service/integration_mdm_test.go
  • ee/server/service/mdm_external_test.go
  • server/datastore/mysql/hosts.go
  • server/service/apple_mdm.go
🔇 Additional comments (16)
changes/26879-add-sticky-enrollment-redis-key (1)

1-1: LGTM!

The changelog entry clearly describes the bug fix from the user's perspective. The issue where host transfers would be reversed by orbit enroll is now documented.

ee/server/integrationtest/hostidentity/suite.go (2)

12-12: LGTM!

The import of redistest is necessary to wire Redis-backed storage into the test setup.


52-52: LGTM!

The Redis pool is correctly wired into the test server setup via redistest.SetupRedis. The three false parameters likely control Redis features (e.g., cluster mode, TLS, persistence), and using false values is appropriate for integration tests that need a simple Redis instance.

cmd/fleet/serve.go (2)

777-777: LGTM!

The Redis-backed key-value store is correctly wired into the main Fleet service initialization. Using redis_key_value.New(redisPool) creates a new store instance that shares the underlying connection pool, which is appropriate for this stateless wrapper.


1282-1282: LGTM!

The Redis-backed key-value store is correctly wired into the MDM Apple checkin and command service. This enables the sticky enrollment feature for MDM flows by providing the necessary storage backend.

server/fleet/orbit.go (1)

120-126: New IgnoreTeamUpdate flag: API shape LGTM

Field name and behavior are clear and align with the enrollment flow usage.

server/service/apple_mdm_test.go (2)

37-37: LGTM: Redis test import added appropriately

Importing redistest aligns with new key-value dependency for tests.


52-52: LGTM: Key-value store import wired for tests

redis_key_value is correctly introduced for injecting the store.

server/service/orbit.go (1)

193-193: LGTM! Correct implementation of sticky enrollment logic.

The nil check correctly determines whether to ignore team updates during enrollment:

  • If the Redis key exists (stickyEnrollment != nil), team updates are ignored
  • If the key doesn't exist or Redis errors occurred, team updates proceed normally (safe default)

This properly implements the race condition mitigation described in the PR objectives.

server/service/service.go (2)

69-71: LGTM: service gains a KeyValueStore dependency

Unexported field is fine; clean injection point for the sticky enrollment feature.


181-182: LGTM: dependency wired

Field assignment is correct; no other behavior changes here.

server/service/apple_mdm.go (2)

3484-3484: LGTM: adds KeyValueStore to Apple check-in service

Field addition is straightforward and aligns with the sticky enrollment design.


3487-3496: Constructor updated — callers pass non-nil store

All instantiations (serve.go and testing_utils.go) use redis_key_value.New(redisPool), which returns a non-nil KeyValueStore. No further action needed.

server/service/testing_utils.go (3)

30-30: LGTM!

The import addition is necessary for the redistest.SetupRedis call introduced later in the file.


442-442: Verify that creating multiple redis_key_value.New instances is intentional.

The code creates separate redis_key_value.New instances at line 122 (for the main service's keyValueStore) and line 442 (for NewMDMAppleCheckinAndCommandService). If opts[0].Pool is provided, both instances use the same underlying Redis pool but are separate instances.

Please confirm this is intentional (e.g., different services need isolated instances) rather than unintended duplication.


219-219: LGTM!

The keyValueStore parameter is properly initialized earlier in the function (lines 98-100 or 118-123) before being passed to NewService.

Comment thread ee/server/service/mdm_external_test.go
Comment thread server/service/integration_mdm_test.go Outdated
Comment thread server/service/integration_mdm_test.go Outdated
Comment thread server/service/service.go

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

What about osquery enrollment? Do we also need to check there? I notice that it also looks at the secret and uses it to set team ID during enrollment:

// WithEnrollOsqueryTeamID sets the team ID for datastore Host enrollment
func WithEnrollOsqueryTeamID(teamID *uint) DatastoreEnrollOsqueryOption {
	return func(c *DatastoreEnrollOsqueryConfig) {
		c.TeamID = teamID
	}
}

@MagnusHJensen
MagnusHJensen merged commit 9360128 into main Oct 9, 2025
42 checks passed
@MagnusHJensen
MagnusHJensen deleted the 26879-add-sticky-enrollment-redis-key branch October 9, 2025 14:22
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.

Host transferred to a team via the POST /api/v1/fleet/hosts/transfer endpoint remained in No Team

2 participants