diff --git a/changes/21955-ndes-scep-proxy b/changes/21955-ndes-scep-proxy new file mode 100644 index 000000000000..0460f4c15655 --- /dev/null +++ b/changes/21955-ndes-scep-proxy @@ -0,0 +1 @@ +Added SCEP proxy for Windows NDES (Network Device Enrollment Service) AD CS server, which allows devices to request certificates. diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 02353ffe3183..6afd5bd86303 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -501,7 +501,7 @@ the way that the Fleet server works. } checkMDMAssets := func(names []fleet.MDMAssetName) (bool, error) { - _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), names) + _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), names, nil) if err != nil { if fleet.IsNotFound(err) || errors.Is(err, mysql.ErrPartialResult) { return false, nil @@ -576,7 +576,7 @@ the way that the Fleet server works. } } - if err := ds.InsertMDMConfigAssets(context.Background(), args); err != nil { + if err := ds.InsertMDMConfigAssets(context.Background(), args, nil); err != nil { if mysql.IsDuplicate(err) { // we already checked for existing assets so we should never have a duplicate key error here; we'll add a debug log just in case level.Debug(logger).Log("msg", "unexpected duplicate key error inserting MDM APNs and SCEP assets") @@ -611,7 +611,7 @@ the way that the Fleet server works. } if len(toInsert) > 0 { - err := ds.InsertMDMConfigAssets(context.Background(), toInsert) + err := ds.InsertMDMConfigAssets(context.Background(), toInsert, nil) switch { case err != nil && mysql.IsDuplicate(err): // we already checked for existing assets so we should never have a duplicate key error here; we'll add a debug log just in case @@ -1082,7 +1082,7 @@ the way that the Fleet server works. err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{ {Name: fleet.MDMAssetSCEPChallenge, Value: []byte(scepChallenge)}, - }) + }, nil) if err != nil { // duplicate key errors mean that we already // have a value for those keys in the @@ -1108,6 +1108,13 @@ the way that the Fleet server works. } } + // SCEP proxy (for NDES, etc.) + if license.IsPremium() { + if err = service.RegisterSCEPProxy(rootMux, ds, logger); err != nil { + initFatal(err, "setup SCEP proxy") + } + } + if config.Prometheus.BasicAuth.Username != "" && config.Prometheus.BasicAuth.Password != "" { rootMux.Handle("/metrics", basicAuthHandler( config.Prometheus.BasicAuth.Username, diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index e472566f3e93..0d147f698603 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -30,6 +30,7 @@ import ( "github.com/go-kit/log" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/jmoiron/sqlx" "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1077,7 +1078,8 @@ func TestVerifyDiskEncryptionKeysJob(t *testing.T) { fleet.MDMAssetCACert: {Value: testCertPEM}, fleet.MDMAssetCAKey: {Value: testKeyPEM}, } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return assets, nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { diff --git a/cmd/fleetctl/gitops_enterprise_integration_test.go b/cmd/fleetctl/gitops_enterprise_integration_test.go index 235d89dca82a..1128fbf26879 100644 --- a/cmd/fleetctl/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/gitops_enterprise_integration_test.go @@ -59,7 +59,7 @@ func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() { {Name: fleet.MDMAssetAPNSKey, Value: testKeyPEM}, {Name: fleet.MDMAssetCACert, Value: testCertPEM}, {Name: fleet.MDMAssetCAKey, Value: testKeyPEM}, - }) + }, nil) require.NoError(s.T(), err) mdmStorage, err := s.ds.NewMDMAppleMDMStorage() @@ -83,7 +83,7 @@ func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() { } err = s.ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{ {Name: fleet.MDMAssetSCEPChallenge, Value: []byte("scepchallenge")}, - }) + }, nil) require.NoError(s.T(), err) users, server := service.RunServerForTestsWithDS(s.T(), s.ds, &serverConfig) s.T().Setenv("FLEET_SERVER_ADDRESS", server.URL) // fleetctl always uses this env var in tests diff --git a/cmd/fleetctl/gitops_integration_test.go b/cmd/fleetctl/gitops_integration_test.go index 3117edc13aa2..78d88c7c5f7b 100644 --- a/cmd/fleetctl/gitops_integration_test.go +++ b/cmd/fleetctl/gitops_integration_test.go @@ -71,7 +71,7 @@ func (s *integrationGitopsTestSuite) SetupSuite() { } err = s.ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{ {Name: fleet.MDMAssetSCEPChallenge, Value: []byte("scepchallenge")}, - }) + }, nil) require.NoError(s.T(), err) users, server := service.RunServerForTestsWithDS(s.T(), s.ds, &serverConfig) s.T().Setenv("FLEET_SERVER_ADDRESS", server.URL) // fleetctl always uses this env var in tests diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 295172612d74..b6119885b3a3 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -27,6 +27,7 @@ import ( "github.com/fleetdm/fleet/v4/server/service" "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1682,7 +1683,8 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) { scepCert := tokenpki.PEMCertificate(crt.Raw) scepKey := tokenpki.PEMRSAPrivateKey(key) - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Value: scepCert}, fleet.MDMAssetCAKey: {Value: scepKey}, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index ece537e7b95f..f1fca92952d0 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -90,7 +90,8 @@ "integrations": { "jira": null, "zendesk": null, - "google_calendar": null + "google_calendar": null, + "ndes_scep_proxy": null }, "mdm": { "apple_bm_terms_expired": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 5f19ffcb8ac0..a79e0f0b3681 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -16,6 +16,7 @@ spec: integrations: google_calendar: null jira: null + ndes_scep_proxy: null zendesk: null mdm: apple_bm_terms_expired: false diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 9fa625b676af..ce6f6c4c22bb 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -140,7 +140,8 @@ "integrations": { "jira": null, "zendesk": null, - "google_calendar": null + "google_calendar": null, + "ndes_scep_proxy": null }, "update_interval": { "osquery_detail": "1h0m0s", diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index b28ab395b3e9..bc910c8ede31 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -16,6 +16,7 @@ spec: integrations: google_calendar: null jira: null + ndes_scep_proxy: null zendesk: null mdm: apple_business_manager: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 02adaad3acfc..c2b0a4fed468 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -16,6 +16,7 @@ spec: integrations: google_calendar: null jira: null + ndes_scep_proxy: null zendesk: null mdm: apple_business_manager: diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 2dd2f93adf1f..b2c5fec52e85 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -16,6 +16,7 @@ spec: integrations: google_calendar: null jira: null + ndes_scep_proxy: null zendesk: null mdm: apple_business_manager: diff --git a/cmd/fleetctl/testing_utils.go b/cmd/fleetctl/testing_utils.go index 918a12b94e27..4229dea300b8 100644 --- a/cmd/fleetctl/testing_utils.go +++ b/cmd/fleetctl/testing_utils.go @@ -19,6 +19,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/service" "github.com/fleetdm/fleet/v4/server/test" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -136,7 +137,8 @@ func runServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http fleet.MDMAssetCAKey: "scepkey", }, nil } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM}, fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM}, diff --git a/ee/server/service/errors.go b/ee/server/service/errors.go index 873069aa6982..3a263c07a20c 100644 --- a/ee/server/service/errors.go +++ b/ee/server/service/errors.go @@ -1,6 +1,8 @@ package service -import "github.com/fleetdm/fleet/v4/server/fleet" +import ( + "github.com/fleetdm/fleet/v4/server/fleet" +) type notFoundError struct { fleet.ErrorWithUUID @@ -15,3 +17,27 @@ func (e notFoundError) Error() string { func (e notFoundError) IsNotFound() bool { return true } + +type NDESInvalidError struct { + msg string +} + +func (e NDESInvalidError) Error() string { + return e.msg +} + +func NewNDESInvalidError(msg string) NDESInvalidError { + return NDESInvalidError{msg: msg} +} + +type NDESPasswordCacheFullError struct { + msg string +} + +func (e NDESPasswordCacheFullError) Error() string { + return e.msg +} + +func NewNDESPasswordCacheFullError(msg string) NDESPasswordCacheFullError { + return NDESPasswordCacheFullError{msg: msg} +} diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index f6db29af5c9b..8e22df5b1b05 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -1163,7 +1163,7 @@ func (svc *Service) GetMDMManualEnrollmentProfile(ctx context.Context) ([]byte, assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetSCEPChallenge, - }) + }, nil) if err != nil { return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err) } @@ -1368,7 +1368,7 @@ func (svc *Service) decryptUploadedABMToken(ctx context.Context, token io.Reader pair, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetABMCert, fleet.MDMAssetABMKey, - }) + }, nil) if err != nil { if fleet.IsNotFound(err) { return nil, nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index 760d046c9c5b..9522a87e5198 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -30,6 +30,7 @@ import ( "github.com/fleetdm/fleet/v4/server/worker" kitlog "github.com/go-kit/log" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -234,7 +235,8 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { require.NoError(t, err) certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t) require.NoError(t, err) - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM}, fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM}, diff --git a/ee/server/service/mdm_test.go b/ee/server/service/mdm_test.go index df7e5afb50e4..21cc3d21d939 100644 --- a/ee/server/service/mdm_test.go +++ b/ee/server/service/mdm_test.go @@ -11,13 +11,15 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) func setup(t *testing.T) (*mock.Store, *Service) { ds := new(mock.Store) - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Value: []byte(testCert)}, fleet.MDMAssetCAKey: {Value: []byte(testKey)}, @@ -38,7 +40,8 @@ func TestMDMAppleEnableFileVaultAndEscrow(t *testing.T) { t.Run("fails if SCEP is not configured", func(t *testing.T) { ds := new(mock.Store) svc := &Service{ds: ds} - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return nil, nil } err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, nil) diff --git a/ee/server/service/scep_proxy.go b/ee/server/service/scep_proxy.go new file mode 100644 index 000000000000..53c47e419faf --- /dev/null +++ b/ee/server/service/scep_proxy.go @@ -0,0 +1,206 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/Azure/go-ntlmssp" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + scepclient "github.com/fleetdm/fleet/v4/server/mdm/scep/client" + scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + "github.com/go-kit/log" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +var _ scepserver.ServiceWithIdentifier = (*scepProxyService)(nil) +var challengeRegex = regexp.MustCompile(`(?i)The enrollment challenge password is: (?P\S*)`) + +const ( + fullPasswordCache = "The password cache is full." + MessageSCEPProxyNotConfigured = "SCEP proxy is not configured" +) + +type scepProxyService struct { + ds fleet.Datastore + // info logging is implemented in the service middleware layer. + debugLogger log.Logger +} + +// GetCACaps returns a list of SCEP options which are supported by the server. +// It is a pass-through call to the SCEP server. +func (svc *scepProxyService) GetCACaps(ctx context.Context) ([]byte, error) { + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting app config") + } + if !appConfig.Integrations.NDESSCEPProxy.Valid { + // Return error that implements kithttp.StatusCoder interface + return nil, &scepserver.BadRequestError{Message: MessageSCEPProxyNotConfigured} + } + client, err := scepclient.New(appConfig.Integrations.NDESSCEPProxy.Value.URL, svc.debugLogger) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating SCEP client") + } + res, err := client.GetCACaps(ctx) + if err != nil { + return res, ctxerr.Wrapf(ctx, err, "Could not GetCACaps from SCEP server %s", appConfig.Integrations.NDESSCEPProxy.Value.URL) + } + return res, nil +} + +// GetCACert returns the CA certificate(s) from SCEP server. +// It is a pass-through call to the SCEP server. +func (svc *scepProxyService) GetCACert(ctx context.Context, message string) ([]byte, int, error) { + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, 0, ctxerr.Wrap(ctx, err, "getting app config") + } + if !appConfig.Integrations.NDESSCEPProxy.Valid { + // Return error that implements kithttp.StatusCoder interface + return nil, 0, &scepserver.BadRequestError{Message: MessageSCEPProxyNotConfigured} + } + client, err := scepclient.New(appConfig.Integrations.NDESSCEPProxy.Value.URL, svc.debugLogger) + if err != nil { + return nil, 0, ctxerr.Wrap(ctx, err, "creating SCEP client") + } + res, num, err := client.GetCACert(ctx, message) + if err != nil { + return res, num, ctxerr.Wrapf(ctx, err, "Could not GetCACert from SCEP server %s", appConfig.Integrations.NDESSCEPProxy.Value.URL) + } + return res, num, nil +} + +func (svc *scepProxyService) PKIOperation(ctx context.Context, data []byte, identifier string) ([]byte, error) { + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting app config") + } + if !appConfig.Integrations.NDESSCEPProxy.Valid { + // Return error that implements kithttp.StatusCoder interface + return nil, &scepserver.BadRequestError{Message: MessageSCEPProxyNotConfigured} + } + + // Validate the identifier. In the future, we will also use the identifier for tracking the certificate renewal. + parsedID, err := url.QueryUnescape(identifier) + if err != nil { + // Should never happen since the identifier comes in as a path variable + return nil, ctxerr.Wrap(ctx, err, "unescaping identifier in URL path") + } + parsedIDs := strings.Split(parsedID, ",") + if len(parsedIDs) != 2 || parsedIDs[0] == "" || parsedIDs[1] == "" { + // Return error that implements kithttp.StatusCoder interface + return nil, &scepserver.BadRequestError{Message: "invalid identifier in URL path"} + } + profile, err := svc.ds.GetHostMDMAppleProfile(ctx, parsedIDs[0], parsedIDs[1]) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting host MDM profile") + } + if profile == nil { + // Return error that implements kithttp.StatusCoder interface + return nil, &scepserver.BadRequestError{Message: "unknown identifier in URL path"} + } + + client, err := scepclient.New(appConfig.Integrations.NDESSCEPProxy.Value.URL, svc.debugLogger) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating SCEP client") + } + res, err := client.PKIOperation(ctx, data) + if err != nil { + return res, ctxerr.Wrapf(ctx, err, + "Could not do PKIOperation on SCEP server %s", appConfig.Integrations.NDESSCEPProxy.Value.URL) + } + return res, nil +} + +func (svc *scepProxyService) GetNextCACert(ctx context.Context) ([]byte, error) { + // NDES on Windows Server 2022 does not support this, as advertised via GetCACaps + return nil, errors.New("GetNextCACert is not implemented for SCEP proxy") +} + +// NewSCEPProxyService creates a new scep proxy service +func NewSCEPProxyService(ds fleet.Datastore, logger log.Logger) scepserver.ServiceWithIdentifier { + return &scepProxyService{ + ds: ds, + debugLogger: logger, + } +} + +func ValidateNDESSCEPAdminURL(ctx context.Context, proxy fleet.NDESSCEPProxyIntegration) error { + _, err := GetNDESSCEPChallenge(ctx, proxy) + return err +} + +func GetNDESSCEPChallenge(ctx context.Context, proxy fleet.NDESSCEPProxyIntegration) (string, error) { + adminURL, username, password := proxy.AdminURL, proxy.Username, proxy.Password + // Get the challenge from NDES + client := fleethttp.NewClient() + client.Transport = ntlmssp.Negotiator{ + RoundTripper: fleethttp.NewTransport(), + } + req, err := http.NewRequest(http.MethodGet, adminURL, http.NoBody) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "creating request") + } + req.SetBasicAuth(username, password) + resp, err := client.Do(req) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "sending request") + } + if resp.StatusCode != http.StatusOK { + return "", ctxerr.Wrap(ctx, NDESInvalidError{msg: fmt.Sprintf( + "unexpected status code: %d; could not retrieve the enrollment challenge password; invalid admin URL or credentials; please correct and try again", + resp.StatusCode)}) + } + // Make a transformer that converts MS-Win default to UTF8: + win16be := unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM) + // Make a transformer that is like win16be, but abides by BOM: + utf16bom := unicode.BOMOverride(win16be.NewDecoder()) + + // Make a Reader that uses utf16bom: + unicodeReader := transform.NewReader(resp.Body, utf16bom) + bodyText, err := io.ReadAll(unicodeReader) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "reading response body") + } + htmlString := string(bodyText) + + matches := challengeRegex.FindStringSubmatch(htmlString) + challenge := "" + if matches != nil { + challenge = matches[challengeRegex.SubexpIndex("password")] + } + if challenge == "" { + if strings.Contains(htmlString, fullPasswordCache) { + return "", ctxerr.Wrap(ctx, + NDESPasswordCacheFullError{msg: "the password cache is full; please increase the number of cached passwords in NDES; by default, NDES caches 5 passwords and they expire 60 minutes after they are created"}) + } + return "", ctxerr.Wrap(ctx, + NDESInvalidError{msg: "could not retrieve the enrollment challenge password; invalid admin URL or credentials; please correct and try again"}) + } + return challenge, nil +} + +func ValidateNDESSCEPURL(ctx context.Context, proxy fleet.NDESSCEPProxyIntegration, logger log.Logger) error { + client, err := scepclient.New(proxy.URL, logger) + if err != nil { + return ctxerr.Wrap(ctx, err, "creating SCEP client; invalid SCEP URL; please correct and try again") + } + + certs, _, err := client.GetCACert(ctx, "") + if err != nil { + return ctxerr.Wrap(ctx, err, "could not retrieve CA certificate from SCEP URL; invalid SCEP URL; please correct and try again") + } + if len(certs) == 0 { + return ctxerr.New(ctx, "SCEP URL did not return a CA certificate") + } + return nil +} diff --git a/ee/server/service/scep_proxy_test.go b/ee/server/service/scep_proxy_test.go new file mode 100644 index 000000000000..9fe9f2a6c82f --- /dev/null +++ b/ee/server/service/scep_proxy_test.go @@ -0,0 +1,147 @@ +package service + +import ( + "context" + "crypto/x509" + "encoding/binary" + "net/http" + "net/http/httptest" + "os" + "syscall" + "testing" + "unicode/utf16" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" + filedepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot/file" + scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + kitlog "github.com/go-kit/log" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateNDESSCEPAdminURL(t *testing.T) { + t.Parallel() + + var returnPage func() []byte + returnStatus := http.StatusOK + ndesAdminServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(returnStatus) + if returnStatus == http.StatusOK { + _, err := w.Write(returnPage()) + require.NoError(t, err) + } + })) + t.Cleanup(ndesAdminServer.Close) + + proxy := fleet.NDESSCEPProxyIntegration{ + AdminURL: ndesAdminServer.URL, + Username: "admin", + Password: "password", + } + + returnStatus = http.StatusNotFound + err := ValidateNDESSCEPAdminURL(context.Background(), proxy) + assert.ErrorContains(t, err, "unexpected status code") + + // We need to convert the HTML page to UTF-16 encoding, which is used by Windows servers + returnPageFromFile := func(path string) []byte { + dat, err := os.ReadFile(path) + require.NoError(t, err) + datUTF16, err := utf16FromString(string(dat)) + require.NoError(t, err) + byteData := make([]byte, len(datUTF16)*2) + for i, v := range datUTF16 { + binary.BigEndian.PutUint16(byteData[i*2:], v) + } + return byteData + } + + // Catch ths issue when NDES password cache is full + returnStatus = http.StatusOK + returnPage = func() []byte { + return returnPageFromFile("./testdata/mscep_admin_cache_full.html") + } + err = ValidateNDESSCEPAdminURL(context.Background(), proxy) + assert.ErrorContains(t, err, "the password cache is full") + + // Nothing returned + returnPage = func() []byte { + return []byte{} + } + err = ValidateNDESSCEPAdminURL(context.Background(), proxy) + assert.ErrorContains(t, err, "could not retrieve the enrollment challenge password") + + // All good + returnPage = func() []byte { + return returnPageFromFile("./testdata/mscep_admin_password.html") + } + err = ValidateNDESSCEPAdminURL(context.Background(), proxy) + assert.NoError(t, err) +} + +func TestValidateNDESSCEPURL(t *testing.T) { + t.Parallel() + srv := newSCEPServer(t) + + proxy := fleet.NDESSCEPProxyIntegration{ + URL: srv.URL + "/scep", + } + err := ValidateNDESSCEPURL(context.Background(), proxy, kitlog.NewNopLogger()) + assert.NoError(t, err) + + proxy.URL = srv.URL + "/bozo" + err = ValidateNDESSCEPURL(context.Background(), proxy, kitlog.NewNopLogger()) + assert.ErrorContains(t, err, "could not retrieve CA certificate") + +} + +// utf16FromString returns the UTF-16 encoding of the UTF-8 string s, with a terminating NUL added. +// If s contains a NUL byte at any location, it returns (nil, syscall.EINVAL). +func utf16FromString(s string) ([]uint16, error) { + for i := 0; i < len(s); i++ { + if s[i] == 0 { + return nil, syscall.EINVAL + } + } + return utf16.Encode([]rune(s + "\x00")), nil +} + +func newSCEPServer(t *testing.T) *httptest.Server { + var err error + var certDepot depot.Depot // cert storage + depotPath := "./testdata/testca" + t.Cleanup(func() { + _ = os.Remove("./testdata/testca/serial") + _ = os.Remove("./testdata/testca/index.txt") + }) + certDepot, err = filedepot.NewFileDepot(depotPath) + if err != nil { + t.Fatal(err) + } + certDepot = &noopDepot{certDepot} + crt, key, err := certDepot.CA([]byte{}) + if err != nil { + t.Fatal(err) + } + var svc scepserver.Service // scep service + svc, err = scepserver.NewService(crt[0], key, scepserver.NopCSRSigner()) + if err != nil { + t.Fatal(err) + } + logger := kitlog.NewNopLogger() + e := scepserver.MakeServerEndpoints(svc) + scepHandler := scepserver.MakeHTTPHandler(e, svc, logger) + r := mux.NewRouter() + r.Handle("/scep", scepHandler) + server := httptest.NewServer(r) + t.Cleanup(server.Close) + return server +} + +type noopDepot struct{ depot.Depot } + +func (d *noopDepot) Put(_ string, _ *x509.Certificate) error { + return nil +} diff --git a/ee/server/service/testdata/mscep_admin_cache_full.html b/ee/server/service/testdata/mscep_admin_cache_full.html new file mode 100644 index 000000000000..9f87b8ac5847 --- /dev/null +++ b/ee/server/service/testdata/mscep_admin_cache_full.html @@ -0,0 +1 @@ +Network Device Enrollment Service
Network Device Enrollment Service

Network Device Enrollment Service allows you to obtain certificates for routers or other network devices using the Simple Certificate Enrollment Protocol (SCEP).

The password cache is full.

Network Device Enrollment Service stores unused password for later use. By default, passwords are stored for 60 minutes.

Use one of the existing passwords. If you cannot use an existing password:

  • Wait until one or more existing passwords expire (by default passwords expire 60 minutes after they are created).
  • Restart Internet Information Services (IIS).
  • Configure the service to cache more than 5 passwords.

For more information see Using Network Device Enrollment Service .

\ No newline at end of file diff --git a/ee/server/service/testdata/mscep_admin_password.html b/ee/server/service/testdata/mscep_admin_password.html new file mode 100644 index 000000000000..c2462ee90cc9 --- /dev/null +++ b/ee/server/service/testdata/mscep_admin_password.html @@ -0,0 +1 @@ +Network Device Enrollment Service
Network Device Enrollment Service

Network Device Enrollment Service allows you to obtain certificates for routers or other network devices using the Simple Certificate Enrollment Protocol (SCEP).

To complete certificate enrollment for your network device you will need the following information:

The thumbprint (hash value) for the CA certificate is: A656FA66 AB12B433 A2DA5CF7 CC153D9A

The enrollment challenge password is: 8CE317021F690069

This password can be used only once and will expire within 60 minutes.

Each enrollment requires a new challenge password. You can refresh this web page to obtain a new challenge password.

For more information see Using Network Device Enrollment Service .

\ No newline at end of file diff --git a/ee/server/service/testdata/testca/ca.key b/ee/server/service/testdata/testca/ca.key new file mode 100644 index 000000000000..1614d5541c9e --- /dev/null +++ b/ee/server/service/testdata/testca/ca.key @@ -0,0 +1,54 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: DES-EDE3-CBC,85186376af1462c4 + +jY1gAV22U2GeDZW0cVFw41uABS7fe7zKQen4aQQvkFJ5DPRlZFMI2qZ2MI1Oo265 +ek1Of/pcV52ct139NuVg9JZwkPPog40xUDn72IanZ2ZvJl/dcoHFn99T816Hu0p7 +YGKpvyyy3VYuxaarZV2aUNFye/o4bnAh4P0db6qa7/sGAKAhJ9PusNcSAXWWMz1l +QSCkdD30KrZOtf39StVnNSf2vPWAjAR/w3fEOVKqEehANP7yptDVOthiqrN+p58Q +kOGf3RnA5BJ21EY09W8rbgKE18EPI0UH9LVZEvRabZd4e/VSRNL6leNO7AiMLqA0 +3P7DAvjQDeTpYu3tZNWKFlmXv28KjwNo/4mclQTM8k5nkpQcLhGEVJanMlz0NAvZ ++BOgHGt1Vd8fXp5vBEMIz0tj7jJJnyEyd6tLRc7Pm7GaFjRy70rr/ZCO27HnKeWi +BVwFmZqG6Bcc1WvOGH/w549q/Xq1B3EgjSCShd7WQqINXMFOLJRg+MVZ9/EgWTrT +AEMkWwozb7hDJ56IdjE6PUop/5nbH87YjXHreV4kaGxgz3xD+sj6MGl8uw2JeT7b +6mFrK2d+704NU0Z56w8i0ydYeNn1uFmZqL9alYTDmAjOORXAR/ApvY6ctPXSpPTW +bXgv7LNWbcD8cNWuf/24dpI+kxbrIsKdGmucjQQ066Ce9qa2Kr7HEpH/CxxKCuBx +9KTHpb2ZZI1j6Zd9DQarEQm2D9fPaqEIq9XH46tNq8twXXEaYSTwwodSkwlovB5n +e4HlbiSuHB78ej2lQyFKquqWVYMRQ3dk5CUem/4XPF0L8dPvnifoQgBMpvJvzCG5 +BsIDQXKf0qLhQPrXwemhgY8fnZqDpuRTD6mdEXoPqvJC5L+3hzpPXHtCU94oqIbq +z4lkG1ARi9yS+WfUbXXZO95+7EBBg4lXzEZvXjqY6epUVjWCnoa873H9zfMZBuLL +XkxMQyDOnXaqYeqNsCahbdH4zuobR1SCNL4nt3iSaADaN6Lezwz8LPHxoM1kG1i6 +fvPa/uRo9aVsfWsovO+od2VmqLh1sfPZoOenZSKQsAVPYmEuV8XXVJ/B8NVvNTrt +DrfAR+vFe41liMHdTUndo8uG9/IO7JNC8u98zWjyvcr6cCukqE9H60Y9QvDgaSK5 +yD/D7B4ZAp6UjHOtD+jY1mjV+aL/2XeHJyQaDczHUKm1Vd5Um2c5f6NkZycrbtGE +7z5lR2SccnbbG6XVngYiZxdMLZCrQUnSfhke+zhzYM2Ng/7fxyz3mTyG5EYvxreU +6i0Psceh+vD9IEGHYbpRfV4Uozmk7AhEOfQN3ZDXZTA4LB5Svp7j3DcOmAGtCWGx +PWA3su4KzwrW40b5ommDhndPZNoSJfsrw2GJHV62AdfIxmAi5zvALJ31YdYvZsz2 +e8cf1Cl5oxeF/jgewEy6RTSkOUjvb0iTfVgreu4Tk/sBW37jdKhfW32INasCgEYb +0fq9DLXVcDk2neH/Sb78cE26JNXS3EtW1V4dvdkhvOjqRFP8O8vFggLi1mFQltAH +pmV143MSNkC/ikyOBahpQjGu89HZ0sLnJr2kzKf5LJTcN7kYAfxRejS7ofUByME1 +O9mrHOZGGNVNIgNesBXv42UEd0/SzwF4UKxHY72sEoTNLXliroaJORYbbvWw4GDI +91/vHKJMqMimoC37soS16wrsP/SabzusUXBayHD/PLkkmHBPV9++cO79b+HbVB0Q +6OpxBY7u6QhZnfTJv/W/InG404pumq8oz6bt7bXurbfC2QzviNHuyZ/IenbQ/y41 +K5URD3fdFYLC3OS38SSBBq32yncjJam0FOj2joUZ4iAAXSju1NSDskT8WbVy3BOq +tdTxekrxM9w98p17Og+Uf8966H2mQUIrz53Umc9V1974TVWdu0Y862ghJGSeLEbH +617VGwNN9hINdQE+iYaAVvbogEKSdCfljyVdIx1MuS1jeae5wUgReqqE+bopYgJm +oIXlVNI7tWX2y3JdG1vqCqKpq/UDzciLxAUdyGgwZESt9T3mvqQcdvWxsfREBGwX +XzbiDiGoom735dOOaGxvmyZUtJi7r5AonzJpR+qaRWoHNr8cqeU9be1wxBZ57Kln +2eKpwPIwdBTwxCjnc/kstuTsR45M8G37zOgh9XK38jS6FB/FzFytHtt9oPQBZBeb +3A6p7kqbbb4ynAgDiGEz9ExNNIQf3hQo9RAiaL2WeS9FTFB02hq5QgsgrGVXrR2V +45CKzP874sMPYP8xFQvmrMAXDy//zBXaOrNJHyNOVrtDLPerBNIC6GSKtFp30ynz +Te6GHFhcwqWfrN8N1l2oM79xvc2aKlsvI+YN0xTQklxqSdyCJSdhRUxmCIMN1JM0 +13Ean0HtO+z9u/nH3T2GtAhNySJAPAOXIAAER/74WNXNJNi7SmptNtWJOKKeKK3m +Jon7XC3Bx5NTnTM6UjrrXvwXvsJyf8G+SlkoZXZx9izgQYAANAsSblieSvPVppwM +/EfU6HIby2cBLQ1wTJiEDjYu7E1JKpAPBhqL0cN7aJea9tV7bmjoqzKhbwxACHkI +ymOZ1BDIF67M5fCLFCnCZEJcl2sgx4bRBaP6+p0uRWhplrus+8x1LAtNyB+V17in +nXacPqGELgqv+F6embq03retfaCbIwLwQYmaMU+QHg9jHc9j1AIf6fHSxhIRUPUz +PWMhy7dJdUcmm2GX2EGBrr7jH+H2y33W7y+0I2a4s5WdpIWsYUMFiBU+M+qJdAwY +O/n1Q8ZPdKdY9+c2RMzeO6Zvyc7f1hwoOy0FuYi748qaELV6rx1Tr2MDWl5/uhUa +vYMF4RshsKJY9OCUKvL9waqELZf4zEPyu875ZLm9eoJV2MFcokUuPcpAN+ljj6mx +S+1O9/kRioHo7FMs9rU3bHbCMbphLc0NdI363L/sM2kSFjRWxYv87z5fEQAoZGQR +d7HePVRbp09GC9Jk9p28F6ysgqS7PwlreRRp3Dj5vFJ422QviUWTP/jLj1QfukQR +0KXZhKhs0iSmfW9vlFnADS32l67fmycHMlN9yktvzcytm6dZ/XiQMHVDhZPlIGVC +frJ2R1MhmAdFEgIPZZGuoHeXFdlYq9HMpM9lbykJ1L7M36XqaW6GgRTnhf2g4iKJ +-----END RSA PRIVATE KEY----- diff --git a/ee/server/service/testdata/testca/ca.pem b/ee/server/service/testdata/testca/ca.pem new file mode 100644 index 000000000000..037b296cdee8 --- /dev/null +++ b/ee/server/service/testdata/testca/ca.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFODCCAyCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAtMQwwCgYDVQQGEwNVU0Ex +EDAOBgNVBAoTB2V0Y2QtY2ExCzAJBgNVBAsTAkNBMB4XDTE2MDUyOTEzNDcwNVoX +DTI2MDUyOTEzNDcwOFowLTEMMAoGA1UEBhMDVVNBMRAwDgYDVQQKEwdldGNkLWNh +MQswCQYDVQQLEwJDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALEG +S866Uf79znmx8+BakJ17tox8VYem0NZzPc2jF4RVWXfT481Yz9jdsjZubMCFuJiI +JzpMBT7RzXvZvuzMzZEe77Tb0mM+83t5kVwWWuxkEz7HQn0tWxuLR7NGaAi5MH53 +pcSGRNH8RgC7WdhyQ/3HwNGWObe0wQT69tfz1pHDSvNR9v7DS9KIiGsMc+dcqayz +n3YQuwEV8nD1KGenxEFjFh0NsP5FKrzDrsvzdFOWLJ3jedfDCSQSe0y33syZIYAQ +wS2/b+io6GMWDQemcirN9QiI1NGkcN9zioPRuYPxkaxGNa0O+3cTgA8egTFMigvI +4ZFsmERfZkJM4sBMK1uUmxXKb87nA1zooPvPk1KGQChXBEnrkHPbkP1VO+yYOS4m +t9LDweGVS6GoC5vjqQgymOHecaNfKpBnU6t7fP/aEZUF+6mxRKofolR/hTknkVNc +q2nrXEJpz8J73Iq8rkL0rNAEu1h83npPAoUgdFhwHzlq9ShRbz+ZQTxdAv5MOVs+ +6F9qcmbv/6C4xc1N1xH2NAJ8aFZTxsw4ny43hi7DgyRh1LJxcb2Bp7JMaD56CMSA +0zJqxIiV5kGUwbmrBjXMyvjYzx/0qI3j3bZl3p8BjZgyjkvOP0nArP3bby5mEUYx +i7+YgPm8dfGIzPh19I4oFReszOJl+JrdLnbf45efAgMBAAGjYzBhMA4GA1UdDwEB +/wQEAwICBDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBT6XD/PBaV7GbFEnxOm +3OJ3deamkzAfBgNVHSMEGDAWgBT6XD/PBaV7GbFEnxOm3OJ3deamkzANBgkqhkiG +9w0BAQsFAAOCAgEAC6yBHrRElZ7ovDrqjVBf8fLG+nINETPJ/kPTlTNtvqClLaeE +NKPH6JVp0/uusoKmqvE0LxyBEdP7waHQVq2XnfYggDCNjAUFxdv7OKAwlBjJ0JGs +5RsJ9DEehyLecnDDDhte92M2xUcfMet1BmuizLDDKaUU17sI1g/UNE+c7hViZA2J +e+wezVOUZqCY0pICsm4ar8JBY/pfUZ+1J00AZJtXuVWqK5GYGkrLZ7ZjNzzDF0cY +UmJxki5rj11XpCCQOZjVB+Pp3t7YpUOey1EC+1fKKrdS40zaRS3VVgh+Guavs5HV +egBzKDQUuRrZDbodJSv28RYlVbFTmkl3hGGNE0l2v0L2XHasZHoBkDZzz9nLuiI8 +ZdhWS+fn7dbswN9WzzB+dPzKS1WkTj5RXL/luI/7+fYNQyvIJYdnNCegyi2C2yTD +a/vmFJkBU+uLHWsW9a8R5Ca7A91ltJobTJE3uwxdXuZMTrmlWKsEbhqHCqO7d0j8 +IgYGxDo9ysfA4AOiNDxlp7lXxV/JFOsuGXNdFKcDFykLZ5u21X9ho9fptWJDP9JN +NNOXjC0Jv2UGZrHze6IqyL5JqxOGpK22PQIwpZwExwijUom+LH5VEXK1zpXzwC93 +WXWVtGOW4yEqv0VTn7vafIeM5GBTJ44ggpkp4RpFWoBMZcAFj8gE/9AUaHo= +-----END CERTIFICATE----- diff --git a/go.mod b/go.mod index 83670b64fdd1..144be92910eb 100644 --- a/go.mod +++ b/go.mod @@ -162,6 +162,7 @@ require ( github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/BurntSushi/toml v1.3.2 // indirect github.com/DisgoOrg/disgohook v1.4.3 // indirect github.com/DisgoOrg/log v1.1.0 // indirect diff --git a/go.sum b/go.sum index 8bf177e83697..fabc33d24932 100644 --- a/go.sum +++ b/go.sum @@ -145,6 +145,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= diff --git a/pkg/optjson/optjson.go b/pkg/optjson/optjson.go index dbc44dd5e7c0..1270a0bb2b0c 100644 --- a/pkg/optjson/optjson.go +++ b/pkg/optjson/optjson.go @@ -169,3 +169,38 @@ func (s *Slice[T]) UnmarshalJSON(data []byte) error { s.Valid = true return nil } + +type Any[T any] struct { + Set bool + Valid bool + Value T +} + +func (s Any[T]) MarshalJSON() ([]byte, error) { + if !s.Valid { + return []byte("null"), nil + } + return json.Marshal(s.Value) +} + +func (s *Any[T]) UnmarshalJSON(data []byte) error { + // If this method was called, the value was set. + s.Set = true + s.Valid = false + + if bytes.Equal(data, []byte("null")) { + // The key was set to null, set value to zero/default value + var zero T + s.Value = zero + return nil + } + + // The key isn't set to null + var v T + if err := json.Unmarshal(data, &v); err != nil { + return err + } + s.Value = v + s.Valid = true + return nil +} diff --git a/pkg/optjson/optjson_test.go b/pkg/optjson/optjson_test.go index 2a1d01e1433a..a8d18af450cf 100644 --- a/pkg/optjson/optjson_test.go +++ b/pkg/optjson/optjson_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -440,3 +441,45 @@ func TestSliceWithinStruct(t *testing.T) { } }) } + +func TestAny(t *testing.T) { + t.Parallel() + type Item struct { + ID int `json:"id"` + Name string `json:"name"` + } + SetItem := func(item Item) Any[Item] { + return Any[Item]{Set: true, Valid: true, Value: item} + } + + cases := []struct { + data string + wantErr string + wantRes Any[Item] + marshalAs string + }{ + {data: `{ "id": 1, "name": "bozo" }`, wantErr: "", wantRes: SetItem(Item{ID: 1, Name: "bozo"}), + marshalAs: `{"id":1,"name":"bozo"}`}, + {data: `null`, wantErr: "", wantRes: Any[Item]{Set: true, Valid: false}, marshalAs: `null`}, + {data: `[]`, wantErr: "cannot unmarshal array", wantRes: Any[Item]{Set: true, Valid: false, Value: Item{}}, marshalAs: `null`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var s Any[Item] + err := json.Unmarshal([]byte(c.data), &s) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, c.wantRes, s) + + b, err := json.Marshal(s) + require.NoError(t, err) + require.Equal(t, c.marshalAs, string(b)) + }) + } +} diff --git a/server/datastore/cached_mysql/cached_mysql.go b/server/datastore/cached_mysql/cached_mysql.go index 392199fab325..1292ed0dfde5 100644 --- a/server/datastore/cached_mysql/cached_mysql.go +++ b/server/datastore/cached_mysql/cached_mysql.go @@ -8,6 +8,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" "github.com/patrickmn/go-cache" ) @@ -403,7 +404,8 @@ func (ds *cachedMysql) ResultCountForQuery(ctx context.Context, queryID uint) (i return count, nil } -func (ds *cachedMysql) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { +func (ds *cachedMysql) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName, + queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { // always reach the database to get the latest hashes latestHashes, err := ds.Datastore.GetAllMDMConfigAssetsHashes(ctx, assetNames) if err != nil { @@ -434,7 +436,7 @@ func (ds *cachedMysql) GetAllMDMConfigAssetsByName(ctx context.Context, assetNam } // fetch missing assets from the database - assetMap, err := ds.Datastore.GetAllMDMConfigAssetsByName(ctx, missingAssets) + assetMap, err := ds.Datastore.GetAllMDMConfigAssetsByName(ctx, missingAssets, queryerContext) if err != nil { return nil, err } diff --git a/server/datastore/cached_mysql/cached_mysql_test.go b/server/datastore/cached_mysql/cached_mysql_test.go index 004f3360bfb2..bb41f514c6af 100644 --- a/server/datastore/cached_mysql/cached_mysql_test.go +++ b/server/datastore/cached_mysql/cached_mysql_test.go @@ -12,6 +12,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -772,7 +773,8 @@ func TestGetAllMDMConfigAssetsByName(t *testing.T) { return result, nil } - mockedDS.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + mockedDS.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { result := map[fleet.MDMAssetName]fleet.MDMConfigAsset{} for _, n := range assetNames { result[n] = assetMap[n] @@ -781,7 +783,7 @@ func TestGetAllMDMConfigAssetsByName(t *testing.T) { } // returns cached assets if hashes match - result, err := ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2"}) + result, err := ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2"}, nil) require.NoError(t, err) require.True(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked) @@ -792,7 +794,7 @@ func TestGetAllMDMConfigAssetsByName(t *testing.T) { require.Equal(t, assetMap["asset2"], result["asset2"]) require.NotContains(t, result, "asset3") - result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2"}) + result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2"}, nil) require.NoError(t, err) require.False(t, mockedDS.GetAllMDMConfigAssetsByNameFuncInvoked) require.True(t, mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked) @@ -805,7 +807,7 @@ func TestGetAllMDMConfigAssetsByName(t *testing.T) { mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false // fetches missing assets from the db - result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2", "asset3"}) + result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"asset1", "asset2", "asset3"}, nil) require.NoError(t, err) require.Equal(t, assetMap["asset1"], result["asset1"]) require.Equal(t, assetMap["asset2"], result["asset2"]) @@ -818,7 +820,7 @@ func TestGetAllMDMConfigAssetsByName(t *testing.T) { // fetches updated assets from the db assetHashes["asset1"] = "newhash" assetMap["asset1"] = fleet.MDMConfigAsset{Name: "asset1", Value: []byte("newvalue")} - result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), assetNames) + result, err = ds.GetAllMDMConfigAssetsByName(context.Background(), assetNames, nil) require.NoError(t, err) require.Equal(t, assetMap["asset1"], result["asset1"]) require.Equal(t, assetMap["asset2"], result["asset2"]) @@ -829,11 +831,12 @@ func TestGetAllMDMConfigAssetsByName(t *testing.T) { mockedDS.GetAllMDMConfigAssetsHashesFuncInvoked = false // passes errors fetching assets from downstream - mockedDS.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + mockedDS.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return nil, errors.New("error fetching assets") } - _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"not exists"}) + _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"not exists"}, nil) require.Error(t, err) require.Equal(t, "error fetching assets", err.Error()) @@ -842,7 +845,7 @@ func TestGetAllMDMConfigAssetsByName(t *testing.T) { return nil, errors.New("error fetching hashes") } - _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"not exists"}) + _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{"not exists"}, nil) require.Error(t, err) require.Equal(t, "error fetching hashes", err.Error()) } diff --git a/server/datastore/mysql/app_configs.go b/server/datastore/mysql/app_configs.go index 1b8bf9b4b4c6..298d81738c5f 100644 --- a/server/datastore/mysql/app_configs.go +++ b/server/datastore/mysql/app_configs.go @@ -1,6 +1,7 @@ package mysql import ( + "bytes" "context" "database/sql" "encoding/json" @@ -49,13 +50,29 @@ func appConfigDB(ctx context.Context, q sqlx.QueryerContext) (*fleet.AppConfig, } func (ds *Datastore) SaveAppConfig(ctx context.Context, info *fleet.AppConfig) error { - configBytes, err := json.Marshal(info) - if err != nil { - return ctxerr.Wrap(ctx, err, "marshaling config") - } - return ds.withTx(ctx, func(tx sqlx.ExtContext) error { - _, err := tx.ExecContext(ctx, + // Check if passwords need to be encrypted + if info.Integrations.NDESSCEPProxy.Valid { + if info.Integrations.NDESSCEPProxy.Set && + info.Integrations.NDESSCEPProxy.Value.Password != "" && + info.Integrations.NDESSCEPProxy.Value.Password != fleet.MaskedPassword { + err := ds.insertOrReplaceConfigAsset(ctx, tx, fleet.MDMConfigAsset{ + Name: fleet.MDMAssetNDESPassword, + Value: []byte(info.Integrations.NDESSCEPProxy.Value.Password), + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "processing NDES SCEP proxy password") + } + } + info.Integrations.NDESSCEPProxy.Value.Password = fleet.MaskedPassword + } + + configBytes, err := json.Marshal(info) + if err != nil { + return ctxerr.Wrap(ctx, err, "marshaling config") + } + + _, err = tx.ExecContext(ctx, `INSERT INTO app_config_json(json_value) VALUES(?) ON DUPLICATE KEY UPDATE json_value = VALUES(json_value)`, configBytes, ) @@ -67,6 +84,30 @@ func (ds *Datastore) SaveAppConfig(ctx context.Context, info *fleet.AppConfig) e }) } +func (ds *Datastore) insertOrReplaceConfigAsset(ctx context.Context, tx sqlx.ExtContext, asset fleet.MDMConfigAsset) error { + assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{asset.Name}, tx) + if err != nil { + if fleet.IsNotFound(err) { + return ds.InsertMDMConfigAssets(ctx, []fleet.MDMConfigAsset{asset}, tx) + } + return ctxerr.Wrap(ctx, err, "get all mdm config assets by name") + } + if len(assets) == 0 { + // Should never happen + return ctxerr.New(ctx, fmt.Sprintf("no asset found for name %s", asset.Name)) + } + currentAsset, ok := assets[asset.Name] + if !ok { + // Should never happen + return ctxerr.New(ctx, fmt.Sprintf("asset not found for name %s", asset.Name)) + } + if !bytes.Equal(currentAsset.Value, asset.Value) { + return ds.ReplaceMDMConfigAssets(ctx, []fleet.MDMConfigAsset{asset}, tx) + } + // asset already exists and is the same, so not need to update + return nil +} + func (ds *Datastore) VerifyEnrollSecret(ctx context.Context, secret string) (*fleet.EnrollSecret, error) { var s fleet.EnrollSecret err := sqlx.GetContext(ctx, ds.reader(ctx), &s, "SELECT team_id FROM enroll_secrets WHERE secret = ?", secret) diff --git a/server/datastore/mysql/app_configs_test.go b/server/datastore/mysql/app_configs_test.go index 5357860ebdca..0e8afb39ded7 100644 --- a/server/datastore/mysql/app_configs_test.go +++ b/server/datastore/mysql/app_configs_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/fleetdm/fleet/v4/pkg/optjson" + "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -35,6 +36,7 @@ func TestAppConfig(t *testing.T) { {"Backwards Compatibility", testAppConfigBackwardsCompatibility}, {"GetConfigEnableDiskEncryption", testGetConfigEnableDiskEncryption}, {"IsEnrollSecretAvailable", testIsEnrollSecretAvailable}, + {"NDESSCEPProxyPassword", testNDESSCEPProxyPassword}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -533,3 +535,76 @@ func testIsEnrollSecretAvailable(t *testing.T, ds *Datastore) { } } + +func testNDESSCEPProxyPassword(t *testing.T, ds *Datastore) { + ctx := context.Background() + ctx = ctxdb.BypassCachedMysql(ctx, true) + defer TruncateTables(t, ds) + + ac, err := ds.AppConfig(ctx) + require.NoError(t, err) + + adminURL := "https://localhost:8080/mscep_admin/" + username := "admin" + url := "https://localhost:8080/mscep/mscep.dll" + password := "password" + + ac.Integrations.NDESSCEPProxy = optjson.Any[fleet.NDESSCEPProxyIntegration]{ + Valid: true, + Set: true, + Value: fleet.NDESSCEPProxyIntegration{ + AdminURL: adminURL, + Username: username, + Password: password, + URL: url, + }, + } + + err = ds.SaveAppConfig(ctx, ac) + require.NoError(t, err) + + checkProxyConfig := func() { + result, err := ds.AppConfig(ctx) + require.NoError(t, err) + require.NotNil(t, result.Integrations.NDESSCEPProxy) + assert.Equal(t, url, result.Integrations.NDESSCEPProxy.Value.URL) + assert.Equal(t, adminURL, result.Integrations.NDESSCEPProxy.Value.AdminURL) + assert.Equal(t, username, result.Integrations.NDESSCEPProxy.Value.Username) + assert.Equal(t, fleet.MaskedPassword, result.Integrations.NDESSCEPProxy.Value.Password) + } + + checkProxyConfig() + + checkPassword := func() { + assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetNDESPassword}, nil) + require.NoError(t, err) + require.Len(t, assets, 1) + assert.Equal(t, password, string(assets[fleet.MDMAssetNDESPassword].Value)) + } + checkPassword() + + // Set password to masked password -- should not update + ac.Integrations.NDESSCEPProxy.Value.Password = fleet.MaskedPassword + err = ds.SaveAppConfig(ctx, ac) + require.NoError(t, err) + checkProxyConfig() + checkPassword() + + // Set password to empty -- password should not update + url = "https://newurl.com" + ac.Integrations.NDESSCEPProxy.Value.Password = "" + ac.Integrations.NDESSCEPProxy.Value.URL = url + err = ds.SaveAppConfig(ctx, ac) + require.NoError(t, err) + checkProxyConfig() + checkPassword() + + // Set password to a new value + password = "newpassword" + ac.Integrations.NDESSCEPProxy.Value.Password = password + err = ds.SaveAppConfig(ctx, ac) + require.NoError(t, err) + checkProxyConfig() + checkPassword() + +} diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index ad18242eb754..1bf9259ea7b3 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -455,6 +455,29 @@ WHERE return profiles, nil } +func (ds *Datastore) GetHostMDMAppleProfile(ctx context.Context, hostUUID string, profileUUID string) (*fleet.HostMDMAppleProfile, error) { + stmt := ` + SELECT + profile_uuid, + profile_name AS name, + profile_identifier AS identifier, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail + FROM + host_mdm_apple_profiles + WHERE + host_uuid = ? AND profile_uuid = ?` + var profile fleet.HostMDMAppleProfile + if err := sqlx.GetContext(ctx, ds.reader(ctx), &profile, stmt, hostUUID, profileUUID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &profile, nil +} + func (ds *Datastore) NewMDMAppleEnrollmentProfile( ctx context.Context, payload fleet.MDMAppleEnrollmentProfilePayload, @@ -4731,17 +4754,21 @@ func decrypt(encrypted []byte, privateKey string) ([]byte, error) { return decrypted, nil } -func (ds *Datastore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error { - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { +func (ds *Datastore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error { + insertFunc := func(tx sqlx.ExtContext) error { if err := insertMDMConfigAssets(ctx, tx, assets, ds.serverPrivateKey); err != nil { return ctxerr.Wrap(ctx, err, "insert mdm config assets") } - return nil - }) + } + if tx != nil { + return insertFunc(tx) + } + return ds.withRetryTxx(ctx, insertFunc) } -func (ds *Datastore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { +func (ds *Datastore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName, + queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { if len(assetNames) == 0 { return nil, nil } @@ -4762,7 +4789,10 @@ WHERE } var res []fleet.MDMConfigAsset - if err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil { + if queryerContext == nil { + queryerContext = ds.reader(ctx) + } + if err := sqlx.SelectContext(ctx, queryerContext, &res, stmt, args...); err != nil { return nil, ctxerr.Wrap(ctx, err, "get mdm config assets by name") } @@ -4833,6 +4863,15 @@ func (ds *Datastore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames }) } +func (ds *Datastore) HardDeleteMDMConfigAsset(ctx context.Context, assetName fleet.MDMAssetName) error { + stmt := ` +DELETE FROM mdm_config_assets +WHERE name = ?` + _, err := ds.writer(ctx).ExecContext(ctx, stmt, assetName) + // ctxerr.Wrap returns nil if err is nil + return ctxerr.Wrap(ctx, err, "hard delete mdm config asset") +} + func softDeleteMDMConfigAssetsByName(ctx context.Context, tx sqlx.ExtContext, assetNames []fleet.MDMAssetName) error { stmt := ` UPDATE @@ -4883,8 +4922,8 @@ VALUES return ctxerr.Wrap(ctx, err, "writing mdm config assets to db") } -func (ds *Datastore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error { - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { +func (ds *Datastore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error { + replaceFunc := func(tx sqlx.ExtContext) error { var names []fleet.MDMAssetName for _, a := range assets { names = append(names, a.Name) @@ -4897,9 +4936,12 @@ func (ds *Datastore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet. if err := insertMDMConfigAssets(ctx, tx, assets, ds.serverPrivateKey); err != nil { return ctxerr.Wrap(ctx, err, "upsert mdm config assets insert") } - return nil - }) + } + if tx != nil { + return replaceFunc(tx) + } + return ds.withRetryTxx(ctx, replaceFunc) } // ListIOSAndIPadOSToRefetch returns the UUIDs of iPhones/iPads that should be refetched diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 3090bdb04d61..3261b0b82ae1 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -5694,10 +5694,10 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { for _, a := range assets { wantAssets[a.Name] = a } - err := ds.InsertMDMConfigAssets(ctx, assets) + err := ds.InsertMDMConfigAssets(ctx, assets, nil) require.NoError(t, err) - a, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) + a, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}, nil) require.NoError(t, err) require.Equal(t, wantAssets, a) @@ -5709,7 +5709,7 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { // try to fetch an asset that doesn't exist var nfe fleet.NotFoundError - a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetABMCert}) + a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetABMCert}, ds.writer(ctx)) require.ErrorAs(t, err, &nfe) require.Nil(t, a) @@ -5718,7 +5718,7 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { require.Nil(t, h) // try to fetch a mix of assets that exist and doesn't exist - a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetABMCert}) + a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetABMCert}, nil) require.ErrorIs(t, err, ErrPartialResult) require.Len(t, a, 1) @@ -5745,10 +5745,10 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { wantNewAssets[a.Name] = a } - err = ds.ReplaceMDMConfigAssets(ctx, newAssets) + err = ds.ReplaceMDMConfigAssets(ctx, newAssets, nil) require.NoError(t, err) - a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) + a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}, ds.reader(ctx)) require.NoError(t, err) require.Equal(t, wantNewAssets, a) @@ -5763,7 +5763,7 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { err = ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) require.NoError(t, err) - a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) + a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}, nil) require.ErrorAs(t, err, &nfe) require.Nil(t, a) @@ -5802,6 +5802,22 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) { require.NotEmpty(t, got.DeletionUUID) require.NotEmpty(t, got.DeletedAt) } + + // Hard delete + err = ds.HardDeleteMDMConfigAsset(ctx, fleet.MDMAssetCACert) + require.NoError(t, err) + a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}, nil) + require.ErrorAs(t, err, &nfe) + require.Nil(t, a) + + var result bool + err = sqlx.GetContext(ctx, ds.reader(ctx), &result, "SELECT 1 FROM mdm_config_assets WHERE name = ?", fleet.MDMAssetCACert) + assert.ErrorIs(t, err, sql.ErrNoRows) + + // other (non-hard deleted asset still present) + err = sqlx.GetContext(ctx, ds.reader(ctx), &result, "SELECT 1 FROM mdm_config_assets WHERE name = ?", fleet.MDMAssetCAKey) + assert.NoError(t, err) + assert.True(t, result) } func testListIOSAndIPadOSToRefetch(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 6026dcd9b140..daf6681a3b42 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -3708,6 +3708,20 @@ func (ds *Datastore) SetOrUpdateHostEmailsFromMdmIdpAccounts( ) } +func (ds *Datastore) GetHostEmails(ctx context.Context, hostUUID string, source string) ([]string, error) { + stmt := ` + SELECT email + FROM host_emails he + JOIN hosts h ON h.id = he.host_id + WHERE h.uuid = ? AND he.source = ? + ` + var emails []string + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &emails, stmt, hostUUID, source); err != nil { + return nil, ctxerr.Wrap(ctx, err, "select host emails") + } + return emails, nil +} + // SetOrUpdateHostDisksSpace sets the available gigs and percentage of the // disks for the specified host. func (ds *Datastore) SetOrUpdateHostDisksSpace(ctx context.Context, hostID uint, gigsAvailable, percentAvailable, gigsTotal float64) error { diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 2edc5ab39353..ed3e5ec3d70f 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -169,6 +169,7 @@ func TestHosts(t *testing.T) { {"HostsAddToTeamCleansUpTeamQueryResults", testHostsAddToTeamCleansUpTeamQueryResults}, {"UpdateHostIssues", testUpdateHostIssues}, {"ListUpcomingHostMaintenanceWindows", testListUpcomingHostMaintenanceWindows}, + {"GetHostEmails", testGetHostEmails}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -9654,3 +9655,42 @@ func testListUpcomingHostMaintenanceWindows(t *testing.T, ds *Datastore) { require.Equal(t, startTime.Round(time.Second), mW.StartsAt) require.Equal(t, timeZone, *mW.TimeZone) } + +func testGetHostEmails(t *testing.T, ds *Datastore) { + ctx := context.Background() + host, err := ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("1"), + UUID: uuid.NewString(), + Hostname: "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + }) + require.NoError(t, err) + + emails, err := ds.GetHostEmails(ctx, host.UUID, fleet.DeviceMappingMDMIdpAccounts) + require.NoError(t, err) + assert.Empty(t, emails) + + err = ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{ + { + HostID: host.ID, + Email: "foo@example.com", + Source: fleet.DeviceMappingMDMIdpAccounts, + }, + { + HostID: host.ID, + Email: "bar@example.com", + Source: fleet.DeviceMappingMDMIdpAccounts, + }, + }, fleet.DeviceMappingMDMIdpAccounts) + require.NoError(t, err) + + emails, err = ds.GetHostEmails(ctx, host.UUID, fleet.DeviceMappingMDMIdpAccounts) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"foo@example.com", "bar@example.com"}, emails) + +} diff --git a/server/datastore/mysql/nanomdm_storage.go b/server/datastore/mysql/nanomdm_storage.go index e032cab539ed..ed5f99352c3d 100644 --- a/server/datastore/mysql/nanomdm_storage.go +++ b/server/datastore/mysql/nanomdm_storage.go @@ -132,8 +132,9 @@ func (s *NanoMDMStorage) EnqueueDeviceWipeCommand(ctx context.Context, host *fle }, s.logger) } -func (s *NanoMDMStorage) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { - return s.ds.GetAllMDMConfigAssetsByName(ctx, assetNames) +func (s *NanoMDMStorage) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName, + queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + return s.ds.GetAllMDMConfigAssetsByName(ctx, assetNames, queryerContext) } func (s *NanoMDMStorage) GetABMTokenByOrgName(ctx context.Context, orgName string) (*fleet.ABMToken, error) { diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 5755e67d0f0a..c45f4d3a3fc8 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -65,7 +65,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `calendar_events` ( diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index f3940eda31dc..051d508d19d2 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -706,7 +706,7 @@ func CreateABMKeyCertIfNotExists(t testing.TB, ds *Datastore) { var assets []fleet.MDMConfigAsset _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ fleet.MDMAssetABMKey, - }) + }, nil) if err != nil { var nfe fleet.NotFoundError require.ErrorAs(t, err, &nfe) @@ -715,7 +715,7 @@ func CreateABMKeyCertIfNotExists(t testing.TB, ds *Datastore) { _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ fleet.MDMAssetABMCert, - }) + }, nil) if err != nil { var nfe fleet.NotFoundError require.ErrorAs(t, err, &nfe) @@ -723,7 +723,7 @@ func CreateABMKeyCertIfNotExists(t testing.TB, ds *Datastore) { } if len(assets) != 0 { - err = ds.InsertMDMConfigAssets(context.Background(), assets) + err = ds.InsertMDMConfigAssets(context.Background(), assets, ds.writer(context.Background())) require.NoError(t, err) } } @@ -733,7 +733,7 @@ func CreateAndSetABMToken(t testing.TB, ds *Datastore, orgName string) *fleet.AB assets, err := ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ fleet.MDMAssetABMKey, fleet.MDMAssetABMCert, - }) + }, nil) require.NoError(t, err) certPEM := assets[fleet.MDMAssetABMCert].Value @@ -791,7 +791,7 @@ func SetTestABMAssets(t testing.TB, ds *Datastore, orgName string) *fleet.ABMTok {Name: fleet.MDMAssetCAKey, Value: keyPEM}, } - err = ds.InsertMDMConfigAssets(context.Background(), assets) + err = ds.InsertMDMConfigAssets(context.Background(), assets, nil) require.NoError(t, err) tok, err := ds.InsertABMToken(context.Background(), &fleet.ABMToken{EncryptedToken: tokenBytes, OrganizationName: orgName}) diff --git a/server/fleet/app.go b/server/fleet/app.go index 603ba2e0ab88..c56c3bebfa0a 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -520,6 +520,9 @@ func (c *AppConfig) Obfuscate() { for _, zdIntegration := range c.Integrations.Zendesk { zdIntegration.APIToken = MaskedPassword } + if c.Integrations.NDESSCEPProxy.Valid { + c.Integrations.NDESSCEPProxy.Value.Password = MaskedPassword + } } // Clone implements cloner. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 99b2cdb7d27c..3a21cae96c20 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -15,6 +15,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" + "github.com/jmoiron/sqlx" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" ) @@ -885,6 +886,8 @@ type Datastore interface { // SetOrUpdateHostEmailsFromMdmIdpAccounts sets or updates the host emails associated with the provided // host based on the MDM IdP account information associated with the provided fleet enrollment reference. SetOrUpdateHostEmailsFromMdmIdpAccounts(ctx context.Context, hostID uint, fleetEnrollmentRef string) error + // GetHostEmails returns the emails associated with the provided host for a given source, such as "google_chrome_profiles" + GetHostEmails(ctx context.Context, hostUUID string, source string) ([]string, error) SetOrUpdateHostDisksSpace(ctx context.Context, hostID uint, gigsAvailable, percentAvailable, gigsTotal float64) error SetOrUpdateHostDisksEncryption(ctx context.Context, hostID uint, encrypted bool) error // SetOrUpdateHostDiskEncryptionKey sets the base64, encrypted key for @@ -1060,6 +1063,10 @@ type Datastore interface { // GetHostMDMAppleProfiles returns the MDM profile information for the specified host UUID. GetHostMDMAppleProfiles(ctx context.Context, hostUUID string) ([]HostMDMAppleProfile, error) + // GetHostMDMAppleProfile returns the MDM profile information for the specified host UUID and profile UUID. + // nil is returned if the profile is not found. + GetHostMDMAppleProfile(ctx context.Context, hostUUID string, profileUUID string) (*HostMDMAppleProfile, error) + CleanupDiskEncryptionKeysOnTeamChange(ctx context.Context, hostIDs []uint, newTeamID *uint) error // NewMDMAppleEnrollmentProfile creates and returns new enrollment profile. @@ -1334,12 +1341,15 @@ type Datastore interface { GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, hostSerial string) (*AppleOSUpdateSettings, error) // InsertMDMConfigAssets inserts MDM related config assets, such as SCEP and APNS certs and keys. - InsertMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset) error + // tx is optional and can be used to pass an existing transaction. + InsertMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset, tx sqlx.ExtContext) error // GetAllMDMConfigAssetsByName returns the requested config assets. // - // If it doesn't find all the assets requested, it returns a `mysql.ErrPartialResult` - GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) (map[MDMAssetName]MDMConfigAsset, error) + // If it doesn't find all the assets requested, it returns a `mysql.ErrPartialResult` error. + // The queryerContext is optional and can be used to pass a transaction. + GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName, + queryerContext sqlx.QueryerContext) (map[MDMAssetName]MDMConfigAsset, error) // GetAllMDMConfigAssetsHashes behaves like // GetAllMDMConfigAssetsByName, but only returns a sha256 checksum of @@ -1351,10 +1361,14 @@ type Datastore interface { // DeleteMDMConfigAssetsByName soft deletes the given MDM config assets. DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) error + // HardDeleteMDMConfigAsset permanently deletes the given MDM config asset. + HardDeleteMDMConfigAsset(ctx context.Context, assetName MDMAssetName) error + // ReplaceMDMConfigAssets replaces (soft delete if they exist + insert) `MDMConfigAsset`s in a // single transaction. Useful for "renew" flows where users are updating the assets with newly // generated ones. - ReplaceMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset) error + // tx parameter is optional and can be used to pass an existing transaction. + ReplaceMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset, tx sqlx.ExtContext) error // GetABMTokenByOrgName retrieves the Apple Business Manager token identified by // its unique name (the organization name). @@ -1738,7 +1752,8 @@ type MDMAppleStore interface { } type MDMAssetRetriever interface { - GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) (map[MDMAssetName]MDMConfigAsset, error) + GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName, + queryerContext sqlx.QueryerContext) (map[MDMAssetName]MDMConfigAsset, error) GetABMTokenByOrgName(ctx context.Context, orgName string) (*ABMToken, error) } diff --git a/server/fleet/integrations.go b/server/fleet/integrations.go index a4fab4c0f749..887db9a54ee2 100644 --- a/server/fleet/integrations.go +++ b/server/fleet/integrations.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/service/externalsvc" ) @@ -352,11 +353,21 @@ type GoogleCalendarIntegration struct { ApiKey map[string]string `json:"api_key_json"` } +// NDESSCEPProxyIntegration configures SCEP proxy for NDES SCEP server. Premium feature. +type NDESSCEPProxyIntegration struct { + URL string `json:"url"` + AdminURL string `json:"admin_url"` + Username string `json:"username"` + Password string `json:"password"` // not stored here -- encrypted in DB +} + // Integrations configures the integrations with external systems. type Integrations struct { Jira []*JiraIntegration `json:"jira"` Zendesk []*ZendeskIntegration `json:"zendesk"` GoogleCalendar []*GoogleCalendarIntegration `json:"google_calendar"` + // NDESSCEPProxy settings. In JSON, not specifying this field means keep current setting, null means clear settings. + NDESSCEPProxy optjson.Any[NDESSCEPProxyIntegration] `json:"ndes_scep_proxy"` } func ValidateEnabledActivitiesWebhook(webhook ActivitiesWebhookSettings, invalid *InvalidArgumentError) { diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 0e5cf329717c..f5afed1e45e2 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -676,6 +676,9 @@ const ( // Deprecated: VPP tokens are now stored in the vpp_tokens table, they are // not in mdm_config_assets anymore. MDMAssetVPPTokenDeprecated MDMAssetName = "vpp_token" + // MDMAssetNDESPassword is the password used to retrieve SCEP challenge from + // NDES SCEP server. It is used by Fleet's SCEP proxy. + MDMAssetNDESPassword MDMAssetName = "ndes_password" ) type MDMConfigAsset struct { diff --git a/server/fleet/utils.go b/server/fleet/utils.go index 7095585d6ebf..ddab26e8e797 100644 --- a/server/fleet/utils.go +++ b/server/fleet/utils.go @@ -4,8 +4,10 @@ import ( "encoding/json" "errors" "io" + "strings" "github.com/fatih/color" + "golang.org/x/text/unicode/norm" ) func WriteExpiredLicenseBanner(w io.Writer) { @@ -56,3 +58,10 @@ func JSONStrictDecode(r io.Reader, v interface{}) error { return nil } + +func Preprocess(input string) string { + // Remove leading/trailing whitespace. + input = strings.TrimSpace(input) + // Normalize Unicode characters. + return norm.NFC.String(input) +} diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 81ec4c8f52f6..2361c474cb66 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -49,6 +49,9 @@ const ( // FleetPayloadIdentifier is the value for the "PayloadIdentifier" // used by Fleet MDM on the enrollment profile. FleetPayloadIdentifier = "com.fleetdm.fleet.mdm.apple" + + // SCEPProxyPath is the HTTP path that serves the SCEP proxy service. The path is followed by identifier. + SCEPProxyPath = "/mdm/scep/proxy/" ) func ResolveAppleMDMURL(serverURL string) (string, error) { diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 29138179f40b..6f63d1054003 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -18,6 +18,7 @@ import ( svcmock "github.com/fleetdm/fleet/v4/server/service/mock" "github.com/google/uuid" "github.com/groob/plist" + "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" @@ -75,7 +76,8 @@ func TestMDMAppleCommander(t *testing.T) { mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) { return false, nil } - mdmStorage.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + mdmStorage.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { certPEM, err := os.ReadFile("../../service/testdata/server.pem") require.NoError(t, err) keyPEM, err := os.ReadFile("../../service/testdata/server.key") diff --git a/server/mdm/assets/assets.go b/server/mdm/assets/assets.go index af303b65ce47..218b3132fc61 100644 --- a/server/mdm/assets/assets.go +++ b/server/mdm/assets/assets.go @@ -26,7 +26,7 @@ func KeyPair(ctx context.Context, ds fleet.MDMAssetRetriever, certName, keyName assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ certName, keyName, - }) + }, nil) if err != nil { return nil, fmt.Errorf("loading %s, %s keypair from the database: %w", certName, keyName, err) } @@ -45,7 +45,7 @@ func KeyPair(ctx context.Context, ds fleet.MDMAssetRetriever, certName, keyName } func X509Cert(ctx context.Context, ds fleet.MDMAssetRetriever, certName fleet.MDMAssetName) (*x509.Certificate, error) { - assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{certName}) + assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{certName}, nil) if err != nil { return nil, fmt.Errorf("loading certificate %s from the database: %w", certName, err) } @@ -76,7 +76,7 @@ func ABMToken(ctx context.Context, ds fleet.MDMAssetRetriever, abmOrgName string assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetABMKey, fleet.MDMAssetABMCert, - }) + }, nil) if err != nil { return nil, fmt.Errorf("loading ABM assets from the database: %w", err) } diff --git a/server/mdm/assets/assets_test.go b/server/mdm/assets/assets_test.go index b6484fd4b003..9e12ea074fda 100644 --- a/server/mdm/assets/assets_test.go +++ b/server/mdm/assets/assets_test.go @@ -18,6 +18,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/jmoiron/sqlx" "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" ) @@ -70,7 +71,8 @@ func TestCAKeyPair(t *testing.T) { fleet.MDMAssetCAKey: {Value: keyPEM}, } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { require.ElementsMatch(t, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}, assetNames) return assets, nil } @@ -92,7 +94,8 @@ func TestAPNSKeyPair(t *testing.T) { fleet.MDMAssetAPNSCert: {Value: certPEM}, fleet.MDMAssetAPNSKey: {Value: keyPEM}, } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { require.ElementsMatch(t, []fleet.MDMAssetName{fleet.MDMAssetAPNSCert, fleet.MDMAssetAPNSKey}, assetNames) return assets, nil } @@ -112,7 +115,8 @@ func TestX509Cert(t *testing.T) { assets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetAPNSCert: {Value: certPEM}, } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { require.ElementsMatch(t, []fleet.MDMAssetName{fleet.MDMAssetAPNSCert}, assetNames) return assets, nil } @@ -133,7 +137,8 @@ func TestAPNSTopic(t *testing.T) { assets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetAPNSCert: {Value: certPEM}, } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { require.ElementsMatch(t, []fleet.MDMAssetName{fleet.MDMAssetAPNSCert}, assetNames) return assets, nil } @@ -188,7 +193,8 @@ func TestABMToken(t *testing.T) { fleet.MDMAssetABMCert: {Value: certPEM}, fleet.MDMAssetABMKey: {Value: keyPEM}, } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { require.ElementsMatch(t, []fleet.MDMAssetName{ fleet.MDMAssetABMCert, fleet.MDMAssetABMKey, diff --git a/server/mdm/crypto/scep_test.go b/server/mdm/crypto/scep_test.go index 179864b9d3ff..b59bc62e8247 100644 --- a/server/mdm/crypto/scep_test.go +++ b/server/mdm/crypto/scep_test.go @@ -15,6 +15,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -76,7 +77,8 @@ func TestVerify(t *testing.T) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { if tt.rootCert == nil { return nil, errors.New("test error") } diff --git a/server/mdm/crypto/sign_test.go b/server/mdm/crypto/sign_test.go index 7a46eb71ba2f..bf8935550cc3 100644 --- a/server/mdm/crypto/sign_test.go +++ b/server/mdm/crypto/sign_test.go @@ -7,6 +7,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -43,7 +44,8 @@ func TestSign(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { require.ElementsMatch(t, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}, assetNames) return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Value: tc.cert}, diff --git a/server/mdm/scep/server/endpoint.go b/server/mdm/scep/server/endpoint.go index e865a3341653..58163fbce039 100644 --- a/server/mdm/scep/server/endpoint.go +++ b/server/mdm/scep/server/endpoint.go @@ -101,6 +101,14 @@ func MakeServerEndpoints(svc Service) *Endpoints { } } +func MakeServerEndpointsWithIdentifier(svc ServiceWithIdentifier) *Endpoints { + e := MakeSCEPEndpointWithIdentifier(svc) + return &Endpoints{ + GetEndpoint: e, + PostEndpoint: e, + } +} + // MakeClientEndpoints returns an Endpoints struct where each endpoint invokes // the corresponding method on the remote instance, via a transport/http.Client. // Useful in a SCEP client. @@ -157,6 +165,30 @@ type SCEPRequest struct { func (r SCEPRequest) scepOperation() string { return r.Operation } +func MakeSCEPEndpointWithIdentifier(svc ServiceWithIdentifier) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + req := request.(SCEPRequestWithIdentifier) + resp := SCEPResponse{operation: req.Operation} + switch req.Operation { + case "GetCACaps": + resp.Data, resp.Err = svc.GetCACaps(ctx) + case "GetCACert": + resp.Data, resp.CACertNum, resp.Err = svc.GetCACert(ctx, string(req.Message)) + case "PKIOperation": + resp.Data, resp.Err = svc.PKIOperation(ctx, req.Message, req.Identifier) + default: + return nil, &BadRequestError{Message: "operation not implemented"} + } + return resp, resp.Err + } +} + +// SCEPRequestWithIdentifier is a SCEP server request. +type SCEPRequestWithIdentifier struct { + SCEPRequest + Identifier string `url:"identifier"` +} + // SCEPResponse is a SCEP server response. // Business errors will be encoded as a CertRep message // with pkiStatus FAILURE and a failInfo attribute. diff --git a/server/mdm/scep/server/service.go b/server/mdm/scep/server/service.go index d387f3f39cce..e7bbbcb4c2b9 100644 --- a/server/mdm/scep/server/service.go +++ b/server/mdm/scep/server/service.go @@ -32,6 +32,28 @@ type Service interface { GetNextCACert(ctx context.Context) ([]byte, error) } +// ServiceWithIdentifier is the interface for all supported SCEP server operations. +type ServiceWithIdentifier interface { + // GetCACaps returns a list of options + // which are supported by the server. + GetCACaps(ctx context.Context) ([]byte, error) + + // GetCACert returns CA certificate or + // a CA certificate chain with intermediates + // in a PKCS#7 Degenerate Certificates format + // message is an optional string for the CA + GetCACert(ctx context.Context, message string) ([]byte, int, error) + + // PKIOperation handles incoming SCEP messages such as PKCSReq and + // sends back a CertRep PKIMessag. + PKIOperation(ctx context.Context, msg []byte, identifier string) ([]byte, error) + + // GetNextCACert returns a replacement certificate or certificate chain + // when the old one expires. The response format is a PKCS#7 Degenerate + // Certificates type. + GetNextCACert(ctx context.Context) ([]byte, error) +} + type service struct { // The service certificate and key for SCEP exchanges. These are // quite likely the same as the CA keypair but may be its own SCEP @@ -52,8 +74,10 @@ type service struct { debugLogger log.Logger } +const DefaultCACaps = "Renewal\nSHA-1\nSHA-256\nAES\nDES3\nSCEPStandard\nPOSTPKIOperation" + func (svc *service) GetCACaps(ctx context.Context) ([]byte, error) { - defaultCaps := []byte("Renewal\nSHA-1\nSHA-256\nAES\nDES3\nSCEPStandard\nPOSTPKIOperation") + defaultCaps := []byte(DefaultCACaps) return defaultCaps, nil } diff --git a/server/mdm/scep/server/transport.go b/server/mdm/scep/server/transport.go index 69f704e3c8a5..d31e0a824c03 100644 --- a/server/mdm/scep/server/transport.go +++ b/server/mdm/scep/server/transport.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" + "github.com/go-kit/kit/transport" kithttp "github.com/go-kit/kit/transport/http" kitlog "github.com/go-kit/log" "github.com/gorilla/mux" @@ -40,6 +41,29 @@ func MakeHTTPHandler(e *Endpoints, svc Service, logger kitlog.Logger) http.Handl return r } +func MakeHTTPHandlerWithIdentifier(e *Endpoints, rootPath string, logger kitlog.Logger) http.Handler { + opts := []kithttp.ServerOption{ + kithttp.ServerErrorHandler(transport.NewLogErrorHandler(logger)), + kithttp.ServerFinalizer(logutil.NewHTTPLogger(logger).LoggingFinalizer), + } + + r := mux.NewRouter() + r.Path(rootPath + "{identifier}").Methods("GET").Handler(kithttp.NewServer( + e.GetEndpoint, + decodeSCEPRequestWithIdentifier, + encodeSCEPResponse, + opts..., + )) + r.Path(rootPath + "{identifier}").Methods("POST").Handler(kithttp.NewServer( + e.PostEndpoint, + decodeSCEPRequestWithIdentifier, + encodeSCEPResponse, + opts..., + )) + + return r +} + // EncodeSCEPRequest encodes a SCEP HTTP Request. Used by the client. func EncodeSCEPRequest(ctx context.Context, r *http.Request, request interface{}) error { req := request.(SCEPRequest) @@ -98,6 +122,30 @@ func decodeSCEPRequest(ctx context.Context, r *http.Request) (interface{}, error return request, nil } +func decodeSCEPRequestWithIdentifier(_ context.Context, r *http.Request) (interface{}, error) { + msg, err := message(r) + if err != nil { + return nil, err + } + defer r.Body.Close() + + operation := r.URL.Query().Get("operation") + identifier := mux.Vars(r)["identifier"] + if len(operation) == 0 { + return nil, &BadRequestError{Message: "missing operation"} + } + + request := SCEPRequestWithIdentifier{ + SCEPRequest: SCEPRequest{ + Message: msg, + Operation: operation, + }, + Identifier: identifier, + } + + return request, nil +} + // extract message from request func message(r *http.Request) ([]byte, error) { switch r.Method { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index a592559bdf5a..14c3ba040b4b 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -16,6 +16,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/jmoiron/sqlx" ) var _ fleet.Datastore = (*DataStore)(nil) @@ -622,6 +623,8 @@ type UpdateMDMDataFunc func(ctx context.Context, hostID uint, enrolled bool) err type SetOrUpdateHostEmailsFromMdmIdpAccountsFunc func(ctx context.Context, hostID uint, fleetEnrollmentRef string) error +type GetHostEmailsFunc func(ctx context.Context, hostUUID string, source string) ([]string, error) + type SetOrUpdateHostDisksSpaceFunc func(ctx context.Context, hostID uint, gigsAvailable float64, percentAvailable float64, gigsTotal float64) error type SetOrUpdateHostDisksEncryptionFunc func(ctx context.Context, hostID uint, encrypted bool) error @@ -726,6 +729,8 @@ type DeleteMDMAppleConfigProfileByTeamAndIdentifierFunc func(ctx context.Context type GetHostMDMAppleProfilesFunc func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) +type GetHostMDMAppleProfileFunc func(ctx context.Context, hostUUID string, profileUUID string) (*fleet.HostMDMAppleProfile, error) + type CleanupDiskEncryptionKeysOnTeamChangeFunc func(ctx context.Context, hostIDs []uint, newTeamID *uint) error type NewMDMAppleEnrollmentProfileFunc func(ctx context.Context, enrollmentPayload fleet.MDMAppleEnrollmentProfilePayload) (*fleet.MDMAppleEnrollmentProfile, error) @@ -872,15 +877,17 @@ type MDMAppleSetPendingDeclarationsAsFunc func(ctx context.Context, hostUUID str type GetMDMAppleOSUpdatesSettingsByHostSerialFunc func(ctx context.Context, hostSerial string) (*fleet.AppleOSUpdateSettings, error) -type InsertMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset) error +type InsertMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error -type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) +type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) type GetAllMDMConfigAssetsHashesFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) type DeleteMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) error -type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset) error +type HardDeleteMDMConfigAssetFunc func(ctx context.Context, assetName fleet.MDMAssetName) error + +type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error type GetABMTokenByOrgNameFunc func(ctx context.Context, orgName string) (*fleet.ABMToken, error) @@ -1994,6 +2001,9 @@ type DataStore struct { SetOrUpdateHostEmailsFromMdmIdpAccountsFunc SetOrUpdateHostEmailsFromMdmIdpAccountsFunc SetOrUpdateHostEmailsFromMdmIdpAccountsFuncInvoked bool + GetHostEmailsFunc GetHostEmailsFunc + GetHostEmailsFuncInvoked bool + SetOrUpdateHostDisksSpaceFunc SetOrUpdateHostDisksSpaceFunc SetOrUpdateHostDisksSpaceFuncInvoked bool @@ -2150,6 +2160,9 @@ type DataStore struct { GetHostMDMAppleProfilesFunc GetHostMDMAppleProfilesFunc GetHostMDMAppleProfilesFuncInvoked bool + GetHostMDMAppleProfileFunc GetHostMDMAppleProfileFunc + GetHostMDMAppleProfileFuncInvoked bool + CleanupDiskEncryptionKeysOnTeamChangeFunc CleanupDiskEncryptionKeysOnTeamChangeFunc CleanupDiskEncryptionKeysOnTeamChangeFuncInvoked bool @@ -2381,6 +2394,9 @@ type DataStore struct { DeleteMDMConfigAssetsByNameFunc DeleteMDMConfigAssetsByNameFunc DeleteMDMConfigAssetsByNameFuncInvoked bool + HardDeleteMDMConfigAssetFunc HardDeleteMDMConfigAssetFunc + HardDeleteMDMConfigAssetFuncInvoked bool + ReplaceMDMConfigAssetsFunc ReplaceMDMConfigAssetsFunc ReplaceMDMConfigAssetsFuncInvoked bool @@ -4806,6 +4822,13 @@ func (s *DataStore) SetOrUpdateHostEmailsFromMdmIdpAccounts(ctx context.Context, return s.SetOrUpdateHostEmailsFromMdmIdpAccountsFunc(ctx, hostID, fleetEnrollmentRef) } +func (s *DataStore) GetHostEmails(ctx context.Context, hostUUID string, source string) ([]string, error) { + s.mu.Lock() + s.GetHostEmailsFuncInvoked = true + s.mu.Unlock() + return s.GetHostEmailsFunc(ctx, hostUUID, source) +} + func (s *DataStore) SetOrUpdateHostDisksSpace(ctx context.Context, hostID uint, gigsAvailable float64, percentAvailable float64, gigsTotal float64) error { s.mu.Lock() s.SetOrUpdateHostDisksSpaceFuncInvoked = true @@ -5170,6 +5193,13 @@ func (s *DataStore) GetHostMDMAppleProfiles(ctx context.Context, hostUUID string return s.GetHostMDMAppleProfilesFunc(ctx, hostUUID) } +func (s *DataStore) GetHostMDMAppleProfile(ctx context.Context, hostUUID string, profileUUID string) (*fleet.HostMDMAppleProfile, error) { + s.mu.Lock() + s.GetHostMDMAppleProfileFuncInvoked = true + s.mu.Unlock() + return s.GetHostMDMAppleProfileFunc(ctx, hostUUID, profileUUID) +} + func (s *DataStore) CleanupDiskEncryptionKeysOnTeamChange(ctx context.Context, hostIDs []uint, newTeamID *uint) error { s.mu.Lock() s.CleanupDiskEncryptionKeysOnTeamChangeFuncInvoked = true @@ -5681,18 +5711,18 @@ func (s *DataStore) GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context return s.GetMDMAppleOSUpdatesSettingsByHostSerialFunc(ctx, hostSerial) } -func (s *DataStore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error { +func (s *DataStore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error { s.mu.Lock() s.InsertMDMConfigAssetsFuncInvoked = true s.mu.Unlock() - return s.InsertMDMConfigAssetsFunc(ctx, assets) + return s.InsertMDMConfigAssetsFunc(ctx, assets, tx) } -func (s *DataStore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { +func (s *DataStore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { s.mu.Lock() s.GetAllMDMConfigAssetsByNameFuncInvoked = true s.mu.Unlock() - return s.GetAllMDMConfigAssetsByNameFunc(ctx, assetNames) + return s.GetAllMDMConfigAssetsByNameFunc(ctx, assetNames, queryerContext) } func (s *DataStore) GetAllMDMConfigAssetsHashes(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) { @@ -5709,11 +5739,18 @@ func (s *DataStore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames return s.DeleteMDMConfigAssetsByNameFunc(ctx, assetNames) } -func (s *DataStore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error { +func (s *DataStore) HardDeleteMDMConfigAsset(ctx context.Context, assetName fleet.MDMAssetName) error { + s.mu.Lock() + s.HardDeleteMDMConfigAssetFuncInvoked = true + s.mu.Unlock() + return s.HardDeleteMDMConfigAssetFunc(ctx, assetName) +} + +func (s *DataStore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset, tx sqlx.ExtContext) error { s.mu.Lock() s.ReplaceMDMConfigAssetsFuncInvoked = true s.mu.Unlock() - return s.ReplaceMDMConfigAssetsFunc(ctx, assets) + return s.ReplaceMDMConfigAssetsFunc(ctx, assets, tx) } func (s *DataStore) GetABMTokenByOrgName(ctx context.Context, orgName string) (*fleet.ABMToken, error) { diff --git a/server/mock/mdm/datastore_mdm_mock.go b/server/mock/mdm/datastore_mdm_mock.go index 78141b25253f..5e3f19e6cbe2 100644 --- a/server/mock/mdm/datastore_mdm_mock.go +++ b/server/mock/mdm/datastore_mdm_mock.go @@ -10,6 +10,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/jmoiron/sqlx" ) var _ fleet.MDMAppleStore = (*MDMAppleStore)(nil) @@ -54,7 +55,7 @@ type RetrieveMigrationCheckinsFunc func(p0 context.Context, p1 chan<- interface{ type RetrieveTokenUpdateTallyFunc func(ctx context.Context, id string) (int, error) -type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) +type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) type GetABMTokenByOrgNameFunc func(ctx context.Context, orgName string) (*fleet.ABMToken, error) @@ -278,11 +279,11 @@ func (fs *MDMAppleStore) RetrieveTokenUpdateTally(ctx context.Context, id string return fs.RetrieveTokenUpdateTallyFunc(ctx, id) } -func (fs *MDMAppleStore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { +func (fs *MDMAppleStore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { fs.mu.Lock() fs.GetAllMDMConfigAssetsByNameFuncInvoked = true fs.mu.Unlock() - return fs.GetAllMDMConfigAssetsByNameFunc(ctx, assetNames) + return fs.GetAllMDMConfigAssetsByNameFunc(ctx, assetNames, queryerContext) } func (fs *MDMAppleStore) GetABMTokenByOrgName(ctx context.Context, orgName string) (*fleet.ABMToken, error) { diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 22998d24d601..70645872679e 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/server/authz" @@ -28,6 +29,10 @@ import ( "golang.org/x/text/unicode/norm" ) +// Functions that can be overwritten in tests +var validateNDESSCEPAdminURL = eeservice.ValidateNDESSCEPAdminURL +var validateNDESSCEPURL = eeservice.ValidateNDESSCEPURL + //////////////////////////////////////////////////////////////////////////////// // Get AppConfig //////////////////////////////////////////////////////////////////////////////// @@ -330,6 +335,61 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle return nil, ctxerr.Wrap(ctx, err) } + // Validate NDES SCEP URLs if they changed. Validation is done in both dry run and normal mode. + if newAppConfig.Integrations.NDESSCEPProxy.Set && newAppConfig.Integrations.NDESSCEPProxy.Valid && !license.IsPremium() { + invalid.Append("integrations.ndes_scep_proxy", ErrMissingLicense.Error()) + appConfig.Integrations.NDESSCEPProxy.Valid = false + } else { + switch { + case !newAppConfig.Integrations.NDESSCEPProxy.Set: + // Nothing is set -- keep the old value + appConfig.Integrations.NDESSCEPProxy = oldAppConfig.Integrations.NDESSCEPProxy + case !newAppConfig.Integrations.NDESSCEPProxy.Valid: + // User is explicitly clearing this setting + appConfig.Integrations.NDESSCEPProxy.Valid = false + // Delete stored password + if !applyOpts.DryRun { + if err := svc.ds.HardDeleteMDMConfigAsset(ctx, fleet.MDMAssetNDESPassword); err != nil { + return nil, ctxerr.Wrap(ctx, err, "delete NDES SCEP password") + } + } + default: + // User is updating the setting + appConfig.Integrations.NDESSCEPProxy.Value.URL = fleet.Preprocess(newAppConfig.Integrations.NDESSCEPProxy.Value.URL) + appConfig.Integrations.NDESSCEPProxy.Value.AdminURL = fleet.Preprocess(newAppConfig.Integrations.NDESSCEPProxy.Value.AdminURL) + appConfig.Integrations.NDESSCEPProxy.Value.Username = fleet.Preprocess(newAppConfig.Integrations.NDESSCEPProxy.Value.Username) + // do not preprocess password + + validateAdminURL, validateSCEPURL := false, false + newSCEPProxy := appConfig.Integrations.NDESSCEPProxy.Value + if !oldAppConfig.Integrations.NDESSCEPProxy.Valid { + validateAdminURL, validateSCEPURL = true, true + } else { + oldSCEPProxy := oldAppConfig.Integrations.NDESSCEPProxy.Value + if newSCEPProxy.URL != oldSCEPProxy.URL { + validateSCEPURL = true + } + if newSCEPProxy.AdminURL != oldSCEPProxy.AdminURL || + newSCEPProxy.Username != oldSCEPProxy.Username || + (newSCEPProxy.Password != "" && newSCEPProxy.Password != fleet.MaskedPassword) { + validateAdminURL = true + } + } + + if validateAdminURL { + if err = validateNDESSCEPAdminURL(ctx, newSCEPProxy); err != nil { + invalid.Append("integrations.ndes_scep_proxy", err.Error()) + } + } + + if validateSCEPURL { + if err = validateNDESSCEPURL(ctx, newSCEPProxy, svc.logger); err != nil { + invalid.Append("integrations.ndes_scep_proxy.url", err.Error()) + } + } + } + } + // EnableDiskEncryption is an optjson.Bool field in order to support the // legacy field under "mdm.macos_settings". If the field provided to the // PATCH endpoint is set but invalid (that is, "enable_disk_encryption": diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 2c6e6bc10849..f514261e0707 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "database/sql" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -24,6 +25,7 @@ import ( nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" + "github.com/go-kit/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1394,3 +1396,197 @@ func TestModifyEnableAnalytics(t *testing.T) { }) } } + +func TestModifyAppConfigForNDESSCEPProxy(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierFree}}) + scepURL := "https://example.com/mscep/mscep.dll" + adminURL := "https://example.com/mscep_admin/" + username := "user" + password := "password" + + appConfig := &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{ + OrgName: "Test", + }, + ServerSettings: fleet.ServerSettings{ + ServerURL: "https://localhost:8080", + }, + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + if appConfig.Integrations.NDESSCEPProxy.Valid { + appConfig.Integrations.NDESSCEPProxy.Value.Password = fleet.MaskedPassword + } + return appConfig, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error { + appConfig = conf + return nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{ID: 1}}, nil + } + ds.SaveABMTokenFunc = func(ctx context.Context, token *fleet.ABMToken) error { + return nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + + jsonPayloadBase := ` +{ + "integrations": { + "ndes_scep_proxy": { + "url": "%s", + "admin_url": "%s", + "username": "%s", + "password": "%s" + } + } +} +` + jsonPayload := fmt.Sprintf(jsonPayloadBase, scepURL, adminURL, username, password) + admin := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)} + ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) + + // SCEP proxy not configured for free users + _, err := svc.ModifyAppConfig(ctx, []byte(jsonPayload), fleet.ApplySpecOptions{}) + assert.ErrorContains(t, err, ErrMissingLicense.Error()) + assert.ErrorContains(t, err, "integrations.ndes_scep_proxy") + + origValidateNDESSCEPURL := validateNDESSCEPURL + origValidateNDESSCEPAdminURL := validateNDESSCEPAdminURL + t.Cleanup(func() { + validateNDESSCEPURL = origValidateNDESSCEPURL + validateNDESSCEPAdminURL = origValidateNDESSCEPAdminURL + }) + validateNDESSCEPURLCalled := false + validateNDESSCEPURL = func(_ context.Context, _ fleet.NDESSCEPProxyIntegration, _ log.Logger) error { + validateNDESSCEPURLCalled = true + return nil + } + validateNDESSCEPAdminURLCalled := false + validateNDESSCEPAdminURL = func(_ context.Context, _ fleet.NDESSCEPProxyIntegration) error { + validateNDESSCEPAdminURLCalled = true + return nil + } + + svc, ctx = newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) + ac, err := svc.ModifyAppConfig(ctx, []byte(jsonPayload), fleet.ApplySpecOptions{}) + require.NoError(t, err) + checkSCEPProxy := func() { + require.NotNil(t, ac.Integrations.NDESSCEPProxy) + assert.Equal(t, scepURL, ac.Integrations.NDESSCEPProxy.Value.URL) + assert.Equal(t, adminURL, ac.Integrations.NDESSCEPProxy.Value.AdminURL) + assert.Equal(t, username, ac.Integrations.NDESSCEPProxy.Value.Username) + assert.Equal(t, fleet.MaskedPassword, ac.Integrations.NDESSCEPProxy.Value.Password) + } + checkSCEPProxy() + assert.True(t, validateNDESSCEPURLCalled) + assert.True(t, validateNDESSCEPAdminURLCalled) + + // Validation not done if there is no change + appConfig = ac + validateNDESSCEPURLCalled = false + validateNDESSCEPAdminURLCalled = false + jsonPayload = fmt.Sprintf(jsonPayloadBase, " "+scepURL, adminURL+" ", " "+username+" ", fleet.MaskedPassword) + ac, err = svc.ModifyAppConfig(ctx, []byte(jsonPayload), fleet.ApplySpecOptions{}) + require.NoError(t, err, jsonPayload) + checkSCEPProxy() + assert.False(t, validateNDESSCEPURLCalled) + assert.False(t, validateNDESSCEPAdminURLCalled) + + // Validation not done if there is no change, part 2 + validateNDESSCEPURLCalled = false + validateNDESSCEPAdminURLCalled = false + ac, err = svc.ModifyAppConfig(ctx, []byte(`{"integrations":{}}`), fleet.ApplySpecOptions{}) + require.NoError(t, err) + checkSCEPProxy() + assert.False(t, validateNDESSCEPURLCalled) + assert.False(t, validateNDESSCEPAdminURLCalled) + + // Validation done for SCEP URL. Password is blank, which is not considered a change. + scepURL = "https://new.com/mscep/mscep.dll" + jsonPayload = fmt.Sprintf(jsonPayloadBase, scepURL, adminURL, username, "") + ac, err = svc.ModifyAppConfig(ctx, []byte(jsonPayload), fleet.ApplySpecOptions{}) + require.NoError(t, err) + checkSCEPProxy() + assert.True(t, validateNDESSCEPURLCalled) + assert.False(t, validateNDESSCEPAdminURLCalled) + appConfig = ac + validateNDESSCEPURLCalled = false + validateNDESSCEPAdminURLCalled = false + + // Validation done for SCEP admin URL + adminURL = "https://new.com/mscep_admin/" + jsonPayload = fmt.Sprintf(jsonPayloadBase, scepURL, adminURL, username, fleet.MaskedPassword) + ac, err = svc.ModifyAppConfig(ctx, []byte(jsonPayload), fleet.ApplySpecOptions{}) + require.NoError(t, err) + checkSCEPProxy() + assert.False(t, validateNDESSCEPURLCalled) + assert.True(t, validateNDESSCEPAdminURLCalled) + + // Validation fails + validateNDESSCEPURLCalled = false + validateNDESSCEPAdminURLCalled = false + validateNDESSCEPURL = func(_ context.Context, _ fleet.NDESSCEPProxyIntegration, _ log.Logger) error { + validateNDESSCEPURLCalled = true + return errors.New("**invalid** 1") + } + validateNDESSCEPAdminURL = func(_ context.Context, _ fleet.NDESSCEPProxyIntegration) error { + validateNDESSCEPAdminURLCalled = true + return errors.New("**invalid** 2") + } + scepURL = "https://new2.com/mscep/mscep.dll" + jsonPayload = fmt.Sprintf(jsonPayloadBase, scepURL, adminURL, username, password) + ac, err = svc.ModifyAppConfig(ctx, []byte(jsonPayload), fleet.ApplySpecOptions{}) + assert.ErrorContains(t, err, "**invalid**") + assert.True(t, validateNDESSCEPURLCalled) + assert.True(t, validateNDESSCEPAdminURLCalled) + + // Reset validation + validateNDESSCEPURLCalled = false + validateNDESSCEPURL = func(_ context.Context, _ fleet.NDESSCEPProxyIntegration, _ log.Logger) error { + validateNDESSCEPURLCalled = true + return nil + } + validateNDESSCEPAdminURLCalled = false + validateNDESSCEPAdminURL = func(_ context.Context, _ fleet.NDESSCEPProxyIntegration) error { + validateNDESSCEPAdminURLCalled = true + return nil + } + + // Config cleared with explicit null + validateNDESSCEPURLCalled = false + validateNDESSCEPAdminURLCalled = false + payload := ` +{ + "integrations": { + "ndes_scep_proxy": null + } +} +` + // First, dry run. + ac, err = svc.ModifyAppConfig(ctx, []byte(payload), fleet.ApplySpecOptions{DryRun: true}) + require.NoError(t, err) + assert.False(t, ac.Integrations.NDESSCEPProxy.Valid) + // Also check what was saved. + assert.False(t, appConfig.Integrations.NDESSCEPProxy.Valid) + assert.False(t, validateNDESSCEPURLCalled) + assert.False(t, validateNDESSCEPAdminURLCalled) + assert.False(t, ds.HardDeleteMDMConfigAssetFuncInvoked, "DB write should not happen in dry run") + + // Second, real run. + ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { + return nil + } + ac, err = svc.ModifyAppConfig(ctx, []byte(payload), fleet.ApplySpecOptions{}) + require.NoError(t, err) + assert.False(t, ac.Integrations.NDESSCEPProxy.Valid) + // Also check what was saved. + assert.False(t, appConfig.Integrations.NDESSCEPProxy.Valid) + assert.False(t, validateNDESSCEPURLCalled) + assert.False(t, validateNDESSCEPAdminURLCalled) + assert.True(t, ds.HardDeleteMDMConfigAssetFuncInvoked) +} diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index c96777ea580e..4dfc5ce72c64 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -7,24 +7,29 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + "encoding/xml" "errors" "fmt" "io" "mime/multipart" "net/http" + "net/url" "os" + "regexp" "strconv" "strings" "sync" "time" "github.com/docker/go-units" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -47,6 +52,33 @@ import ( "go.mozilla.org/pkcs7" ) +const ( + // FleetVarNDESSCEPChallenge and other variables are used as $FLEET_VAR_. + // For example: $FLEET_VAR_NDES_SCEP_CHALLENGE + // Currently, we assume the variables are fully unique and not substrings of each other. + FleetVarNDESSCEPChallenge = "NDES_SCEP_CHALLENGE" + FleetVarNDESSCEPProxyURL = "NDES_SCEP_PROXY_URL" + FleetVarHostEndUserEmailIDP = "HOST_END_USER_EMAIL_IDP" +) + +var ( + profileVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_(?P\w+))|(\${FLEET_VAR_(?P\w+)})`) + fleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarNDESSCEPChallenge, + FleetVarNDESSCEPChallenge)) + fleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarNDESSCEPProxyURL, + FleetVarNDESSCEPProxyURL)) + fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%s})`, FleetVarHostEndUserEmailIDP, + FleetVarHostEndUserEmailIDP)) +) + +type hostProfileUUID struct { + HostUUID string + ProfileUUID string +} + +// Functions that can be overwritten in tests +var getNDESSCEPChallenge = eeservice.GetNDESSCEPChallenge + type getMDMAppleCommandResultsRequest struct { CommandUUID string `query:"command_uuid,optional"` } @@ -1372,7 +1404,7 @@ func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, tok assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetSCEPChallenge, - }) + }, nil) if err != nil { return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err) } @@ -1480,7 +1512,7 @@ func (svc *Service) getAppleSoftwareUpdateRequiredForDEPEnrollment(m fleet.MDMAp func (svc *Service) mdmPushCertTopic(ctx context.Context) (string, error) { assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetAPNSCert, - }) + }, nil) if err != nil { return "", ctxerr.Wrap(ctx, err, "loading SCEP keypair from the database") } @@ -3168,6 +3200,16 @@ func ReconcileAppleDeclarations( return nil } +// install/removeTargets are maps from profileUUID -> command uuid and host +// UUIDs as the underlying MDM services are optimized to send one command to +// multiple hosts at the same time. Note that the same command uuid is used +// for all hosts in a given install/remove target operation. +type cmdTarget struct { + cmdUUID string + profIdent string + hostUUIDs []string +} + func ReconcileAppleProfiles( ctx context.Context, ds fleet.Datastore, @@ -3184,7 +3226,7 @@ func ReconcileAppleProfiles( assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetCACert, - }) + }, nil) if err != nil { return ctxerr.Wrap(ctx, err, "getting Apple SCEP") } @@ -3240,15 +3282,9 @@ func ReconcileAppleProfiles( // command. hostProfilesToCleanup := []*fleet.MDMAppleProfilePayload{} - // install/removeTargets are maps from profileUUID -> command uuid and host - // UUIDs as the underlying MDM services are optimized to send one command to - // multiple hosts at the same time. Note that the same command uuid is used - // for all hosts in a given install/remove target operation. - type cmdTarget struct { - cmdUUID string - profIdent string - hostUUIDs []string - } + // Index host profiles to install by host and profile UUID, for easier bulk error processing + hostProfilesToInstallMap := make(map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(toInstall)) + installTargets, removeTargets := make(map[string]*cmdTarget), make(map[string]*cmdTarget) for _, p := range toInstall { if pp, ok := profileIntersection.GetMatchingProfileInCurrentState(p); ok { @@ -3257,7 +3293,7 @@ func ReconcileAppleProfiles( // the same) we don't send another InstallProfile // command. if pp.Status != &fleet.MDMDeliveryFailed && bytes.Equal(pp.Checksum, p.Checksum) { - hostProfiles = append(hostProfiles, &fleet.MDMAppleBulkUpsertHostProfilePayload{ + hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{ ProfileUUID: p.ProfileUUID, HostUUID: p.HostUUID, ProfileIdentifier: p.ProfileIdentifier, @@ -3267,7 +3303,9 @@ func ReconcileAppleProfiles( Status: pp.Status, CommandUUID: pp.CommandUUID, Detail: pp.Detail, - }) + } + hostProfiles = append(hostProfiles, hostProfile) + hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile continue } } @@ -3283,7 +3321,7 @@ func ReconcileAppleProfiles( } target.hostUUIDs = append(target.hostUUIDs, p.HostUUID) - hostProfiles = append(hostProfiles, &fleet.MDMAppleBulkUpsertHostProfilePayload{ + hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{ ProfileUUID: p.ProfileUUID, HostUUID: p.HostUUID, OperationType: fleet.MDMOperationTypeInstall, @@ -3292,7 +3330,9 @@ func ReconcileAppleProfiles( ProfileIdentifier: p.ProfileIdentifier, ProfileName: p.ProfileName, Checksum: p.Checksum, - }) + } + hostProfiles = append(hostProfiles, hostProfile) + hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile } for _, p := range toRemove { @@ -3359,6 +3399,12 @@ func ReconcileAppleProfiles( return ctxerr.Wrap(ctx, err, "get profile contents") } + // Insert variables into profile contents + err = preprocessProfileContents(ctx, appConfig, ds, installTargets, profileContents, hostProfilesToInstallMap) + if err != nil { + return err + } + type remoteResult struct { Err error CmdUUID string @@ -3438,6 +3484,264 @@ func ReconcileAppleProfiles( return nil } +func preprocessProfileContents( + ctx context.Context, + appConfig *fleet.AppConfig, + ds fleet.Datastore, + targets map[string]*cmdTarget, + profileContents map[string]mobileconfig.Mobileconfig, + hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, +) error { + + // This method replaces Fleet variables ($FLEET_VAR_) in the profile contents, generating a unique profile for each host. + // For a 2KB profile and 30K hosts, this method may generate ~60MB of profile data in memory. + + isNDESSCEPConfigured := func(profUUID string, target *cmdTarget) (bool, error) { + if !license.IsPremium(ctx) { + profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs)) + for _, hostUUID := range target.hostUUIDs { + profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] + if !ok { // Should never happen + continue + } + profile.Status = &fleet.MDMDeliveryFailed + profile.Detail = "NDES SCEP Proxy requires a Fleet Premium license." + profilesToUpdate = append(profilesToUpdate, profile) + } + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { + return false, err + } + return false, nil + } + if !appConfig.Integrations.NDESSCEPProxy.Valid { + profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs)) + for _, hostUUID := range target.hostUUIDs { + profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] + if !ok { // Should never happen + continue + } + profile.Status = &fleet.MDMDeliveryFailed + profile.Detail = "NDES SCEP Proxy is not configured. " + + "Please configure in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol." + profilesToUpdate = append(profilesToUpdate, profile) + } + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { + return false, err + } + return false, nil + } + return appConfig.Integrations.NDESSCEPProxy.Valid, nil + } + + // Copy of NDES SCEP config which will contain unencrypted password, if needed + var ndesConfig *fleet.NDESSCEPProxyIntegration + + var addedTargets map[string]*cmdTarget + for profUUID, target := range targets { + contents, ok := profileContents[profUUID] + if !ok { + // This should never happen + continue + } + + // Check if Fleet variables are present. + contentsStr := string(contents) + fleetVars := findFleetVariables(contentsStr) + if len(fleetVars) == 0 { + continue + } + + // Do common validation that applies to all hosts in the target + valid := true + for fleetVar := range fleetVars { + switch fleetVar { + case FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL: + configured, err := isNDESSCEPConfigured(profUUID, target) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking NDES SCEP configuration") + } + if !configured { + valid = false + break + } + case FleetVarHostEndUserEmailIDP: + // No extra validation needed for this variable + default: + // Error out if we find an unknown variable + profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.hostUUIDs)) + for _, hostUUID := range target.hostUUIDs { + profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] + if !ok { // Should never happen + continue + } + profile.Status = &fleet.MDMDeliveryFailed + profile.Detail = fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.", + fleetVar) + profilesToUpdate = append(profilesToUpdate, profile) + } + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profiles for unknown variable") + } + valid = false + break + } + } + if !valid { + // We marked the profile as failed, so we will not do any additional processing on it + delete(targets, profUUID) + continue + } + + // Currently, all supported Fleet variables are unique per host, so we split the profile into multiple profiles. + // We generate a new temporary profileUUID which is currently only used to install the profile. + // The profileUUID in host_mdm_apple_profiles is still the original profileUUID. + if addedTargets == nil { + addedTargets = make(map[string]*cmdTarget, 1) + } + for _, hostUUID := range target.hostUUIDs { + newProfUUID := uuid.NewString() + hostContents := contentsStr + + failed := false + for fleetVar := range fleetVars { + switch fleetVar { + case FleetVarNDESSCEPChallenge: + if ndesConfig == nil { + // Retrieve the NDES admin password. This is done once per run. + configAssets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetNDESPassword}, nil) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting NDES password") + } + // Copy config struct by value + configWithPassword := appConfig.Integrations.NDESSCEPProxy.Value + configWithPassword.Password = string(configAssets[fleet.MDMAssetNDESPassword].Value) + // Store the config with the password for later use + ndesConfig = &configWithPassword + } + // Insert the SCEP challenge into the profile contents + challenge, err := getNDESSCEPChallenge(ctx, *ndesConfig) + if err != nil { + detail := "" + switch { + case errors.As(err, &eeservice.NDESInvalidError{}): + detail = fmt.Sprintf("Invalid NDES admin credentials. "+ + "Fleet couldn't populate $FLEET_VAR_%s. "+ + "Please update credentials in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol.", + FleetVarNDESSCEPChallenge) + case errors.As(err, &eeservice.NDESPasswordCacheFullError{}): + detail = fmt.Sprintf("The NDES password cache is full. "+ + "Fleet couldn't populate $FLEET_VAR_%s. "+ + "Please increase the number of cached passwords in NDES and try again.", + FleetVarNDESSCEPChallenge) + default: + detail = fmt.Sprintf("Fleet couldn't populate $FLEET_VAR_%s. %s", FleetVarNDESSCEPChallenge, err.Error()) + } + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.cmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: detail, + OperationType: fleet.MDMOperationTypeInstall, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for NDES SCEP challenge") + } + failed = true + break + } + + hostContents = replaceFleetVariable(fleetVarNDESSCEPChallengeRegexp, hostContents, challenge) + case FleetVarNDESSCEPProxyURL: + // Insert the SCEP URL into the profile contents + proxyURL := fmt.Sprintf("%s%s%s", appConfig.ServerSettings.ServerURL, apple_mdm.SCEPProxyPath, + url.QueryEscape(fmt.Sprintf("%s,%s", hostUUID, profUUID))) + hostContents = replaceFleetVariable(fleetVarNDESSCEPProxyURLRegexp, hostContents, proxyURL) + case FleetVarHostEndUserEmailIDP: + // Insert the end user email IDP into the profile contents + emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts) + if err != nil { + // This is a server error, so we exit. + return ctxerr.Wrap(ctx, err, "getting host emails") + } + if len(emails) == 0 { + // Error if we can't retrieve the end user email IDP + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.cmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: fmt.Sprintf("There is no IdP email for this host. "+ + "Fleet couldn't populate $FLEET_VAR_%s. "+ + "[Learn more](https://fleetdm.com/learn-more-about/idp-email)", + FleetVarHostEndUserEmailIDP), + OperationType: fleet.MDMOperationTypeInstall, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user email IdP") + } + failed = true + break + } + hostContents = replaceFleetVariable(fleetVarHostEndUserEmailIDPRegexp, hostContents, emails[0]) + default: + // This was handled in the above switch statement, so we should never reach this case + } + } + if !failed { + addedTargets[newProfUUID] = &cmdTarget{ + cmdUUID: target.cmdUUID, + profIdent: target.profIdent, + hostUUIDs: []string{hostUUID}, + } + profileContents[newProfUUID] = mobileconfig.Mobileconfig(hostContents) + } + } + // Remove the parent target, since we will use host-specific targets + delete(targets, profUUID) + } + if len(addedTargets) > 0 { + // Add the new host-specific targets to the original targets map + for profUUID, target := range addedTargets { + targets[profUUID] = target + } + } + return nil +} + +func replaceFleetVariable(regExp *regexp.Regexp, contents string, replacement string) string { + // Escape XML characters + b := make([]byte, 0, len(replacement)) + buf := bytes.NewBuffer(b) + // error is always nil for Buffer.Write method, so we ignore it + _ = xml.EscapeText(buf, []byte(replacement)) + return regExp.ReplaceAllString(contents, buf.String()) +} + +func findFleetVariables(contents string) map[string]interface{} { + var result map[string]interface{} + matches := profileVariableRegex.FindAllStringSubmatch(contents, -1) + if len(matches) == 0 { + return nil + } + nameToIndex := make(map[string]int, 2) + for i, name := range profileVariableRegex.SubexpNames() { + if name == "" { + continue + } + nameToIndex[name] = i + } + for _, match := range matches { + for _, i := range nameToIndex { + if match[i] != "" { + if result == nil { + result = make(map[string]interface{}) + } + result[match[i]] = struct{}{} + } + } + } + return result +} + // scepCertRenewalThresholdDays defines the number of days before a SCEP // certificate must be renewed. const scepCertRenewalThresholdDays = 180 @@ -3516,7 +3820,7 @@ func RenewSCEPCertificates( assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetSCEPChallenge, - }) + }, nil) if err != nil { return ctxerr.Wrap(ctx, err, "loading SCEP challenge from the database") } @@ -3885,7 +4189,7 @@ func (svc *Service) GenerateABMKeyPair(ctx context.Context) (*fleet.MDMAppleDEPK assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetABMCert, fleet.MDMAssetABMKey, - }) + }, nil) if err != nil { // allow not found errors as it means that we're generating the // keypair for the first time @@ -3905,7 +4209,7 @@ func (svc *Service) GenerateABMKeyPair(ctx context.Context) (*fleet.MDMAppleDEPK err = svc.ds.InsertMDMConfigAssets(ctx, []fleet.MDMConfigAsset{ {Name: fleet.MDMAssetABMCert, Value: publicKeyPEM}, {Name: fleet.MDMAssetABMKey, Value: privateKeyPEM}, - }) + }, nil) if err != nil { return nil, ctxerr.Wrap(ctx, err, "saving ABM keypair in database") } @@ -4302,7 +4606,7 @@ func (svc *Service) MDMAppleProcessOTAEnrollment( assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetSCEPChallenge, - }) + }, nil) if err != nil { return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err) } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index aeabb3542b5c..d93d1c45c34a 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -17,6 +17,7 @@ import ( "math/big" "net/http" "net/http/httptest" + "net/url" "os" "strings" "sync" @@ -24,6 +25,7 @@ import ( "testing" "time" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" @@ -48,6 +50,7 @@ import ( kitlog "github.com/go-kit/log" "github.com/google/uuid" "github.com/groob/plist" + "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" @@ -212,7 +215,8 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi require.NoError(t, err) certPEM := tokenpki.PEMCertificate(crt.Raw) keyPEM := tokenpki.PEMRSAPrivateKey(key) - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetAPNSCert: {Value: apnsCert}, fleet.MDMAssetAPNSKey: {Value: apnsKey}, @@ -2197,7 +2201,8 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { AppleSCEPCert: "./testdata/server.pem", AppleSCEPKey: "./testdata/server.key", } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { _, pemCert, pemKey, err := mdmConfig.AppleSCEP() require.NoError(t, err) return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ @@ -2209,6 +2214,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher) hostUUID, hostUUID2 := "ABC-DEF", "GHI-JKL" contents1 := []byte("test-content-1") + expectedContents1 := []byte("test-content-1") // used for Fleet variable substitution contents2 := []byte("test-content-2") contents4 := []byte("test-content-4") @@ -2267,10 +2273,10 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { mu.Unlock() require.NoError(t, err) - if !bytes.Equal(p7.Content, contents1) && !bytes.Equal(p7.Content, contents2) && + if !bytes.Equal(p7.Content, expectedContents1) && !bytes.Equal(p7.Content, contents2) && !bytes.Equal(p7.Content, contents4) { require.Failf(t, "profile contents don't match", "expected to contain %s, %s or %s but got %s", - contents1, contents2, contents4, p7.Content) + expectedContents1, contents2, contents4, p7.Content) } case "RemoveProfile": require.ElementsMatch(t, []string{hostUUID, hostUUID2}, id) @@ -2303,7 +2309,8 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) { return false, nil } - mdmStorage.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + mdmStorage.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { certPEM, err := os.ReadFile("./testdata/server.pem") require.NoError(t, err) keyPEM, err := os.ReadFile("./testdata/server.key") @@ -2419,9 +2426,9 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { checkAndReset := func(t *testing.T, want bool, invoked *bool) { if want { - require.True(t, *invoked) + assert.True(t, *invoked) } else { - require.False(t, *invoked) + assert.False(t, *invoked) } *invoked = false } @@ -2530,6 +2537,470 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { checkAndReset(t, true, &ds.GetMDMAppleProfilesContentsFuncInvoked) checkAndReset(t, true, &ds.BulkUpsertMDMAppleHostProfilesFuncInvoked) }) + + // Zero profiles to remove + ds.ListMDMAppleProfilesToRemoveFunc = func(ctx context.Context) ([]*fleet.MDMAppleProfilePayload, error) { + return nil, nil + } + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + if failedCall { + failedCheck(payload) + return nil + } + + // next call will be failed call, until reset + failedCall = true + + // first time it is called, it is to set the status to pending and all + // host profiles have a command uuid + cmdUUIDByProfileUUIDInstall := make(map[string]string) + cmdUUIDByProfileUUIDRemove := make(map[string]string) + copies := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(payload)) + for i, p := range payload { + if p.OperationType == fleet.MDMOperationTypeInstall { + existing, ok := cmdUUIDByProfileUUIDInstall[p.ProfileUUID] + if ok { + require.Equal(t, existing, p.CommandUUID) + } else { + cmdUUIDByProfileUUIDInstall[p.ProfileUUID] = p.CommandUUID + } + } else { + require.Equal(t, fleet.MDMOperationTypeRemove, p.OperationType) + existing, ok := cmdUUIDByProfileUUIDRemove[p.ProfileUUID] + if ok { + require.Equal(t, existing, p.CommandUUID) + } else { + cmdUUIDByProfileUUIDRemove[p.ProfileUUID] = p.CommandUUID + } + } + + // clear the command UUID (in a copy so that it does not affect the + // pointed-to struct) from the payload for the subsequent checks + copyp := *p + copyp.CommandUUID = "" + copies[i] = ©p + } + + require.ElementsMatch(t, []*fleet.MDMAppleBulkUpsertHostProfilePayload{ + { + ProfileUUID: p1, + ProfileIdentifier: "com.add.profile", + HostUUID: hostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + }, + { + ProfileUUID: p2, + ProfileIdentifier: "com.add.profile.two", + HostUUID: hostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + }, + { + ProfileUUID: p2, + ProfileIdentifier: "com.add.profile.two", + HostUUID: hostUUID2, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + }, + { + ProfileUUID: p4, + ProfileIdentifier: "com.add.profile.four", + HostUUID: hostUUID2, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + }, + }, copies) + return nil + } + + // Enable NDES + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + appCfg := &fleet.AppConfig{} + appCfg.ServerSettings.ServerURL = "https://test.example.com" + appCfg.MDM.EnabledAndConfigured = true + appCfg.Integrations.NDESSCEPProxy.Valid = true + return appCfg, nil + } + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + + t.Run("replace $FLEET_VAR_"+FleetVarNDESSCEPProxyURL, func(t *testing.T) { + var failedCount int + failedCall = false + failedCheck = func(payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) { + failedCount++ + require.Len(t, payload, 0) + } + enqueueFailForOp = "" + newContents := "$FLEET_VAR_" + FleetVarNDESSCEPProxyURL + originalContents1 := contents1 + originalExpectedContents1 := expectedContents1 + contents1 = []byte(newContents) + expectedContents1 = []byte("https://test.example.com" + apple_mdm.SCEPProxyPath + url.QueryEscape(fmt.Sprintf("%s,%s", hostUUID, + p1))) + t.Cleanup(func() { + contents1 = originalContents1 + expectedContents1 = originalExpectedContents1 + }) + err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger()) + require.NoError(t, err) + require.Equal(t, 1, failedCount) + checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallFuncInvoked) + checkAndReset(t, true, &ds.ListMDMAppleProfilesToRemoveFuncInvoked) + checkAndReset(t, true, &ds.GetMDMAppleProfilesContentsFuncInvoked) + checkAndReset(t, true, &ds.BulkUpsertMDMAppleHostProfilesFuncInvoked) + }) + + t.Run("preprocessor fails on $FLEET_VAR_"+FleetVarHostEndUserEmailIDP, func(t *testing.T) { + var failedCount int + failedCall = false + failedCheck = func(payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) { + failedCount++ + require.Len(t, payload, 0) + } + enqueueFailForOp = "" + newContents := "$FLEET_VAR_" + FleetVarHostEndUserEmailIDP + originalContents1 := contents1 + contents1 = []byte(newContents) + t.Cleanup(func() { + contents1 = originalContents1 + }) + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return nil, errors.New("GetHostEmailsFuncError") + } + err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger()) + assert.ErrorContains(t, err, "GetHostEmailsFuncError") + checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallFuncInvoked) + checkAndReset(t, true, &ds.ListMDMAppleProfilesToRemoveFuncInvoked) + checkAndReset(t, true, &ds.GetMDMAppleProfilesContentsFuncInvoked) + checkAndReset(t, true, &ds.BulkUpsertMDMAppleHostProfilesFuncInvoked) + }) + + t.Run("bad $FLEET_VAR", func(t *testing.T) { + var failedCount int + failedCall = false + var hostUUIDs []string + failedCheck = func(payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) { + if len(payload) > 0 { + failedCount++ + } + for _, p := range payload { + assert.Equal(t, fleet.MDMDeliveryFailed, *p.Status) + assert.Contains(t, p.Detail, "FLEET_VAR_BOZO") + for i, hu := range hostUUIDs { + if hu == p.HostUUID { + // remove element + hostUUIDs = append(hostUUIDs[:i], hostUUIDs[i+1:]...) + break + } + } + } + } + enqueueFailForOp = "" + + // All profiles will have bad contents + badContents := "bad-content: $FLEET_VAR_BOZO" + originalContents1 := contents1 + originalContents2 := contents2 + originalContents4 := contents4 + contents1 = []byte(badContents) + contents2 = []byte(badContents) + contents4 = []byte(badContents) + t.Cleanup(func() { + contents1 = originalContents1 + contents2 = originalContents2 + contents4 = originalContents4 + }) + + profilesToInstall, _ := ds.ListMDMAppleProfilesToInstallFunc(ctx) + hostUUIDs = make([]string, 0, len(profilesToInstall)) + for _, p := range profilesToInstall { + hostUUIDs = append(hostUUIDs, p.HostUUID) + } + + err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger()) + require.NoError(t, err) + assert.Empty(t, hostUUIDs, "all host+profile combinations should be updated") + require.Equal(t, 3, failedCount, "number of profiles with bad content") + checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallFuncInvoked) + checkAndReset(t, true, &ds.ListMDMAppleProfilesToRemoveFuncInvoked) + checkAndReset(t, true, &ds.GetMDMAppleProfilesContentsFuncInvoked) + checkAndReset(t, true, &ds.BulkUpsertMDMAppleHostProfilesFuncInvoked) + // Check that individual updates were not done (bulk update should be done) + checkAndReset(t, false, &ds.UpdateOrDeleteHostMDMAppleProfileFuncInvoked) + }) +} + +func TestPreprocessProfileContents(t *testing.T) { + origGetNDESSCEPChallenge := getNDESSCEPChallenge + t.Cleanup(func() { + getNDESSCEPChallenge = origGetNDESSCEPChallenge + }) + + ctx := context.Background() + appCfg := &fleet.AppConfig{} + appCfg.ServerSettings.ServerURL = "https://test.example.com" + appCfg.MDM.EnabledAndConfigured = true + appCfg.Integrations.NDESSCEPProxy.Valid = true + ds := new(mock.Store) + + // No-op + err := preprocessProfileContents(ctx, appCfg, ds, nil, nil, nil) + require.NoError(t, err) + + hostUUID := "host-1" + cmdUUID := "cmd-1" + var targets map[string]*cmdTarget + populateTargets := func() { + targets = map[string]*cmdTarget{ + "p1": {cmdUUID: cmdUUID, profIdent: "com.add.profile", hostUUIDs: []string{hostUUID}}, + } + } + hostProfilesToInstallMap := make(map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, 1) + hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: "p1"}] = &fleet.MDMAppleBulkUpsertHostProfilePayload{ + ProfileUUID: "p1", + ProfileIdentifier: "com.add.profile", + HostUUID: hostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + CommandUUID: cmdUUID, + } + populateTargets() + profileContents := map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + FleetVarNDESSCEPProxyURL), + } + + var updatedPayload *fleet.MDMAppleBulkUpsertHostProfilePayload + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + require.Len(t, payload, 1) + updatedPayload = payload[0] + for _, p := range payload { + require.NotNil(t, p.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *p.Status) + assert.Equal(t, cmdUUID, p.CommandUUID) + assert.Equal(t, hostUUID, p.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) + } + return nil + } + // Can't use NDES SCEP proxy with free tier + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree}) + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + require.NotNil(t, updatedPayload) + assert.Contains(t, updatedPayload.Detail, "Premium license") + assert.Empty(t, targets) + + // Can't use NDES SCEP proxy without it being configured + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + appCfg.Integrations.NDESSCEPProxy.Valid = false + updatedPayload = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + require.NotNil(t, updatedPayload) + assert.Contains(t, updatedPayload.Detail, "not configured") + assert.Empty(t, targets) + + // Unknown variable + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_BOZO"), + } + appCfg.Integrations.NDESSCEPProxy.Valid = true + updatedPayload = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + require.NotNil(t, updatedPayload) + assert.Contains(t, updatedPayload.Detail, "FLEET_VAR_BOZO") + assert.Empty(t, targets) + + ndesPassword := "test-password" + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, + assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ + fleet.MDMAssetNDESPassword: {Value: []byte(ndesPassword)}, + }, nil + } + + ds.BulkUpsertMDMAppleHostProfilesFunc = nil + var updatedProfile *fleet.HostMDMAppleProfile + ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { + updatedProfile = profile + require.NotNil(t, updatedProfile.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) + assert.Equal(t, cmdUUID, updatedProfile.CommandUUID) + assert.Equal(t, hostUUID, updatedProfile.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) + return nil + } + + // Could not get NDES SCEP challenge + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + FleetVarNDESSCEPChallenge), + } + getNDESSCEPChallenge = func(ctx context.Context, proxy fleet.NDESSCEPProxyIntegration) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", eeservice.NewNDESInvalidError("NDES error") + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge) + assert.Contains(t, updatedProfile.Detail, "update credentials") + assert.Empty(t, targets) + + // Password cache full + getNDESSCEPChallenge = func(ctx context.Context, proxy fleet.NDESSCEPProxyIntegration) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", eeservice.NewNDESPasswordCacheFullError("NDES error") + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge) + assert.Contains(t, updatedProfile.Detail, "cached passwords") + assert.Empty(t, targets) + + // Other NDES challenge error + getNDESSCEPChallenge = func(ctx context.Context, proxy fleet.NDESSCEPProxyIntegration) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", errors.New("NDES error") + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarNDESSCEPChallenge) + assert.NotContains(t, updatedProfile.Detail, "cached passwords") + assert.NotContains(t, updatedProfile.Detail, "update credentials") + assert.Empty(t, targets) + + // NDES challenge + challenge := "ndes-challenge" + getNDESSCEPChallenge = func(ctx context.Context, proxy fleet.NDESSCEPProxyIntegration) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return challenge, nil + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.Equal(t, cmdUUID, target.cmdUUID) + assert.Equal(t, []string{hostUUID}, target.hostUUIDs) + assert.Equal(t, challenge, string(profileContents[profUUID])) + } + + // NDES SCEP proxy URL + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + FleetVarNDESSCEPProxyURL), + } + expectedURL := "https://test.example.com" + apple_mdm.SCEPProxyPath + url.QueryEscape(fmt.Sprintf("%s,%s", hostUUID, "p1")) + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.Equal(t, cmdUUID, target.cmdUUID) + assert.Equal(t, []string{hostUUID}, target.hostUUIDs) + assert.Equal(t, expectedURL, string(profileContents[profUUID])) + } + + // No IdP email found + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return nil, nil + } + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + FleetVarHostEndUserEmailIDP), + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+FleetVarHostEndUserEmailIDP) + assert.Contains(t, updatedProfile.Detail, "no IdP email") + assert.Empty(t, targets) + + // IdP email found + email := "user@example.com" + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return []string{email}, nil + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, hostProfilesToInstallMap) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.Equal(t, cmdUUID, target.cmdUUID) + assert.Equal(t, []string{hostUUID}, target.hostUUIDs) + assert.Equal(t, email, string(profileContents[profUUID])) + } + + // multiple profiles, multiple hosts + populateTargets = func() { + targets = map[string]*cmdTarget{ + "p1": {cmdUUID: cmdUUID, profIdent: "com.add.profile", hostUUIDs: []string{hostUUID, "host-2"}}, // fails + "p2": {cmdUUID: cmdUUID, profIdent: "com.add.profile2", hostUUIDs: []string{hostUUID, "host-3"}}, // works + "p3": {cmdUUID: cmdUUID, profIdent: "com.add.profile2", hostUUIDs: []string{hostUUID, "host-4"}}, // no variables + } + } + populateTargets() + appCfg.Integrations.NDESSCEPProxy.Valid = false // NDES will fail + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + FleetVarNDESSCEPProxyURL), + "p2": []byte("$FLEET_VAR_" + FleetVarHostEndUserEmailIDP), + "p3": []byte("no variables"), + } + expectedHostsToFail := []string{hostUUID, "host-2", "host-3"} + ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { + updatedProfile = profile + require.NotNil(t, updatedProfile.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) + assert.Equal(t, cmdUUID, updatedProfile.CommandUUID) + assert.Contains(t, expectedHostsToFail, updatedProfile.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) + return nil + } + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + for _, p := range payload { + require.NotNil(t, p.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *p.Status) + assert.Equal(t, cmdUUID, p.CommandUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) + } + return nil + } + err = preprocessProfileContents(ctx, appCfg, ds, targets, profileContents, nil) + require.NoError(t, err) + require.NotEmpty(t, targets) + assert.Len(t, targets, 3) + assert.Nil(t, targets["p1"]) // error + assert.Nil(t, targets["p2"]) // renamed + assert.NotNil(t, targets["p3"]) // normal, no variables + for profUUID, target := range targets { + assert.Contains(t, [][]string{{hostUUID}, {"host-3"}, {hostUUID, "host-4"}}, target.hostUUIDs) + assert.Equal(t, cmdUUID, target.cmdUUID) + assert.Contains(t, []string{email, "no variables"}, string(profileContents[profUUID])) + } } func TestAppleMDMFileVaultEscrowFunctions(t *testing.T) { @@ -3035,7 +3506,8 @@ func setupTest(t *testing.T) (context.Context, kitlog.Logger, *mock.Store, *conf _, pemCert, pemKey, err := mdmConfig.AppleSCEP() require.NoError(t, err) - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Value: pemCert}, fleet.MDMAssetCAKey: {Value: pemKey}, @@ -3043,7 +3515,8 @@ func setupTest(t *testing.T) (context.Context, kitlog.Logger, *mock.Store, *conf fleet.MDMAssetAPNSCert: {Value: apnsCert}, }, nil } - mdmStorage.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + mdmStorage.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Value: pemCert}, fleet.MDMAssetCAKey: {Value: pemKey}, diff --git a/server/service/handler.go b/server/service/handler.go index 7443436b8e42..b21ba317be24 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -7,6 +7,7 @@ import ( "net/http" "regexp" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/publicip" @@ -1101,7 +1102,7 @@ func registerSCEP( scep_depot.WithValidityDays(scepConfig.AppleSCEPSignerValidityDays), scep_depot.WithAllowRenewalDays(scepConfig.AppleSCEPSignerAllowRenewalDays), )) - assets, err := mdmStorage.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{fleet.MDMAssetSCEPChallenge}) + assets, err := mdmStorage.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{fleet.MDMAssetSCEPChallenge}, nil) if err != nil { return fmt.Errorf("retrieving SCEP challenge: %w", err) } @@ -1123,6 +1124,24 @@ func registerSCEP( return nil } +func RegisterSCEPProxy( + rootMux *http.ServeMux, + ds fleet.Datastore, + logger kitlog.Logger, +) error { + scepService := eeservice.NewSCEPProxyService( + ds, + kitlog.With(logger, "component", "scep-proxy-service"), + ) + scepLogger := kitlog.With(logger, "component", "http-scep-proxy") + e := scepserver.MakeServerEndpointsWithIdentifier(scepService) + e.GetEndpoint = scepserver.EndpointLoggingMiddleware(scepLogger)(e.GetEndpoint) + e.PostEndpoint = scepserver.EndpointLoggingMiddleware(scepLogger)(e.PostEndpoint) + scepHandler := scepserver.MakeHTTPHandlerWithIdentifier(e, apple_mdm.SCEPProxyPath, scepLogger) + rootMux.Handle(apple_mdm.SCEPProxyPath, scepHandler) + return nil +} + // NanoMDMLogger is a logger adapter for nanomdm. type NanoMDMLogger struct { logger kitlog.Logger diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 59e1cc9413d7..8ae126df9698 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -26,6 +26,7 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" kitlog "github.com/go-kit/log" + "github.com/jmoiron/sqlx" "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1281,7 +1282,8 @@ func TestHostEncryptionKey(t *testing.T) { return nil } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: testCertPEM}, fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: testKeyPEM}, @@ -1333,7 +1335,8 @@ func TestHostEncryptionKey(t *testing.T) { ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { return nil, keyErr } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: testCertPEM}, fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: testKeyPEM}, @@ -1393,7 +1396,8 @@ func TestHostEncryptionKey(t *testing.T) { ) error { return nil } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: testCertPEM}, fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: testKeyPEM}, diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 27c1458c4075..c52810d36aae 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -752,7 +752,7 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ fleet.MDMAssetCACert, - }) + }, nil) require.NoError(t, err) require.True(t, rootCA.AppendCertsFromPEM(assets[fleet.MDMAssetCACert].Value)) diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 1566fae3b114..2b74bdcde9f6 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -42,7 +42,7 @@ func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) { assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ fleet.MDMAssetCACert, - }) + }, nil) require.NoError(t, err) require.True(t, rootCA.AppendCertsFromPEM(assets[fleet.MDMAssetCACert].Value)) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 6eee1327bb2b..25e9664a7819 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -31,6 +31,7 @@ import ( "testing" "time" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/fleetdbase" "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest" @@ -55,6 +56,9 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service" + "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" + filedepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot/file" + scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/mock" "github.com/fleetdm/fleet/v4/server/service/osquery_utils" @@ -63,10 +67,12 @@ import ( "github.com/fleetdm/fleet/v4/server/worker" kitlog "github.com/go-kit/log" "github.com/google/uuid" + "github.com/gorilla/mux" "github.com/groob/plist" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" "github.com/smallstep/pkcs7" + "github.com/smallstep/scep" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -197,7 +203,7 @@ func (s *integrationMDMTestSuite) SetupSuite() { bootstrapPackageStore = s3.SetupTestBootstrapPackageStore(s.T(), "integration-tests", "") } - config := TestServerOpts{ + serverConfig := TestServerOpts{ License: &fleet.LicenseInfo{ Tier: fleet.TierPremium, }, @@ -283,16 +289,17 @@ func (s *integrationMDMTestSuite) SetupSuite() { } }, }, - APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589", + APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589", + EnableSCEPProxy: true, } // ensure all our tests support challenges with invalid XML characters s.scepChallenge = "scepcha/>