From 6c3de12f6940e3aa142935dbf01c3beb044e9f79 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 3 Aug 2026 07:40:48 -0400 Subject: [PATCH] Base Orbit enrollment end user auth on server policy EnrollOrbit now determines end user authentication requirements from server policy rather than the client-supplied X-Fleet-Capabilities header, which is treated as an informational hint. Adds the mdm.allow_orbit_end_user_auth_bypass setting (enabled by default) controlling whether hosts that do not complete end user authentication may enroll into a team that requires it; set it to false to strictly enforce end user auth. The setting also governs installers built with fleetctl package --bypass-end-user-auth. --- changes/16798-orbit-enroll-end-user-auth | 1 + .../fleet-server-configuration.md | 16 +++ server/config/config.go | 13 +++ server/service/integration_enterprise_test.go | 6 +- server/service/orbit.go | 33 ++++-- server/service/orbit_test.go | 110 ++++++++++++++++++ 6 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 changes/16798-orbit-enroll-end-user-auth diff --git a/changes/16798-orbit-enroll-end-user-auth b/changes/16798-orbit-enroll-end-user-auth new file mode 100644 index 0000000000..56b9106c28 --- /dev/null +++ b/changes/16798-orbit-enroll-end-user-auth @@ -0,0 +1 @@ +- Orbit enrollment now determines end user authentication requirements from server policy rather than client-advertised capabilities. The `mdm.allow_orbit_end_user_auth_bypass` server setting (enabled by default) controls whether hosts that do not complete end user authentication may enroll into a team that requires it; set it to `false` to strictly enforce end user authentication for all Orbit enrollments. diff --git a/docs/Configuration/fleet-server-configuration.md b/docs/Configuration/fleet-server-configuration.md index 0912871e50..20d6a053ca 100644 --- a/docs/Configuration/fleet-server-configuration.md +++ b/docs/Configuration/fleet-server-configuration.md @@ -3728,6 +3728,22 @@ Enabling this bypasses checks for forbidden declaration types, reserved identifi allow_all_declarations: true ``` +### mdm.allow_orbit_end_user_auth_bypass + +When a team requires [end user authentication](https://fleetdm.com/guides/end-user-authentication), Fleet gates Linux and Windows Orbit enrollment on end user authentication. `fleetd`/Orbit versions that predate end user authentication support cannot complete that flow, and installers built with `fleetctl package --bypass-end-user-auth` intentionally skip it. + +By default (`true`), Fleet allows those hosts to enroll into a team that requires end user authentication without completing it. Set this to `false` to strictly enforce end user authentication for all Orbit enrollments — hosts that do not complete end user authentication (including `--bypass-end-user-auth` installers and pre-end-user-auth agents) are then blocked. + +Hosts that already enrolled before end user authentication was enabled are always allowed to re-enroll regardless of this setting. Windows hosts that present a valid end-user-auth token from MDM enrollment always complete end user authentication regardless of this setting. + +- Default value: `true` +- Environment variable: `FLEET_MDM_ALLOW_ORBIT_END_USER_AUTH_BYPASS` +- Config file format: + ```yaml + mdm: + allow_orbit_end_user_auth_bypass: false + ``` + ### fleet_allow_bootstrap_package_during_migration When set to `1` or `true`, this environment variable enables Fleet to install bootstrap packages on hosts during MDM migration enrollments (i.e. non-DEP enrollments). By default, bootstrap packages are only installed for DEP-enrolled hosts. Setting this variable restores the previous behavior, ensuring all new enrollments receive the bootstrap package. diff --git a/server/config/config.go b/server/config/config.go index 466a6b95cf..1633421975 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -949,6 +949,14 @@ type MDMConfig struct { EnableCustomDiskEncryption bool `yaml:"enable_custom_disk_encryption"` AllowAllDeclarations bool `yaml:"allow_all_declarations"` + // AllowOrbitEndUserAuthBypass controls whether an Orbit/fleetd host that does + // not complete end user authentication is allowed to enroll into a team that + // requires it. Defaults to true so that agents predating end user + // authentication (and installers built with `fleetctl package + // --bypass-end-user-auth`) can still enroll. Set to false to strictly enforce + // end user authentication for all Orbit enrollments. + AllowOrbitEndUserAuthBypass bool `yaml:"allow_orbit_end_user_auth_bypass"` + AndroidAgent AndroidAgentConfig `yaml:"android_agent"` AndroidBatchSize int `yaml:"android_batch_size"` } @@ -1806,6 +1814,7 @@ func (man Manager) addConfigs() { man.addConfigBool("mdm.enable_custom_filevault", false, "Allows usage of custom Apple MDM profiles for FileVault (Fleet Premium required)") man.addConfigBool("mdm.enable_custom_disk_encryption", false, "Allows usage of custom Apple MDM profiles for FileVault and custom Windows profiles for BitLocker (Fleet Premium required)") man.addConfigBool("mdm.allow_all_declarations", false, "Allows all MDM declaration types to be sent, bypassing safety checks") + man.addConfigBool("mdm.allow_orbit_end_user_auth_bypass", true, "Allow Orbit hosts that do not complete end user authentication to enroll into teams that require it; set to false to strictly enforce end user authentication for Orbit enrollments") man.addConfigString("mdm.android_agent.package", "com.fleetdm.agent", "Package name for the Fleet Android agent") man.addConfigString("mdm.android_agent.signing_sha256", "x+IyvrwVbQEBYV/ojWmLavJE0VIZE1RAT2JmxeI5sFw=", "Signing certificate SHA256 fingerprint for the Fleet Android agent") man.hideConfig("mdm.android_agent.package") @@ -2152,6 +2161,7 @@ func (man Manager) LoadConfig() FleetConfig { EnableCustomFileVault: man.getConfigBool("mdm.enable_custom_filevault"), EnableCustomDiskEncryption: man.getConfigBool("mdm.enable_custom_disk_encryption"), AllowAllDeclarations: man.getConfigBool("mdm.allow_all_declarations"), + AllowOrbitEndUserAuthBypass: man.getConfigBool("mdm.allow_orbit_end_user_auth_bypass"), AndroidAgent: AndroidAgentConfig{ Package: man.getConfigString("mdm.android_agent.package"), SigningSHA256: man.getConfigString("mdm.android_agent.signing_sha256"), @@ -2569,6 +2579,9 @@ func TestConfig() FleetConfig { Vulnerabilities: VulnerabilitiesConfig{ OSVForVulnerabilities: true, }, + MDM: MDMConfig{ + AllowOrbitEndUserAuthBypass: true, + }, } } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 6fbe012863..d9e7765dff 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -33072,9 +33072,9 @@ func (s *integrationEnterpriseTestSuite) TestOrbitEnrollWithIdPPopulatesDeviceMa require.NoError(t, s.ds.ApplyEnrollSecrets(ctx, &team.ID, []*fleet.EnrollSecret{{Secret: enrollSecret}})) // Orbit client capabilities — Linux and Windows orbit builds advertise - // CapabilityEndUserAuth. Without this header the EnrollOrbit handler - // short-circuits past the EUA gating (with a logged warning) and the bug - // would not be exercised. + // CapabilityEndUserAuth. The X-Fleet-Capabilities header is an + // informational hint only: EUA gating must hold regardless of what + // the client advertises. var caps fleet.CapabilityMap caps.PopulateFromString(string(fleet.CapabilityEndUserAuth)) capsHeaders := map[string]string{fleet.CapabilitiesHeader: caps.String()} diff --git a/server/service/orbit.go b/server/service/orbit.go index 5aef6c8c0e..94a51ad1a1 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -253,13 +253,17 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf // Orbit enrollment is only gated by end user auth for Linux and Windows hosts. // For macOS hosts the MDM enrollment process handles end user auth. if platform == "linux" || platform == "windows" { - // If the Orbit client doesn't support end user auth, complain loudly and let the host enroll. - mp, ok := capabilities.FromContext(ctx) + // Enforcement is based solely on server policy. The client-supplied + // X-Fleet-Capabilities header is an informational hint and must not + // gate this decision. + // + // The AllowOrbitEndUserAuthBypass escape hatch lets clients that do not + // advertise the end-user auth capability enroll anyway — either pre-EUA + // agents, or installers built with `fleetctl package --bypass-end-user-auth`. + // It defaults to true; set it to false to strictly enforce end user auth. + mp, capsOK := capabilities.FromContext(ctx) + clientSupportsEUA := capsOK && mp.Has(fleet.CapabilityEndUserAuth) switch { - case !ok: - svc.logger.ErrorContext(ctx, "allowing unauthenticated enrollment: could not determine orbit end-user auth capability", "host_uuid", hostInfo.HardwareUUID) - case !mp.Has(fleet.CapabilityEndUserAuth): - svc.logger.WarnContext(ctx, "allowing unauthenticated enrollment: orbit version does not support end-user authentication", "host_uuid", hostInfo.HardwareUUID) case platform == "windows" && euaToken != "": // A Windows host already authenticated during MDM enrollment and the // EUA token was passed by the MSI installer. @@ -271,6 +275,10 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf euaDeviceID = deviceID euaIdpAcctUUID = idpAcctUUID // Continue enrollment — do not return END_USER_AUTH_REQUIRED. + case svc.config.MDM.AllowOrbitEndUserAuthBypass && !clientSupportsEUA: + svc.logger.WarnContext(ctx, "allowing enrollment without end-user authentication: end-user auth bypass is enabled and the client does not support end-user auth", + "host_uuid", hostInfo.HardwareUUID) + // Continue enrollment — do not return END_USER_AUTH_REQUIRED. default: // A host that already exists in Fleet and was previously orbit-enrolled is re-enrolling (e.g. after a // service restart, node key file loss, or osquery DB rebuild), not enrolling for the first time. We must not @@ -280,7 +288,18 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf return "", fleet.OrbitError{Message: "failed to check for prior orbit enrollment: " + err.Error()} } if !previouslyEnrolled { - // Otherwise report the unauthenticated host and let Orbit handle it (e.g. by prompting the user to authenticate). + // Report the unauthenticated host and let Orbit handle it (e.g. by prompting the user to authenticate). + // Dereference the team ID so the log shows the numeric value; leave it nil for a global enroll secret. + var teamID any + if secret.TeamID != nil { + teamID = *secret.TeamID + } + svc.logger.WarnContext(ctx, "blocking enrollment: end-user authentication required but not completed", + "host_uuid", hostInfo.HardwareUUID, + "hardware_serial", hostInfo.HardwareSerial, + "platform", platform, + "team_id", teamID, + ) return "", fleet.NewOrbitIDPAuthRequiredError() } svc.logger.InfoContext(ctx, "allowing re-enrollment without end-user authentication: host previously orbit-enrolled", diff --git a/server/service/orbit_test.go b/server/service/orbit_test.go index 8fd3905834..20bfffa41f 100644 --- a/server/service/orbit_test.go +++ b/server/service/orbit_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + hostidentity_types "github.com/fleetdm/fleet/v4/ee/pkg/hostidentity/types" "github.com/fleetdm/fleet/v4/pkg/optjson" activity_api "github.com/fleetdm/fleet/v4/server/activity/api" "github.com/fleetdm/fleet/v4/server/config" @@ -1654,3 +1655,112 @@ func TestResolveOrbitDebugLogging(t *testing.T) { }) } } + +func TestEnrollOrbitEndUserAuthBypass(t *testing.T) { + // When end user authentication is required and the enrolling agent does not + // advertise the end_user_auth capability (for example an older agent that + // does not set the X-Fleet-Capabilities header), the + // AllowOrbitEndUserAuthBypass config flag decides whether enrollment is + // blocked or allowed. + newSvc := func(t *testing.T, allowBypass bool) (*mock.DataStore, fleet.Service, context.Context) { + // mock.Store hard-codes EnrollOrbit to return (nil, nil), which would make + // the bypass-allowed success path panic. Use the underlying mock.DataStore so + // EnrollOrbitFunc is honored. + ds := new(mock.DataStore) + cfg := config.TestConfig() + cfg.MDM.AllowOrbitEndUserAuthBypass = allowBypass + svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil) + + // Global enroll secret (no team) with end user auth required at the app-config level. + ds.VerifyEnrollSecretFunc = func(ctx context.Context, secret string) (*fleet.EnrollSecret, error) { + return &fleet.EnrollSecret{Secret: secret}, nil + } + ds.GetHostIdentityCertByNameFunc = func(ctx context.Context, name string) (*hostidentity_types.HostIdentityCertificate, error) { + return nil, nil + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + ac := &fleet.AppConfig{} + ac.MDM.EnabledAndConfigured = true + ac.MDM.MacOSSetup.EnableEndUserAuthentication = true + return ac, nil + } + // No IdP account linked and not previously enrolled: a genuine first-time enrollment. + ds.GetMDMIdPAccountByHostUUIDFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMIdPAccount, error) { + return nil, nil + } + ds.HostPreviouslyOrbitEnrolledFunc = func(ctx context.Context, hostInfo fleet.OrbitHostInfo, isMDMEnabled bool) (bool, error) { + return false, nil + } + ds.EnrollOrbitFunc = func(ctx context.Context, opts ...fleet.DatastoreEnrollOrbitOption) (*fleet.Host, error) { + return &fleet.Host{ID: 1, UUID: "host-uuid-1", Platform: "ubuntu"}, nil + } + ds.MaybeAssociateHostWithScimUserFunc = func(ctx context.Context, hostID uint) error { + return nil + } + return ds, svc, ctx + } + + hostInfo := fleet.OrbitHostInfo{ + HardwareUUID: "host-uuid-1", + HardwareSerial: "serial-1", + Hostname: "host-1", + Platform: "ubuntu", + PlatformLike: "debian", + } + + // noEUACtx builds a request context advertising only unrelated capabilities, + // simulating an agent that does not support end user auth. + noEUACtx := func(ctx context.Context) context.Context { + req := httptest.NewRequest("POST", "/api/fleet/orbit/enroll", nil) + req.Header.Set(fleet.CapabilitiesHeader, "foo,bar") + return capabilities.NewContext(ctx, req) + } + + t.Run("flag disabled blocks enrollment", func(t *testing.T) { + ds, svc, ctx := newSvc(t, false) + _, err := svc.EnrollOrbit(noEUACtx(ctx), hostInfo, "secret", "") + require.Error(t, err) + require.Contains(t, err.Error(), "END_USER_AUTH_REQUIRED") + require.False(t, ds.EnrollOrbitFuncInvoked, "no host must be enrolled when EUA is required and the flag is off") + }) + + t.Run("flag enabled allows enrollment", func(t *testing.T) { + ds, svc, ctx := newSvc(t, true) + nodeKey, err := svc.EnrollOrbit(noEUACtx(ctx), hostInfo, "secret", "") + require.NoError(t, err) + require.NotEmpty(t, nodeKey) + require.True(t, ds.EnrollOrbitFuncInvoked) + }) + + t.Run("flag enabled still gates agents that support EUA", func(t *testing.T) { + // The escape hatch only applies to agents that do not support end user + // auth. A modern agent that advertises the capability must still go + // through the SSO flow even when the flag is on. + ds, svc, ctx := newSvc(t, true) + euaCtx := func(ctx context.Context) context.Context { + req := httptest.NewRequest("POST", "/api/fleet/orbit/enroll", nil) + req.Header.Set(fleet.CapabilitiesHeader, string(fleet.CapabilityEndUserAuth)) + return capabilities.NewContext(ctx, req) + } + _, err := svc.EnrollOrbit(euaCtx(ctx), hostInfo, "secret", "") + require.Error(t, err) + require.Contains(t, err.Error(), "END_USER_AUTH_REQUIRED") + require.False(t, ds.EnrollOrbitFuncInvoked) + }) + + t.Run("windows EUA token takes precedence over the flag", func(t *testing.T) { + // A Windows host presenting an EUA token must go through the token path even when + // the flag is on and the client omits the capability — the token case is ordered + // first. wstepCertManager is unset in this harness, so the token path falls back to + // END_USER_AUTH_REQUIRED; the point is that the flag's bypass does not fire (no host + // is enrolled), proving the token case wins. + ds, svc, ctx := newSvc(t, true) + winHost := hostInfo + winHost.Platform = "windows" + winHost.PlatformLike = "" + _, err := svc.EnrollOrbit(noEUACtx(ctx), winHost, "secret", "some-eua-token") + require.Error(t, err) + require.Contains(t, err.Error(), "END_USER_AUTH_REQUIRED") + require.False(t, ds.EnrollOrbitFuncInvoked, "the flag bypass must not fire when an EUA token is present") + }) +}