Demo: https://www.youtube.com/watch?v=cWxZlu9WuwA Guide updates: https://github.com/fleetdm/fleet/pull/49603/changes IT admins can configure the fleet that hosts enrolling through user-driven Windows MDM enrollment (Windows Autopilot, Entra join) are automatically assigned to, via the Windows MDM settings page, the mdm.windows_enrollment.default_fleet config setting, or GitOps. - New windows_enrollment_config row stores the default team; the config API surfaces it by fleet name and hydrates reads from the row so team renames and deletions never serve a stale name. Deleting the fleet clears the setting. - New edited_windows_enrollment_default_fleet activity, emitted only when the value changes. - The OMA-DM session persists the device-reported SMBIOS serial on still-unlinked enrollments, and orbit enrollment reverse-links by that serial and assigns the default fleet before orbit's one-shot setup-experience init, so the default fleet's software, scripts, and profiles apply during the Autopilot ESP. The DevDetail and osquery link paths keep the same assignment as fallbacks, and the EUA-token link path now shares the same post-link bookkeeping. - Hosts are only assigned when new to Fleet in this enrollment cycle: existing hosts, including ones parked in Unassigned, keep their fleet on re-enrollment, matching macOS ABM behavior. - GitOps defers applying the setting until teams declared in the same run are created, and fleetctl generate-gitops exports it. - Windows MDM settings page redesign per Figma: programmatic enrollment toggle, User driven enrollment section with the Entra-gated Default fleet dropdown, and a Migration section. <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41787 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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. - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## 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 ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). ## New Fleet configuration settings - [x] Verified that the setting is exported via `fleetctl generate-gitops` - [x] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [x] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [x] Verified that any relevant UI is disabled when GitOps mode is enabled <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for assigning a default Fleet Premium fleet to new Windows MDM enrollments, including Autopilot and Entra join. * Default-fleet settings can be configured, cleared, and managed through Windows MDM settings and GitOps. * Assigned fleet software, scripts, and profiles can apply during out-of-box setup. * Added activity-feed visibility for default-fleet changes. * Improved Windows enrollment matching using hardware serial numbers. * **Documentation** * Documented default-fleet assignment for Windows enrollment. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
438 lines
19 KiB
Go
438 lines
19 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
hostidentity_types "github.com/fleetdm/fleet/v4/ee/pkg/hostidentity/types"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
|
|
"github.com/fleetdm/fleet/v4/server/mock"
|
|
mysql_errors "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// euaTestingKey replaces "TESTING KEY" with "PRIVATE KEY" to prevent secret
|
|
// scanners from flagging test keys embedded in source files.
|
|
func euaTestingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }
|
|
|
|
// testWSTEPCert and testWSTEPKey are the same certs used in wstep_test.go.
|
|
var (
|
|
testWSTEPCert = []byte(`-----BEGIN CERTIFICATE-----
|
|
MIIDGzCCAgOgAwIBAgIBATANBgkqhkiG9w0BAQsFADAvMQkwBwYD
|
|
VQQGEwAxEDAOBgNVBAoTB3NjZXAtY2ExEDAOBgNVBAsTB1NDRVAg
|
|
Q0EwHhcNMjIxMjIyMTM0NDMzWhcNMzIxMjIyMTM0NDMzWjAvMQkw
|
|
BwYDVQQGEwAxEDAOBgNVBAoTB3NjZXAtY2ExEDAOBgNVBAsTB1ND
|
|
RVAgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDV
|
|
u9YVfl7gu0UgUkOJoES/XrN0WZdIjgvS2upKfvP4LSJOq1Mnp3bH
|
|
wWOA2NkHem/kjOVeotOk1aEYIzxbic6VlvNOz9huOhbJyoV4TO5v
|
|
tp/GFFcJ4IXh+f1Q4vm/NeH/XxEWn9S20B9OkSMOUievYsAu6iSi
|
|
oWaa74q1mnfpzM29p3dNM82mCKutYdkW0EusixU/CQxcVhdcxC+R
|
|
RyM4jzBFIipa7H20UtqdkZ03/9BoowJb/h/r4X7TN4tKg2vcwpZK
|
|
uJo7VcTBNPxhBowzg3JUmzjCnxPbuU/Ow5kPGOLJtbf4766ToNTM
|
|
/J63i3UPshKUBqAE8mIZO3qb7s25AgMBAAGjQjBAMA4GA1UdDwEB
|
|
/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTxPEY4
|
|
WvsLCt+HDQfnEPOKrHu0gTANBgkqhkiG9w0BAQsFAAOCAQEAGNf5
|
|
R60vRxIfvSOUyV3X7lUk+fVvi1CKC43DsP5OsQ6g5YVGcVXN40U4
|
|
2o7JUeb9K1jvqnzWB/3k+lSCkEb0a5KabjZE5Vpdt9xctmgrfNnQ
|
|
PBCfDdyb0Upjm61CJeB2SW9+ibT2L+OtL/nZjjlugL7ir9ramQBh
|
|
0IY6oB9Yc3TyZyPjnXwbi0jv5cildzIYaYPvPkPPTjezOUqUDgUH
|
|
JtdWRBQeJ/6WxAAm9il0KVXOsRPgAsdiDJTF6FdW4lsY8V/R6y0H
|
|
hTN1ZSyqklKAuvEZZznfmJsrNYRII2Fv2zOk0Uv/+E+EKTOHbgcC
|
|
PQAARDBzDlWvlMGWcbdrdypdeA==
|
|
-----END CERTIFICATE-----
|
|
`)
|
|
|
|
testWSTEPKey = []byte(euaTestingKey(`-----BEGIN RSA TESTING KEY-----
|
|
MIIEowIBAAKCAQEA1bvWFX5e4LtFIFJDiaBEv16zdFmXSI4L0trqSn7z+C0iTqtT
|
|
J6d2x8FjgNjZB3pv5IzlXqLTpNWhGCM8W4nOlZbzTs/YbjoWycqFeEzub7afxhRX
|
|
CeCF4fn9UOL5vzXh/18RFp/UttAfTpEjDlInr2LALuokoqFmmu+KtZp36czNvad3
|
|
TTPNpgirrWHZFtBLrIsVPwkMXFYXXMQvkUcjOI8wRSIqWux9tFLanZGdN//QaKMC
|
|
W/4f6+F+0zeLSoNr3MKWSriaO1XEwTT8YQaMM4NyVJs4wp8T27lPzsOZDxjiybW3
|
|
+O+uk6DUzPyet4t1D7ISlAagBPJiGTt6m+7NuQIDAQABAoIBAE6LXL1BV3SW3Wxn
|
|
TtKAx0Lcdm5HjkTnjojKUldWGCoXzAfFBiYIcKov83UiO394Cy6eaJxCkix9JVpN
|
|
eJzbI8PtWTSZRRwc1MsLVclD3EvJfSW5y9KhZBILYIAdKVKPZqIGOa1qxyz3hsnE
|
|
pHFa16KoU5/qA9SQI7jEVuEuBusv4D/dRlEWvva7QOhnLrBPrSnTSZ5LxCFKRviS
|
|
XrEQ9AuRJeXCKx4WzXd4IZPpgldYHMJSSGMr0TeVcURbsfveI2IWvOLag0ofTHhx
|
|
tolBT2sKzInItLTwt/irZEp5lV08mMGxHuxoCdzhxjFQP8eGOZzPW65c6/D9hEXd
|
|
DzWnjdECgYEA9QtTQosOTtAyU1i4Fm76ltT6nywHy23KAMhBaoKgTMccNtjaOCg/
|
|
5FCCRD+qoo7TF4jdliP2NrMIbAIhr4jEfHSMKaD/rae1xqInseDCrGi9gzvm8UxG
|
|
84VG30Id8s70ZQWZjR/PFFDeNZjNhlk8COO0XoLaqJSZr+A30aSyeUsCgYEA30ok
|
|
3EvO1+/gjZv28J9vApdbiEwtO9xoteghElFzdtuEuzA+wL83w8xvKvdb4Rk5xigE
|
|
6mV69dBPj8zSyGp0lFTYLFvry5N4S8L6QPzt2nk+Lc3cDKSA5CkAkQ5Dmt5JwhxF
|
|
qIPDNZGXmoldIWJ0p/ZSu98/1yXBMQ9gCje/losCgYBwuk4KLbheT27nYsgFIfbL
|
|
zpyg/vty/UXRiE53tjISQALdxHLXJMUHvnW++d8Au12m1QLDIDYTQdddALoIa42g
|
|
h2k3eWZFuAJqp4xFS1WjROfx6Gu8k8+MFcLd0CfA3K4XjzTtdDWqbe1bkLjz1jdF
|
|
C6OdWutGZF4zR53GJtMn8wKBgCfA95cRGB5x4rTTk797YzQ+5lj51wPVVf8s+NZe
|
|
EgSTSKpbCJEgejkt6IzpxT3qU9LnxRhGQQIKuF+Nw+lSqrbN9D7RjsWL19sFN7Di
|
|
VyaSd3OINyk5EImOkz9AHuEvukoI5o3+B38+EJO+6QnMkaBlxo0UTjVrz12As0Se
|
|
cEnJAoGBAOUXjez9oUSzLzqG/WJFrIfHyjDA1vBS1j39XuhDuJGqMdNLlCE8Yr7h
|
|
d3gpZeuV3ZC33QAuwAXfRBNnKIDtDGpcrozM1NndcBVDs9GYvobaTiUaODGjsH44
|
|
oHwpyQbv9Qs+3bjPOQ7DkwekT+w1cptEKudBCC3WQKui1P0NNL0R
|
|
-----END RSA TESTING KEY-----
|
|
`))
|
|
)
|
|
|
|
// newTestServiceWithWSTEP returns a Service with a real wstepCertManager built
|
|
// from the inline test cert/key, backed by a mock datastore.
|
|
func newTestServiceWithWSTEP(t *testing.T, ds *mock.Store) *Service {
|
|
t.Helper()
|
|
certManager, err := microsoft_mdm.NewCertManager(nil, testWSTEPCert, testWSTEPKey)
|
|
require.NoError(t, err)
|
|
|
|
return &Service{
|
|
ds: ds,
|
|
wstepCertManager: certManager,
|
|
logger: slog.New(slog.DiscardHandler),
|
|
}
|
|
}
|
|
|
|
func TestProcessWindowsEUAToken(t *testing.T) {
|
|
const (
|
|
testUPN = "user@example.com"
|
|
testDeviceID = "device-abc-123"
|
|
testHostUUID = "host-uuid-xyz"
|
|
testAcctUUID = "acct-uuid-456"
|
|
)
|
|
|
|
// Helper to generate a valid token for test cases.
|
|
makeToken := func(t *testing.T, svc *Service, upn, deviceID string) string {
|
|
t.Helper()
|
|
tok, err := svc.wstepCertManager.NewEUAToken(upn, deviceID)
|
|
require.NoError(t, err)
|
|
return tok
|
|
}
|
|
|
|
t.Run("valid token, new enrollment, account not yet in db", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := newTestServiceWithWSTEP(t, ds)
|
|
token := makeToken(t, svc, testUPN, testDeviceID)
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
require.Equal(t, testDeviceID, mdmDeviceID)
|
|
return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, HostUUID: ""}, nil
|
|
}
|
|
// First call returns not-found; second call (after insert) returns the account.
|
|
getByEmailCalls := 0
|
|
ds.GetMDMIdPAccountByEmailFunc = func(ctx context.Context, email string) (*fleet.MDMIdPAccount, error) {
|
|
require.Equal(t, testUPN, email)
|
|
getByEmailCalls++
|
|
if getByEmailCalls == 1 {
|
|
return nil, mysql_errors.NotFound("MDMIdPAccount")
|
|
}
|
|
return &fleet.MDMIdPAccount{UUID: testAcctUUID, Email: testUPN, Username: testUPN}, nil
|
|
}
|
|
ds.InsertMDMIdPAccountFunc = func(ctx context.Context, account *fleet.MDMIdPAccount) error {
|
|
require.Equal(t, testUPN, account.Email)
|
|
return nil
|
|
}
|
|
|
|
upn, deviceID, idpAcctUUID, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, token)
|
|
require.NoError(t, err)
|
|
require.Equal(t, testUPN, upn)
|
|
require.Equal(t, testDeviceID, deviceID)
|
|
require.Equal(t, testAcctUUID, idpAcctUUID, "must return idp account uuid so caller can associate after EnrollOrbit creates the host row")
|
|
require.False(t, ds.AssociateHostMDMIdPAccountFuncInvoked, "association is deferred until after EnrollOrbit (issue #45066)")
|
|
})
|
|
|
|
t.Run("valid token, account already exists in db", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := newTestServiceWithWSTEP(t, ds)
|
|
token := makeToken(t, svc, testUPN, testDeviceID)
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, HostUUID: ""}, nil
|
|
}
|
|
// Account exists — Insert should NOT be called.
|
|
ds.GetMDMIdPAccountByEmailFunc = func(ctx context.Context, email string) (*fleet.MDMIdPAccount, error) {
|
|
return &fleet.MDMIdPAccount{UUID: testAcctUUID, Email: testUPN, Username: "existing-username", Fullname: "Existing Name"}, nil
|
|
}
|
|
|
|
_, _, idpAcctUUID, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, token)
|
|
require.NoError(t, err)
|
|
require.False(t, ds.InsertMDMIdPAccountFuncInvoked, "should not insert when account already exists")
|
|
require.Equal(t, testAcctUUID, idpAcctUUID)
|
|
})
|
|
|
|
t.Run("valid token, enrollment already has host_uuid — still returns idp account uuid", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := newTestServiceWithWSTEP(t, ds)
|
|
token := makeToken(t, svc, testUPN, testDeviceID)
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
// HostUUID already set — device was previously enrolled.
|
|
return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, HostUUID: "existing-host-uuid"}, nil
|
|
}
|
|
// Account already exists — re-enrollment after host deletion may
|
|
// have left the enrollment row populated but the mapping missing.
|
|
ds.GetMDMIdPAccountByEmailFunc = func(ctx context.Context, email string) (*fleet.MDMIdPAccount, error) {
|
|
return &fleet.MDMIdPAccount{UUID: testAcctUUID, Email: testUPN, Username: testUPN}, nil
|
|
}
|
|
|
|
upn, deviceID, idpAcctUUID, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, token)
|
|
require.NoError(t, err)
|
|
require.Equal(t, testUPN, upn)
|
|
require.Equal(t, testDeviceID, deviceID)
|
|
require.Equal(t, testAcctUUID, idpAcctUUID)
|
|
require.True(t, ds.GetMDMIdPAccountByEmailFuncInvoked, "should still fetch idp account even when enrollment has host_uuid")
|
|
})
|
|
|
|
t.Run("invalid token falls back to END_USER_AUTH_REQUIRED", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := newTestServiceWithWSTEP(t, ds)
|
|
|
|
_, _, _, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, "this.is.not.a.valid.token")
|
|
require.Error(t, err)
|
|
var orbitErr *fleet.OrbitError
|
|
require.ErrorAs(t, err, &orbitErr)
|
|
require.Equal(t, "END_USER_AUTH_REQUIRED", orbitErr.Message)
|
|
require.False(t, ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked)
|
|
})
|
|
|
|
t.Run("nil wstepCertManager falls back to END_USER_AUTH_REQUIRED without panic", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := &Service{ds: ds, logger: slog.New(slog.DiscardHandler)}
|
|
|
|
_, _, _, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, "any.token.value")
|
|
require.Error(t, err)
|
|
var orbitErr *fleet.OrbitError
|
|
require.ErrorAs(t, err, &orbitErr)
|
|
require.Equal(t, "END_USER_AUTH_REQUIRED", orbitErr.Message)
|
|
require.False(t, ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked)
|
|
})
|
|
|
|
t.Run("device not found falls back to END_USER_AUTH_REQUIRED", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := newTestServiceWithWSTEP(t, ds)
|
|
token := makeToken(t, svc, testUPN, testDeviceID)
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return nil, mysql_errors.NotFound("MDMWindowsEnrolledDevice")
|
|
}
|
|
|
|
_, _, _, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, token)
|
|
require.Error(t, err)
|
|
var orbitErr *fleet.OrbitError
|
|
require.ErrorAs(t, err, &orbitErr)
|
|
require.Equal(t, "END_USER_AUTH_REQUIRED", orbitErr.Message)
|
|
})
|
|
}
|
|
|
|
func TestGenerateWindowsEUAToken(t *testing.T) {
|
|
const (
|
|
testUPN = "user@example.com"
|
|
testDeviceID = "device-abc-123"
|
|
)
|
|
|
|
t.Run("returns token for device with valid UPN", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := newTestServiceWithWSTEP(t, ds)
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, MDMEnrollUserID: testUPN}, nil
|
|
}
|
|
|
|
token := svc.generateWindowsEUAToken(context.Background(), testDeviceID)
|
|
require.NotEmpty(t, token)
|
|
|
|
// Token should be valid and contain expected claims.
|
|
claims, err := svc.wstepCertManager.GetEUATokenClaims(token)
|
|
require.NoError(t, err)
|
|
require.Equal(t, testUPN, claims.UPN)
|
|
require.Equal(t, testDeviceID, claims.DeviceID)
|
|
})
|
|
|
|
t.Run("returns empty string when device has no UPN", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := newTestServiceWithWSTEP(t, ds)
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, MDMEnrollUserID: ""}, nil
|
|
}
|
|
|
|
require.Empty(t, svc.generateWindowsEUAToken(context.Background(), testDeviceID))
|
|
})
|
|
|
|
t.Run("returns empty string when device not found", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := newTestServiceWithWSTEP(t, ds)
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return nil, mysql_errors.NotFound("MDMWindowsEnrolledDevice")
|
|
}
|
|
|
|
require.Empty(t, svc.generateWindowsEUAToken(context.Background(), testDeviceID))
|
|
})
|
|
|
|
t.Run("returns empty string when datastore returns error", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := newTestServiceWithWSTEP(t, ds)
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return nil, sql.ErrConnDone
|
|
}
|
|
|
|
require.Empty(t, svc.generateWindowsEUAToken(context.Background(), testDeviceID))
|
|
})
|
|
|
|
t.Run("returns empty string when wstepCertManager is nil", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
svc := &Service{ds: ds, logger: slog.New(slog.DiscardHandler)}
|
|
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, MDMEnrollUserID: testUPN}, nil
|
|
}
|
|
|
|
require.Empty(t, svc.generateWindowsEUAToken(context.Background(), testDeviceID))
|
|
require.False(t, ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked, "should not query db when cert manager is nil")
|
|
})
|
|
}
|
|
|
|
// enrollOrbitStore wraps mock.Store to make EnrollOrbit mockable
|
|
type enrollOrbitStore struct {
|
|
*mock.Store
|
|
enrollOrbitFunc func(ctx context.Context, opts ...fleet.DatastoreEnrollOrbitOption) (*fleet.Host, error)
|
|
enrollOrbitInvoked bool
|
|
}
|
|
|
|
func (s *enrollOrbitStore) EnrollOrbit(ctx context.Context, opts ...fleet.DatastoreEnrollOrbitOption) (*fleet.Host, error) {
|
|
s.enrollOrbitInvoked = true
|
|
return s.enrollOrbitFunc(ctx, opts...)
|
|
}
|
|
|
|
// TestEnrollOrbitWindowsReverseLink covers the reverse-link-by-serial branch of EnrollOrbit: a still-unlinked user-driven Windows
|
|
// MDM enrollment whose device-reported serial matches the enrolling host gets linked (and the Windows enrollment default fleet
|
|
// applied) before the enroll response returns; lookup failures never fail the enrollment.
|
|
func TestEnrollOrbitWindowsReverseLink(t *testing.T) {
|
|
const testSerial = "SER-123"
|
|
defaultTeamID := uint(7)
|
|
|
|
hostInfo := fleet.OrbitHostInfo{
|
|
HardwareUUID: "hw-uuid-1",
|
|
HardwareSerial: testSerial,
|
|
Hostname: "DESKTOP-1",
|
|
Platform: "windows",
|
|
}
|
|
|
|
newSvc := func(t *testing.T) (fleet.Service, *enrollOrbitStore) {
|
|
inner := new(mock.Store)
|
|
ds := &enrollOrbitStore{
|
|
Store: inner,
|
|
enrollOrbitFunc: func(ctx context.Context, opts ...fleet.DatastoreEnrollOrbitOption) (*fleet.Host, error) {
|
|
return &fleet.Host{ID: 42, UUID: "host-uuid-1", Platform: "windows"}, nil
|
|
},
|
|
}
|
|
svc, _ := newTestService(t, ds, nil, nil)
|
|
inner.VerifyEnrollSecretFunc = func(ctx context.Context, secret string) (*fleet.EnrollSecret, error) {
|
|
return &fleet.EnrollSecret{Secret: secret}, nil
|
|
}
|
|
inner.GetHostIdentityCertByNameFunc = func(ctx context.Context, name string) (*hostidentity_types.HostIdentityCertificate, error) {
|
|
return nil, newNotFoundError()
|
|
}
|
|
inner.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
cfg := &fleet.AppConfig{}
|
|
cfg.MDM.WindowsEnabledAndConfigured = true
|
|
return cfg, nil
|
|
}
|
|
inner.MaybeAssociateHostWithScimUserFunc = func(ctx context.Context, hostID uint) error { return nil }
|
|
return svc, ds
|
|
}
|
|
|
|
t.Run("windows mdm not configured: no reverse-link attempted", func(t *testing.T) {
|
|
svc, ds := newSvc(t)
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{}, nil
|
|
}
|
|
|
|
nodeKey, err := svc.EnrollOrbit(t.Context(), hostInfo, "secret", "")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, nodeKey)
|
|
require.False(t, ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFuncInvoked)
|
|
})
|
|
|
|
t.Run("no unlinked enrollment: enrollment succeeds without linking", func(t *testing.T) {
|
|
svc, ds := newSvc(t)
|
|
ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc = func(ctx context.Context, serial string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
require.Equal(t, testSerial, serial)
|
|
return nil, newNotFoundError()
|
|
}
|
|
|
|
nodeKey, err := svc.EnrollOrbit(t.Context(), hostInfo, "secret", "")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, nodeKey)
|
|
require.True(t, ds.enrollOrbitInvoked)
|
|
require.True(t, ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFuncInvoked)
|
|
require.False(t, ds.UpdateMDMWindowsEnrollmentsHostUUIDFuncInvoked)
|
|
})
|
|
|
|
t.Run("lookup error is non-fatal", func(t *testing.T) {
|
|
svc, ds := newSvc(t)
|
|
ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc = func(ctx context.Context, serial string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return nil, errors.New("db unavailable")
|
|
}
|
|
|
|
nodeKey, err := svc.EnrollOrbit(t.Context(), hostInfo, "secret", "")
|
|
require.NoError(t, err, "a failed reverse-link lookup must not fail enrollment")
|
|
require.NotEmpty(t, nodeKey)
|
|
require.False(t, ds.UpdateMDMWindowsEnrollmentsHostUUIDFuncInvoked)
|
|
})
|
|
|
|
t.Run("unlinked enrollment found: linked and default fleet assigned before returning", func(t *testing.T) {
|
|
svc, ds := newSvc(t)
|
|
device := &fleet.MDMWindowsEnrolledDevice{
|
|
ID: 1,
|
|
MDMDeviceID: "device-1",
|
|
MDMEnrollUserID: "user@example.com", // valid UPN: user-driven enrollment
|
|
CreatedAt: time.Now().UTC().Add(-2 * time.Minute),
|
|
}
|
|
ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc = func(ctx context.Context, serial string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return device, nil
|
|
}
|
|
ds.UpdateMDMWindowsEnrollmentsHostUUIDFunc = func(ctx context.Context, hostUUID string, deviceID string) (bool, error) {
|
|
require.Equal(t, "host-uuid-1", hostUUID)
|
|
require.Equal(t, "device-1", deviceID)
|
|
return true, nil
|
|
}
|
|
ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, deviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
|
|
return device, nil
|
|
}
|
|
ds.GetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context) (*uint, string, error) {
|
|
return &defaultTeamID, "Workstations", nil
|
|
}
|
|
ds.HostLiteByIDFunc = func(ctx context.Context, id uint) (*fleet.HostLite, error) {
|
|
return &fleet.HostLite{ID: id, CreatedAt: time.Now().UTC()}, nil
|
|
}
|
|
var assignedTeamID *uint
|
|
ds.AddHostsToTeamFunc = func(ctx context.Context, params *fleet.AddHostsToTeamParams) error {
|
|
assignedTeamID = params.TeamID
|
|
require.Equal(t, []uint{42}, params.HostIDs)
|
|
return nil
|
|
}
|
|
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (fleet.MDMProfilesUpdates, error) {
|
|
return fleet.MDMProfilesUpdates{}, nil
|
|
}
|
|
ds.ReplaceHostDeviceMappingFunc = func(ctx context.Context, hostID uint, mappings []*fleet.HostDeviceMapping, source string) error {
|
|
return nil
|
|
}
|
|
ds.ScimUserByUserNameOrEmailFunc = func(ctx context.Context, name string, email string) (*fleet.ScimUser, error) {
|
|
return nil, newNotFoundError()
|
|
}
|
|
ds.DeleteHostSCIMUserMappingFunc = func(ctx context.Context, hostID uint) ([]fleet.ActivityTypeResentCertificate, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
nodeKey, err := svc.EnrollOrbit(t.Context(), hostInfo, "secret", "")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, nodeKey)
|
|
require.True(t, ds.UpdateMDMWindowsEnrollmentsHostUUIDFuncInvoked)
|
|
require.True(t, ds.AddHostsToTeamFuncInvoked, "default fleet must be assigned before EnrollOrbit returns")
|
|
require.NotNil(t, assignedTeamID)
|
|
require.Equal(t, defaultTeamID, *assignedTeamID)
|
|
})
|
|
}
|