diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index eed96b690b..748b5a4878 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -91,7 +91,9 @@ "jira": null, "zendesk": null, "google_calendar": null, - "ndes_scep_proxy": null + "ndes_scep_proxy": null, + "custom_scep_proxy": null, + "digicert": null }, "mdm": { "android_enabled_and_configured": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json index 9c4b5f8fb1..988a08764e 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json @@ -64,7 +64,9 @@ "jira": null, "zendesk": null, "google_calendar": null, - "ndes_scep_proxy": null + "ndes_scep_proxy": null, + "custom_scep_proxy": null, + "digicert": null }, "mdm": { "android_enabled_and_configured": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml index 1c811da323..8834fa8d48 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml @@ -14,6 +14,8 @@ spec: enable_host_users: true enable_software_inventory: false integrations: + custom_scep_proxy: null + digicert: null google_calendar: null jira: null ndes_scep_proxy: null diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 54b62c8d33..aca1bb0a28 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -14,6 +14,8 @@ spec: enable_host_users: true enable_software_inventory: false integrations: + custom_scep_proxy: null + digicert: null google_calendar: null jira: null ndes_scep_proxy: null diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index c7d5f27dd8..4b13ec55fa 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -146,7 +146,9 @@ "jira": null, "zendesk": null, "google_calendar": null, - "ndes_scep_proxy": null + "ndes_scep_proxy": null, + "custom_scep_proxy": null, + "digicert": null }, "update_interval": { "osquery_detail": "1h0m0s", diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 202cd0ff33..4d1032c09b 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -14,6 +14,8 @@ spec: enable_host_users: true enable_software_inventory: false integrations: + custom_scep_proxy: null + digicert: null google_calendar: null jira: null ndes_scep_proxy: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 701ade6abe..c31b80d09f 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -14,6 +14,8 @@ spec: activity_expiry_enabled: false activity_expiry_window: 0 integrations: + custom_scep_proxy: null + digicert: null google_calendar: null jira: null ndes_scep_proxy: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 78e849b2d1..fe9541987d 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -14,6 +14,8 @@ spec: activity_expiry_enabled: false activity_expiry_window: 0 integrations: + custom_scep_proxy: null + digicert: null google_calendar: null jira: null ndes_scep_proxy: null diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 8a3914fa36..355aa53db3 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -96,7 +96,7 @@ CREATE TABLE `app_config_json` ( PRIMARY KEY (`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\": {\"script\": null, \"software\": null, \"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}, \"apple_server_url\": \"\", \"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_migration_enabled\": false, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"gitops\": {\"repository_url\": \"\", \"gitops_mode_enabled\": 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'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"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}, \"apple_server_url\": \"\", \"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_migration_enabled\": false, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"gitops\": {\"repository_url\": \"\", \"gitops_mode_enabled\": 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, \"digicert\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null, \"custom_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/fleet/activities.go b/server/fleet/activities.go index ca699cfa83..91e870297b 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -2199,7 +2199,9 @@ func (a ActivityEditedNDESSCEPProxy) Documentation() (activity string, details s return "Generated when NDES SCEP proxy configuration is edited in Fleet.", `This activity does not contain any detail fields.`, `` } -type ActivityAddedCustomSCEPProxy struct{} +type ActivityAddedCustomSCEPProxy struct { + Name string `json:"name"` +} func (a ActivityAddedCustomSCEPProxy) ActivityName() string { return "added_custom_scep_proxy" @@ -2211,7 +2213,9 @@ func (a ActivityAddedCustomSCEPProxy) Documentation() (activity string, details }` } -type ActivityDeletedCustomSCEPProxy struct{} +type ActivityDeletedCustomSCEPProxy struct { + Name string `json:"name"` +} func (a ActivityDeletedCustomSCEPProxy) ActivityName() string { return "deleted_custom_scep_proxy" @@ -2223,7 +2227,9 @@ func (a ActivityDeletedCustomSCEPProxy) Documentation() (activity string, detail }` } -type ActivityEditedCustomSCEPProxy struct{} +type ActivityEditedCustomSCEPProxy struct { + Name string `json:"name"` +} func (a ActivityEditedCustomSCEPProxy) ActivityName() string { return "edited_custom_scep_proxy" @@ -2235,7 +2241,9 @@ func (a ActivityEditedCustomSCEPProxy) Documentation() (activity string, details }` } -type ActivityAddedDigiCert struct{} +type ActivityAddedDigiCert struct { + Name string `json:"name"` +} func (a ActivityAddedDigiCert) ActivityName() string { return "added_digicert" @@ -2247,7 +2255,9 @@ func (a ActivityAddedDigiCert) Documentation() (activity string, details string, }` } -type ActivityDeletedDigiCert struct{} +type ActivityDeletedDigiCert struct { + Name string `json:"name"` +} func (a ActivityDeletedDigiCert) ActivityName() string { return "deleted_digicert" @@ -2259,7 +2269,9 @@ func (a ActivityDeletedDigiCert) Documentation() (activity string, details strin }` } -type ActivityEditedDigiCert struct{} +type ActivityEditedDigiCert struct { + Name string `json:"name"` +} func (a ActivityEditedDigiCert) ActivityName() string { return "edited_digicert" diff --git a/server/fleet/app.go b/server/fleet/app.go index e452a3cad4..120dc98a03 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -683,6 +683,16 @@ func (c *AppConfig) Copy() *AppConfig { maps.Copy(clone.Integrations.GoogleCalendar[i].ApiKey, g.ApiKey) } } + if len(c.Integrations.DigiCert.Value) > 0 { + digicert := make([]DigiCertIntegration, len(c.Integrations.DigiCert.Value)) + copy(digicert, c.Integrations.DigiCert.Value) + clone.Integrations.DigiCert = optjson.SetSlice(digicert) + } + if len(c.Integrations.CustomSCEPProxy.Value) > 0 { + customSCEP := make([]CustomSCEPProxyIntegration, len(c.Integrations.CustomSCEPProxy.Value)) + copy(customSCEP, c.Integrations.CustomSCEPProxy.Value) + clone.Integrations.CustomSCEPProxy = optjson.SetSlice(customSCEP) + } if c.MDM.MacOSSettings.CustomSettings != nil { clone.MDM.MacOSSettings.CustomSettings = make([]MDMProfileSpec, len(c.MDM.MacOSSettings.CustomSettings)) diff --git a/server/fleet/integrations.go b/server/fleet/integrations.go index 887db9a54e..847b24273f 100644 --- a/server/fleet/integrations.go +++ b/server/fleet/integrations.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "slices" "strconv" "strings" @@ -353,6 +354,26 @@ type GoogleCalendarIntegration struct { ApiKey map[string]string `json:"api_key_json"` } +type DigiCertIntegration struct { + Name string `json:"name"` + URL string `json:"url"` + APIToken string `json:"api_token"` + ProfileID string `json:"profile_id"` + CertificateCommonName string `json:"certificate_common_name"` + CertificateUserPrincipalNames []string `json:"certificate_user_principal_names"` + CertificateSeatID string `json:"certificate_seat_id"` +} + +func (d *DigiCertIntegration) Equals(other *DigiCertIntegration) bool { + return d.Name == other.Name && + d.URL == other.URL && + (d.APIToken == "" || d.APIToken == MaskedPassword || d.APIToken == other.APIToken) && + d.ProfileID == other.ProfileID && + d.CertificateCommonName == other.CertificateCommonName && + slices.Equal(d.CertificateUserPrincipalNames, other.CertificateUserPrincipalNames) && + d.CertificateSeatID == other.CertificateSeatID +} + // NDESSCEPProxyIntegration configures SCEP proxy for NDES SCEP server. Premium feature. type NDESSCEPProxyIntegration struct { URL string `json:"url"` @@ -361,13 +382,21 @@ type NDESSCEPProxyIntegration struct { Password string `json:"password"` // not stored here -- encrypted in DB } +type CustomSCEPProxyIntegration struct { + Name string `json:"name"` + URL string `json:"url"` + Challenge string `json:"challenge"` +} + // Integrations configures the integrations with external systems. type Integrations struct { - Jira []*JiraIntegration `json:"jira"` - Zendesk []*ZendeskIntegration `json:"zendesk"` - GoogleCalendar []*GoogleCalendarIntegration `json:"google_calendar"` + Jira []*JiraIntegration `json:"jira"` + Zendesk []*ZendeskIntegration `json:"zendesk"` + GoogleCalendar []*GoogleCalendarIntegration `json:"google_calendar"` + DigiCert optjson.Slice[DigiCertIntegration] `json:"digicert"` // NDESSCEPProxy settings. In JSON, not specifying this field means keep current setting, null means clear settings. - NDESSCEPProxy optjson.Any[NDESSCEPProxyIntegration] `json:"ndes_scep_proxy"` + NDESSCEPProxy optjson.Any[NDESSCEPProxyIntegration] `json:"ndes_scep_proxy"` + CustomSCEPProxy optjson.Slice[CustomSCEPProxyIntegration] `json:"custom_scep_proxy"` } func ValidateEnabledActivitiesWebhook(webhook ActivitiesWebhookSettings, invalid *InvalidArgumentError) { diff --git a/server/service/appconfig.go b/server/service/appconfig.go index a3f8230170..98b0050aa7 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" "os" + "regexp" eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/pkg/optjson" @@ -366,72 +367,7 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle appConfig.MDM.WindowsMigrationEnabled = false } - type ndesStatusType string - const ( - ndesStatusAdded ndesStatusType = "added" - ndesStatusEdited ndesStatusType = "edited" - ndesStatusDeleted ndesStatusType = "deleted" - ) - var ndesStatus ndesStatusType - - // 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 - if oldAppConfig.Integrations.NDESSCEPProxy.Valid { - ndesStatus = ndesStatusDeleted - } - 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 - if len(svc.config.Server.PrivateKey) == 0 { - invalid.Append("integrations.ndes_scep_proxy", - "Cannot encrypt NDES password. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") - } - - validateAdminURL, validateSCEPURL := false, false - newSCEPProxy := appConfig.Integrations.NDESSCEPProxy.Value - if !oldAppConfig.Integrations.NDESSCEPProxy.Valid { - ndesStatus = ndesStatusAdded - validateAdminURL, validateSCEPURL = true, true - } else { - oldSCEPProxy := oldAppConfig.Integrations.NDESSCEPProxy.Value - if newSCEPProxy.URL != oldSCEPProxy.URL { - ndesStatus = ndesStatusEdited - validateSCEPURL = true - } - if newSCEPProxy.AdminURL != oldSCEPProxy.AdminURL || - newSCEPProxy.Username != oldSCEPProxy.Username || - (newSCEPProxy.Password != "" && newSCEPProxy.Password != fleet.MaskedPassword) { - ndesStatus = ndesStatusEdited - 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()) - } - } - } - } + caStatus := svc.processAppConfigCAs(ctx, &newAppConfig, oldAppConfig, appConfig, invalid) // EnableDiskEncryption is an optjson.Bool field in order to support the // legacy field under "mdm.macos_settings". If the field provided to the @@ -446,7 +382,8 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle // 2. To update fields with the incoming values if newAppConfig.MDM.EnableDiskEncryption.Valid { if newAppConfig.MDM.EnableDiskEncryption.Value && svc.config.Server.PrivateKey == "" { - return nil, ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + return nil, ctxerr.New(ctx, + "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") } appConfig.MDM.EnableDiskEncryption = newAppConfig.MDM.EnableDiskEncryption } else if appConfig.MDM.EnableDiskEncryption.Set && !appConfig.MDM.EnableDiskEncryption.Valid { @@ -471,7 +408,8 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle // this "warning" is returned only in dry-run mode, and if no other errors // were encountered. legacyUsedWarning = &fleet.BadRequestError{ - Message: fmt.Sprintf("warning: deprecated settings were used in the configuration: %v; consider updating to the new settings: https://fleetdm.com/docs/using-fleet/configuration-files#settings", legacyKeys), + Message: fmt.Sprintf("warning: deprecated settings were used in the configuration: %v; consider updating to the new settings: https://fleetdm.com/docs/using-fleet/configuration-files#settings", + legacyKeys), } } @@ -685,16 +623,16 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } - switch ndesStatus { - case ndesStatusAdded: + switch caStatus.ndes { + case caStatusAdded: if err = svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityAddedNDESSCEPProxy{}); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for added NDES SCEP proxy") } - case ndesStatusEdited: + case caStatusEdited: if err = svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEditedNDESSCEPProxy{}); err != nil { return nil, ctxerr.Wrap(ctx, err, "create activity for edited NDES SCEP proxy") } - case ndesStatusDeleted: + case caStatusDeleted: // Delete stored password if err := svc.ds.HardDeleteMDMConfigAsset(ctx, fleet.MDMAssetNDESPassword); err != nil { return nil, ctxerr.Wrap(ctx, err, "delete NDES SCEP password") @@ -705,6 +643,40 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle default: // No change, no activity. } + for caName, status := range caStatus.digicert { + switch status { + case caStatusAdded: + if err = svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityAddedDigiCert{Name: caName}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for added DigiCert CA") + } + case caStatusEdited: + if err = svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEditedDigiCert{Name: caName}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for edited DigiCert CA") + } + case caStatusDeleted: + // TODO(#26603): Delete API token + if err = svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityDeletedDigiCert{Name: caName}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for deleted DigiCert CA") + } + } + } + for caName, status := range caStatus.customSCEPProxy { + switch status { + case caStatusAdded: + if err = svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityAddedCustomSCEPProxy{Name: caName}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for added Custom SCEP Proxy") + } + case caStatusEdited: + if err = svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEditedCustomSCEPProxy{Name: caName}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for edited Custom SCEP Proxy") + } + case caStatusDeleted: + // TODO(#26603): Delete challenge + if err = svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityDeletedCustomSCEPProxy{Name: caName}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for deleted Custom SCEP Proxy") + } + } + } if oldAppConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value != appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value && appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value == "" { @@ -943,6 +915,303 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle return obfuscatedAppConfig, nil } +func (svc *Service) processAppConfigCAs(ctx context.Context, newAppConfig *fleet.AppConfig, oldAppConfig *fleet.AppConfig, + appConfig *fleet.AppConfig, invalid *fleet.InvalidArgumentError) appConfigCAStatus { + + var invalidLicense bool + fleetLicense, _ := license.FromContext(ctx) + if newAppConfig.Integrations.NDESSCEPProxy.Set && newAppConfig.Integrations.NDESSCEPProxy.Valid && !fleetLicense.IsPremium() { + invalid.Append("integrations.ndes_scep_proxy", ErrMissingLicense.Error()) + appConfig.Integrations.NDESSCEPProxy.Valid = false + invalidLicense = true + } + if newAppConfig.Integrations.DigiCert.Set && newAppConfig.Integrations.DigiCert.Valid && !fleetLicense.IsPremium() { + invalid.Append("integrations.digicert", ErrMissingLicense.Error()) + appConfig.Integrations.DigiCert.Valid = false + invalidLicense = true + } + if newAppConfig.Integrations.CustomSCEPProxy.Set && newAppConfig.Integrations.CustomSCEPProxy.Valid && !fleetLicense.IsPremium() { + invalid.Append("integrations.custom_scep_proxy", ErrMissingLicense.Error()) + appConfig.Integrations.CustomSCEPProxy.Valid = false + invalidLicense = true + } + result := appConfigCAStatus{ + digicert: make(map[string]caStatusType), + customSCEPProxy: make(map[string]caStatusType), + } + if invalidLicense { + return result + } + + // Validate NDES SCEP URLs if they changed. Validation is done in both dry run and normal mode. + 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 + if oldAppConfig.Integrations.NDESSCEPProxy.Valid { + result.ndes = caStatusDeleted + } + 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 + if len(svc.config.Server.PrivateKey) == 0 { + invalid.Append("integrations.ndes_scep_proxy", + "Cannot encrypt NDES password. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } + + validateAdminURL, validateSCEPURL := false, false + newSCEPProxy := appConfig.Integrations.NDESSCEPProxy.Value + if !oldAppConfig.Integrations.NDESSCEPProxy.Valid { + result.ndes = caStatusAdded + validateAdminURL, validateSCEPURL = true, true + } else { + oldSCEPProxy := oldAppConfig.Integrations.NDESSCEPProxy.Value + if newSCEPProxy.URL != oldSCEPProxy.URL { + result.ndes = caStatusEdited + validateSCEPURL = true + } + if newSCEPProxy.AdminURL != oldSCEPProxy.AdminURL || + newSCEPProxy.Username != oldSCEPProxy.Username || + (newSCEPProxy.Password != "" && newSCEPProxy.Password != fleet.MaskedPassword) { + result.ndes = caStatusEdited + 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()) + } + } + } + + var ( + allCANames = make(map[string]struct{}) + additionalDigiCertValidationNeeded bool + additionalCustomSCEPValidationNeeded bool + ) + + switch { + case !newAppConfig.Integrations.DigiCert.Set: + // Nothing to set -- keep the old value + appConfig.Integrations.DigiCert = oldAppConfig.Integrations.DigiCert + // Populate allCANames so we can check for uniqueness against custom SCEP proxy names + for _, ca := range oldAppConfig.Integrations.DigiCert.Value { + allCANames[ca.Name] = struct{}{} + } + case !newAppConfig.Integrations.DigiCert.Valid || len(newAppConfig.Integrations.DigiCert.Value) == 0: + // User is explicitly clearing this setting + appConfig.Integrations.DigiCert.Valid = false + for _, ca := range oldAppConfig.Integrations.DigiCert.Value { + result.digicert[ca.Name] = caStatusDeleted + } + default: + // We clear DigiCert CAs because we will repopulate them as we diff old vs new CAs + appConfig.Integrations.DigiCert.Value = nil + if len(svc.config.Server.PrivateKey) == 0 { + invalid.Append("integrations.digicert", + "Cannot encrypt DigiCert API token. Missing required private key. Learn how to configure the private key here: https://fleetdm."+ + "com/learn-more-about/fleet-server-private-key") + break + } + additionalDigiCertValidationNeeded = true + for _, ca := range newAppConfig.Integrations.DigiCert.Value { + ca.Name = fleet.Preprocess(ca.Name) + if !validateCAName(ca.Name, "digicert", allCANames, invalid) { + additionalDigiCertValidationNeeded = false + continue + } + // Validate URL + ca.URL = fleet.Preprocess(ca.URL) + if u, err := url.ParseRequestURI(ca.URL); err != nil { + invalid.Append("integrations.digicert.url", err.Error()) + additionalDigiCertValidationNeeded = false + continue + } else if u.Scheme != "https" && u.Scheme != "http" { + invalid.Append("integrations.digicert.url", "digicert URL must be https or http") + additionalDigiCertValidationNeeded = false + continue + } + + if len(ca.CertificateUserPrincipalNames) > 1 { + invalid.Append("integrations.digicert.certificate_user_principal_names", + "DigiCert CA can only have one certificate user principal name") + additionalDigiCertValidationNeeded = false + continue + } + ca.ProfileID = fleet.Preprocess(ca.ProfileID) + appConfig.Integrations.DigiCert.Value = append(appConfig.Integrations.DigiCert.Value, ca) + } + } + + switch { + case !newAppConfig.Integrations.CustomSCEPProxy.Set: + // Nothing to set -- keep the old value + appConfig.Integrations.CustomSCEPProxy = oldAppConfig.Integrations.CustomSCEPProxy + for _, ca := range oldAppConfig.Integrations.CustomSCEPProxy.Value { + if _, ok := allCANames[ca.Name]; ok { + // This issue is caused by the new DigiCert CA added above + invalid.Append("integrations.digicert.name", fmt.Sprintf("Couldn’t edit certificate authority. "+ + "\"%s\" name is already used by another DigiCert certificate authority. Please choose a different name and try again.", ca.Name)) + additionalDigiCertValidationNeeded = false + continue + } + allCANames[ca.Name] = struct{}{} + } + case !newAppConfig.Integrations.CustomSCEPProxy.Valid || len(newAppConfig.Integrations.CustomSCEPProxy.Value) == 0: + // User is explicitly clearing this setting + appConfig.Integrations.CustomSCEPProxy.Valid = false + for _, ca := range oldAppConfig.Integrations.CustomSCEPProxy.Value { + result.customSCEPProxy[ca.Name] = caStatusDeleted + } + default: + // We clear custom SCEP CAs because we will repopulate them as we diff old vs new CAs + appConfig.Integrations.CustomSCEPProxy.Value = nil + if len(svc.config.Server.PrivateKey) == 0 { + invalid.Append("integrations.custom_scep_proxy", + "Cannot encrypt SCEP challenge. Missing required private key. Learn how to configure the private key here: "+ + "https://fleetdm.com/learn-more-about/fleet-server-private-key") + break + } + additionalCustomSCEPValidationNeeded = true + for _, ca := range newAppConfig.Integrations.CustomSCEPProxy.Value { + ca.Name = fleet.Preprocess(ca.Name) + if !validateCAName(ca.Name, "custom_scep_proxy", allCANames, invalid) { + additionalCustomSCEPValidationNeeded = false + continue + } + ca.URL = fleet.Preprocess(ca.URL) + // Validate URL + if u, err := url.ParseRequestURI(ca.URL); err != nil { + invalid.Append("integrations.custom_scep_proxy.url", err.Error()) + additionalCustomSCEPValidationNeeded = false + continue + } else if u.Scheme != "https" && u.Scheme != "http" { + invalid.Append("integrations.custom_scep_proxy.url", "custom_scep_proxy URL must be https or http") + additionalCustomSCEPValidationNeeded = false + continue + } + appConfig.Integrations.CustomSCEPProxy.Value = append(appConfig.Integrations.CustomSCEPProxy.Value, ca) + } + } + + // if additional validation is needed, get all the config assets from DB + + if additionalDigiCertValidationNeeded { + oldCAs := oldAppConfig.Integrations.DigiCert.Value + remainingOldCAs := make([]fleet.DigiCertIntegration, 0, len(oldAppConfig.Integrations.DigiCert.Value)) + for _, oldCA := range oldCAs { + var found bool + for _, newCA := range newAppConfig.Integrations.DigiCert.Value { + if oldCA.Name == newCA.Name { + found = true + break + } + } + if !found { + result.digicert[oldCA.Name] = caStatusDeleted + } else { + remainingOldCAs = append(remainingOldCAs, oldCA) + } + } + + for _, newCA := range appConfig.Integrations.DigiCert.Value { + var found bool + for _, oldCA := range remainingOldCAs { + switch { + case newCA.Equals(&oldCA): + // same + found = true + case newCA.Name == oldCA.Name: + // changed + if newCA.URL != oldCA.URL && (len(newCA.APIToken) == 0 || newCA.APIToken == fleet.MaskedPassword) { + invalid.Append("integrations.digicert.api_token", + fmt.Sprintf("DigiCert API token must be set when modifying URL of an existing CA: %s", newCA.Name)) + } else { + result.digicert[newCA.Name] = caStatusEdited + } + found = true + } + } + if !found { + if len(newCA.APIToken) == 0 || newCA.APIToken == fleet.MaskedPassword { + invalid.Append("integrations.digicert.api_token", + fmt.Sprintf("DigiCert API token must be set on CA: %s", newCA.Name)) + } else { + result.digicert[newCA.Name] = caStatusAdded + } + } + // TODO(#26603): Validate all added and modified DigiCert CAs by making an API call and confirming the profile ID exists + } + // TODO(#26603): Save/encrypt all added/edited API keys + /* We keep API tokens unencrypted for now for frontend testing + for i := range appConfig.Integrations.DigiCert.Value { + appConfig.Integrations.DigiCert.Value[i].APIToken = fleet.MaskedPassword + } + */ + } + + if additionalCustomSCEPValidationNeeded { + svc.logger.Log("msg", "TODO for #26603") + } + return result +} + +func validateCAName(name string, caType string, allCANames map[string]struct{}, invalid *fleet.InvalidArgumentError) bool { + if name == "NDES" { + invalid.Append("integrations."+caType+".name", "CA name cannot be NDES") + return false + } + if len(name) == 0 { + invalid.Append("integrations."+caType+".name", "CA name cannot be empty") + return false + } + if len(name) > 255 { + invalid.Append("integrations."+caType+".name", "CA name cannot be longer than 255 characters") + return false + } + if !isAlphanumeric(name) { + invalid.Append("integrations."+caType+".name", + fmt.Sprintf("Couldn’t edit integrations.%s. Invalid characters in the \"name\" field. Only letters, "+ + "numbers and underscores allowed. %s", + caType, name)) + return false + } + if _, ok := allCANames[name]; ok { + invalid.Append("integrations."+caType+".name", fmt.Sprintf("Couldn’t edit certificate authority. "+ + "\"%s\" name is already used by another DigiCert certificate authority. Please choose a different name and try again.", name)) + return false + } + allCANames[name] = struct{}{} + return true +} + +type appConfigCAStatus struct { + ndes caStatusType + digicert map[string]caStatusType + customSCEPProxy map[string]caStatusType +} + +type caStatusType string + +const ( + caStatusAdded caStatusType = "added" + caStatusEdited caStatusType = "edited" + caStatusDeleted caStatusType = "deleted" +) + // processAppleOSUpdateSettings updates the OS updates configuration if the minimum version+deadline are updated. func (svc *Service) processAppleOSUpdateSettings( ctx context.Context, @@ -1687,3 +1956,9 @@ func (svc *Service) HostFeatures(ctx context.Context, host *fleet.Host) (*fleet. } return &appConfig.Features, nil } + +var alphanumeric = regexp.MustCompile(`^\w+$`) + +func isAlphanumeric(s string) bool { + return alphanumeric.MatchString(s) +} diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 263362d1b4..4eb6e850d8 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -18,6 +18,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" @@ -1739,3 +1740,305 @@ func TestModifyAppConfigForNDESSCEPProxy(t *testing.T) { _, err = svc.ModifyAppConfig(ctx, []byte(jsonPayload), fleet.ApplySpecOptions{}) assert.ErrorContains(t, err, "private key") } + +func TestAppConfigCAs(t *testing.T) { + t.Parallel() + + type myTest struct { + ctx context.Context + svc *Service + appConfig *fleet.AppConfig + newAppConfig *fleet.AppConfig + oldAppConfig *fleet.AppConfig + invalid *fleet.InvalidArgumentError + } + + newTest := func() myTest { + mt := myTest{ + ctx: license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium}), + invalid: &fleet.InvalidArgumentError{}, + newAppConfig: getAppConfigWithDigiCertIntegration("WIFI"), + oldAppConfig: &fleet.AppConfig{}, + appConfig: &fleet.AppConfig{}, + svc: &Service{}, + } + mt.svc.config.Server.PrivateKey = "exists" + return mt + } + + t.Run("free license", func(t *testing.T) { + mt := newTest() + mt.ctx = license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierFree}) + mt.newAppConfig = &fleet.AppConfig{} + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + assert.Empty(t, mt.invalid.Errors) + assert.Empty(t, status.ndes) + assert.Empty(t, status.digicert) + assert.Empty(t, status.customSCEPProxy) + + mt.invalid = &fleet.InvalidArgumentError{} + mt.newAppConfig = &fleet.AppConfig{} + mt.newAppConfig.Integrations.DigiCert.Set = true + mt.newAppConfig.Integrations.DigiCert.Valid = true + status = mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, "digicert", ErrMissingLicense.Error()) + + mt.invalid = &fleet.InvalidArgumentError{} + mt.newAppConfig = &fleet.AppConfig{} + mt.newAppConfig.Integrations.CustomSCEPProxy.Set = true + mt.newAppConfig.Integrations.CustomSCEPProxy.Valid = true + status = mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, "custom_scep_proxy", ErrMissingLicense.Error()) + }) + + t.Run("digicert keep old value", func(t *testing.T) { + mt := newTest() + mt.ctx = license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium}) + mt.oldAppConfig = mt.newAppConfig + mt.appConfig = mt.oldAppConfig.Copy() + mt.newAppConfig = &fleet.AppConfig{} + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + assert.Empty(t, mt.invalid.Errors) + assert.Empty(t, status.ndes) + assert.Empty(t, status.digicert) + assert.Empty(t, status.customSCEPProxy) + assert.Len(t, mt.appConfig.Integrations.DigiCert.Value, 1) + }) + + t.Run("missing server private key", func(t *testing.T) { + mt := newTest() + mt.svc.config.Server.PrivateKey = "" + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert", "private key") + + // TODO: Test custom SCEP + }) + + t.Run("invalid digicert integration name", func(t *testing.T) { + testCases := []struct { + testName string + name string + errorContains []string + }{ + { + testName: "empty", + name: "", + errorContains: []string{"integrations.digicert.name", "CA name cannot be empty"}, + }, + { + testName: "NDES", + name: "NDES", + errorContains: []string{"integrations.digicert.name", "CA name cannot be NDES"}, + }, + { + testName: "too long", + name: strings.Repeat("a", 256), + errorContains: []string{"integrations.digicert.name", "CA name cannot be longer than"}, + }, + { + testName: "invalid characters", + name: "a/b", + errorContains: []string{"integrations.digicert.name", "Only letters, numbers and underscores allowed"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + mt := newTest() + mt.newAppConfig = getAppConfigWithDigiCertIntegration(tc.name) + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, tc.errorContains...) + }) + } + }) + + t.Run("invalid digicert URL", func(t *testing.T) { + mt := newTest() + mt.newAppConfig.Integrations.DigiCert.Value[0].URL = "" + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.url", + "empty url") + + mt = newTest() + mt.newAppConfig.Integrations.DigiCert.Value[0].URL = "nonhttp://bad.com" + status = mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.url", + "URL must be https or http") + }) + + t.Run("duplicate digicert integration name", func(t *testing.T) { + mt := newTest() + mt.newAppConfig.Integrations.DigiCert.Value = append(mt.newAppConfig.Integrations.DigiCert.Value, + mt.newAppConfig.Integrations.DigiCert.Value[0]) + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.name", + "name is already used by another DigiCert certificate authority") + }) + + t.Run("digicert more than 1 user principal name", func(t *testing.T) { + mt := newTest() + mt.newAppConfig.Integrations.DigiCert.Value[0].CertificateUserPrincipalNames = append(mt.newAppConfig.Integrations.DigiCert.Value[0].CertificateUserPrincipalNames, + "another") + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.certificate_user_principal_names", + "one certificate user principal name") + }) + + t.Run("digicert API token not set", func(t *testing.T) { + mt := newTest() + mt.newAppConfig.Integrations.DigiCert.Value[0].APIToken = fleet.MaskedPassword + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.api_token", "DigiCert API token must be set") + }) + + t.Run("digicert happy path -- add one", func(t *testing.T) { + mt := newTest() + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + assert.Empty(t, mt.invalid.Errors) + require.Len(t, status.digicert, 1) + assert.Equal(t, caStatusAdded, status.digicert[mt.newAppConfig.Integrations.DigiCert.Value[0].Name]) + require.Len(t, mt.appConfig.Integrations.DigiCert.Value, 1) + assert.True(t, mt.newAppConfig.Integrations.DigiCert.Value[0].Equals(&mt.appConfig.Integrations.DigiCert.Value[0])) + }) + + t.Run("digicert happy path -- delete one", func(t *testing.T) { + mt := newTest() + mt.oldAppConfig = mt.newAppConfig + mt.appConfig = mt.oldAppConfig.Copy() + mt.newAppConfig = &fleet.AppConfig{ + Integrations: fleet.Integrations{ + DigiCert: optjson.Slice[fleet.DigiCertIntegration]{ + Set: true, + Valid: true, + }, + }, + } + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + assert.Empty(t, mt.invalid.Errors) + require.Len(t, status.digicert, 1) + assert.Equal(t, caStatusDeleted, status.digicert[mt.oldAppConfig.Integrations.DigiCert.Value[0].Name]) + assert.False(t, mt.appConfig.Integrations.DigiCert.Valid) + }) + + t.Run("digicert API token not set on modify", func(t *testing.T) { + mt := newTest() + mt.oldAppConfig.Integrations.DigiCert.Value = append(mt.oldAppConfig.Integrations.DigiCert.Value, + mt.newAppConfig.Integrations.DigiCert.Value[0]) + mt.appConfig = mt.oldAppConfig.Copy() + mt.newAppConfig.Integrations.DigiCert.Value[0].URL = "https://new.com" + mt.newAppConfig.Integrations.DigiCert.Value[0].APIToken = "" + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + checkExpectedCAValidationError(t, mt.invalid, status, "integrations.digicert.api_token", "DigiCert API token must be set when modifying") + }) + + t.Run("digicert happy path -- add one, delete one, modify one", func(t *testing.T) { + mt := newTest() + mt.newAppConfig.Integrations.DigiCert = optjson.Slice[fleet.DigiCertIntegration]{ + Set: true, + Valid: true, + Value: []fleet.DigiCertIntegration{ + { + Name: "add", + URL: "https://example.com", + APIToken: "api_token", + ProfileID: "profile_id", + CertificateCommonName: "common_name", + CertificateUserPrincipalNames: []string{"user_principal_name"}, + CertificateSeatID: "seat_id", + }, + { + Name: "modify", + URL: "https://example.com", + APIToken: "api_token", + ProfileID: "profile_id", + CertificateCommonName: "common_name", + CertificateUserPrincipalNames: nil, + CertificateSeatID: "seat_id", + }, + { + Name: "same", + URL: "https://example.com", + APIToken: "api_token", + ProfileID: "profile_id", + CertificateCommonName: "", + CertificateUserPrincipalNames: nil, + CertificateSeatID: "seat_id", + }, + }, + } + mt.oldAppConfig.Integrations.DigiCert = optjson.Slice[fleet.DigiCertIntegration]{ + Set: true, + Valid: true, + Value: []fleet.DigiCertIntegration{ + { + Name: "delete", + URL: "https://example.com", + APIToken: "api_token", + ProfileID: "profile_id", + CertificateCommonName: "common_name", + CertificateUserPrincipalNames: []string{"user_principal_name"}, + CertificateSeatID: "seat_id", + }, + { + Name: "modify", + URL: "https://example.com", + APIToken: "api_token", + ProfileID: "profile_id", + CertificateCommonName: "common_name", + CertificateUserPrincipalNames: []string{"user_principal_name"}, + CertificateSeatID: "seat_id", + }, + { + Name: "same", + URL: "https://example.com", + APIToken: "api_token", + ProfileID: "profile_id", + CertificateCommonName: "", + CertificateUserPrincipalNames: nil, + CertificateSeatID: "seat_id", + }, + }, + } + mt.appConfig = mt.oldAppConfig.Copy() + status := mt.svc.processAppConfigCAs(mt.ctx, mt.newAppConfig, mt.oldAppConfig, mt.appConfig, mt.invalid) + assert.Empty(t, mt.invalid.Errors) + require.Len(t, status.digicert, 3) + assert.Equal(t, caStatusAdded, status.digicert["add"]) + assert.Equal(t, caStatusEdited, status.digicert["modify"]) + assert.Equal(t, caStatusDeleted, status.digicert["delete"]) + require.Len(t, mt.appConfig.Integrations.DigiCert.Value, 3) + }) + +} + +func checkExpectedCAValidationError(t *testing.T, invalid *fleet.InvalidArgumentError, status appConfigCAStatus, contains ...string) { + assert.Len(t, invalid.Errors, 1) + for _, expected := range contains { + assert.Contains(t, invalid.Error(), expected) + } + assert.Empty(t, status.ndes) + assert.Empty(t, status.digicert) + assert.Empty(t, status.customSCEPProxy) +} + +func getAppConfigWithDigiCertIntegration(name string) *fleet.AppConfig { + digiCertCA := fleet.DigiCertIntegration{ + Name: name, + URL: "https://example.com", + APIToken: "api_token", + ProfileID: "profile_id", + CertificateCommonName: "common_name", + CertificateUserPrincipalNames: []string{"user_principal_name"}, + CertificateSeatID: "seat_id", + } + newAppConfig := &fleet.AppConfig{ + Integrations: fleet.Integrations{ + DigiCert: optjson.Slice[fleet.DigiCertIntegration]{ + Set: true, + Valid: true, + Value: []fleet.DigiCertIntegration{digiCertCA}, + }, + }, + } + return newAppConfig +} diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 2ab3173f21..5973d965ed 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -95,6 +95,17 @@ github.com/fleetdm/fleet/v4/server/fleet/ZendeskIntegration EnableSoftwareVulner github.com/fleetdm/fleet/v4/server/fleet/Integrations GoogleCalendar []*fleet.GoogleCalendarIntegration github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration Domain string github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration ApiKey map[string]string +github.com/fleetdm/fleet/v4/server/fleet/Integrations DigiCert optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.DigiCertIntegration] +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.DigiCertIntegration] Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.DigiCertIntegration] Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.DigiCertIntegration] Value []fleet.DigiCertIntegration +github.com/fleetdm/fleet/v4/server/fleet/DigiCertIntegration Name string +github.com/fleetdm/fleet/v4/server/fleet/DigiCertIntegration URL string +github.com/fleetdm/fleet/v4/server/fleet/DigiCertIntegration APIToken string +github.com/fleetdm/fleet/v4/server/fleet/DigiCertIntegration ProfileID string +github.com/fleetdm/fleet/v4/server/fleet/DigiCertIntegration CertificateCommonName string +github.com/fleetdm/fleet/v4/server/fleet/DigiCertIntegration CertificateUserPrincipalNames []string +github.com/fleetdm/fleet/v4/server/fleet/DigiCertIntegration CertificateSeatID string github.com/fleetdm/fleet/v4/server/fleet/Integrations NDESSCEPProxy optjson.Any[github.com/fleetdm/fleet/v4/server/fleet.NDESSCEPProxyIntegration] github.com/fleetdm/fleet/v4/pkg/optjson/Any[github.com/fleetdm/fleet/v4/server/fleet.NDESSCEPProxyIntegration] Set bool github.com/fleetdm/fleet/v4/pkg/optjson/Any[github.com/fleetdm/fleet/v4/server/fleet.NDESSCEPProxyIntegration] Valid bool @@ -103,6 +114,13 @@ github.com/fleetdm/fleet/v4/server/fleet/NDESSCEPProxyIntegration URL string github.com/fleetdm/fleet/v4/server/fleet/NDESSCEPProxyIntegration AdminURL string github.com/fleetdm/fleet/v4/server/fleet/NDESSCEPProxyIntegration Username string github.com/fleetdm/fleet/v4/server/fleet/NDESSCEPProxyIntegration Password string +github.com/fleetdm/fleet/v4/server/fleet/Integrations CustomSCEPProxy optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.CustomSCEPProxyIntegration] +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.CustomSCEPProxyIntegration] Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.CustomSCEPProxyIntegration] Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.CustomSCEPProxyIntegration] Value []fleet.CustomSCEPProxyIntegration +github.com/fleetdm/fleet/v4/server/fleet/CustomSCEPProxyIntegration Name string +github.com/fleetdm/fleet/v4/server/fleet/CustomSCEPProxyIntegration URL string +github.com/fleetdm/fleet/v4/server/fleet/CustomSCEPProxyIntegration Challenge string github.com/fleetdm/fleet/v4/server/fleet/AppConfig MDM fleet.MDM github.com/fleetdm/fleet/v4/server/fleet/MDM AppleServerURL string github.com/fleetdm/fleet/v4/server/fleet/MDM DeprecatedAppleBMDefaultTeam string