diff --git a/changes/22813-software-scope-labels b/changes/22813-software-scope-labels new file mode 100644 index 0000000000..4a4681a2c6 --- /dev/null +++ b/changes/22813-software-scope-labels @@ -0,0 +1 @@ +- Added features to scope Fleet-maintained apps and custom packages via labels in UI, API, and CLI. diff --git a/changes/24533-skip-policy b/changes/24533-skip-policy new file mode 100644 index 0000000000..4de7d634a3 --- /dev/null +++ b/changes/24533-skip-policy @@ -0,0 +1 @@ +- Adds functionality for skipping automatic installs if the software is not scoped to the host via labels. \ No newline at end of file diff --git a/changes/24534-hide-software-2 b/changes/24534-hide-software-2 new file mode 100644 index 0000000000..9b73513ddd --- /dev/null +++ b/changes/24534-hide-software-2 @@ -0,0 +1 @@ +- Add functionality to filter host software based on label scoping. \ No newline at end of file diff --git a/changes/24536-prevent-label-deletion-if-referenced-by-software b/changes/24536-prevent-label-deletion-if-referenced-by-software new file mode 100644 index 0000000000..ef3e4753f3 --- /dev/null +++ b/changes/24536-prevent-label-deletion-if-referenced-by-software @@ -0,0 +1 @@ +* Added a validation to prevent label deletion if it is used to scope the hosts targeted by a software installer. diff --git a/changes/24538-24542-UI-for-scope-software-via-labels b/changes/24538-24542-UI-for-scope-software-via-labels new file mode 100644 index 0000000000..d8d65558df --- /dev/null +++ b/changes/24538-24542-UI-for-scope-software-via-labels @@ -0,0 +1 @@ +- add UI for scoping software via labels diff --git a/changes/24663-software-scoped-via-labels-gitops b/changes/24663-software-scoped-via-labels-gitops new file mode 100644 index 0000000000..4bb1c15cf2 --- /dev/null +++ b/changes/24663-software-scoped-via-labels-gitops @@ -0,0 +1 @@ +* Added `fleetctl gitops` support to scope software installers by labels, with the `labels_include_any` or `labels_exclude_any` conditions. diff --git a/changes/24792-update-software-installer-activities b/changes/24792-update-software-installer-activities new file mode 100644 index 0000000000..206fa80dac --- /dev/null +++ b/changes/24792-update-software-installer-activities @@ -0,0 +1 @@ +* Added the `labels_include_any` and `labels_exclude_any` fields to the software installer activities. diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 15baf08b46..93d85eff04 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -1910,6 +1910,10 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) { {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"}, {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, + {"testdata/gitops/team_software_installer_invalid_both_include_exclude.yml", `only one of "labels_exclude_any" or "labels_include_any" can be specified`}, + {"testdata/gitops/team_software_installer_valid_include.yml", ""}, + {"testdata/gitops/team_software_installer_valid_exclude.yml", ""}, + {"testdata/gitops/team_software_installer_invalid_unknown_label.yml", "some or all the labels provided don't exist"}, // team tests for setup experience software/script {"testdata/gitops/team_setup_software_valid.yml", ""}, {"testdata/gitops/team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"}, @@ -1939,6 +1943,22 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) { Teams: nil, }, nil } + labelToIDs := map[string]uint{ + fleet.BuiltinLabelMacOS14Plus: 1, + "a": 2, + "b": 3, + } + ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { + // for this test, recognize labels a and b (as well as the built-in macos 14+ one) + ret := make(map[string]uint) + for _, lbl := range labels { + id, ok := labelToIDs[lbl] + if ok { + ret[lbl] = id + } + } + return ret, nil + } _, err = runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { @@ -1992,6 +2012,10 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { {"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"}, {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, + {"testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml", `only one of "labels_exclude_any" or "labels_include_any" can be specified`}, + {"testdata/gitops/no_team_software_installer_valid_include.yml", ""}, + {"testdata/gitops/no_team_software_installer_valid_exclude.yml", ""}, + {"testdata/gitops/no_team_software_installer_invalid_unknown_label.yml", "some or all the labels provided don't exist"}, // No team tests for setup experience software/script {"testdata/gitops/no_team_setup_software_valid.yml", ""}, {"testdata/gitops/no_team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"}, @@ -2021,6 +2045,22 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { Teams: nil, }, nil } + labelToIDs := map[string]uint{ + fleet.BuiltinLabelMacOS14Plus: 1, + "a": 2, + "b": 3, + } + ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { + // for this test, recognize labels a and b (as well as the built-in macos 14+ one) + ret := make(map[string]uint) + for _, lbl := range labels { + id, ok := labelToIDs[lbl] + if ok { + ret[lbl] = id + } + } + return ret, nil + } t.Setenv("APPLE_BM_DEFAULT_TEAM", "") globalFile := "./testdata/gitops/global_config_no_paths.yml" diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml new file mode 100644 index 0000000000..a114afa6e4 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml @@ -0,0 +1,20 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh + labels_include_any: + - a + labels_exclude_any: + - b + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_unknown_label.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_unknown_label.yml new file mode 100644 index 0000000000..73ebf1310a --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_unknown_label.yml @@ -0,0 +1,18 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh + labels_exclude_any: + - zzz + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_exclude.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_exclude.yml new file mode 100644 index 0000000000..bbe779982d --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_exclude.yml @@ -0,0 +1,19 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh + labels_exclude_any: + - a + - b + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_include.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_include.yml new file mode 100644 index 0000000000..225e4d6b27 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid_include.yml @@ -0,0 +1,19 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh + labels_include_any: + - a + - b + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_both_include_exclude.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_both_include_exclude.yml new file mode 100644 index 0000000000..63911bd112 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_both_include_exclude.yml @@ -0,0 +1,29 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby_apply.yml + post_install_script: + path: lib/post_install_ruby.sh + labels_include_any: + - a + labels_exclude_any: + - b + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_unknown_label.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_unknown_label.yml new file mode 100644 index 0000000000..090f14b2f9 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_unknown_label.yml @@ -0,0 +1,27 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby_apply.yml + post_install_script: + path: lib/post_install_ruby.sh + labels_include_any: + - zzz + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid_exclude.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid_exclude.yml new file mode 100644 index 0000000000..2bf6d967a2 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid_exclude.yml @@ -0,0 +1,27 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby_apply.yml + post_install_script: + path: lib/post_install_ruby.sh + labels_exclude_any: + - b + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid_include.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid_include.yml new file mode 100644 index 0000000000..8a6ebf5821 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid_include.yml @@ -0,0 +1,27 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby_apply.yml + post_install_script: + path: lib/post_install_ruby.sh + labels_include_any: + - a + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/docs/Contributing/Audit-logs.md b/docs/Contributing/Audit-logs.md index 6e0e4462ae..fc393fe5c0 100644 --- a/docs/Contributing/Audit-logs.md +++ b/docs/Contributing/Audit-logs.md @@ -1242,6 +1242,8 @@ This activity contains the following fields: - "team_id": The ID of the team to which this software was added. `null` if it was added to no team. - "self_service": Whether the software is available for installation by the end user. - "software_title_id": ID of the added software title. +- "labels_include_any": Target hosts that have any label in the array. +- "labels_exclude_any": Target hosts that don't have any label in the array. #### Example @@ -1252,7 +1254,17 @@ This activity contains the following fields: "team_name": "Workstations", "team_id": 123, "self_service": true, - "software_title_id": 2234 + "software_title_id": 2234, + "labels_include_any": [ + { + "name": "Engineering", + "id": 12 + }, + { + "name": "Product", + "id": 17 + } + ] } ``` @@ -1266,6 +1278,8 @@ This activity contains the following fields: - "team_name": Name of the team on which this software was updated. `null` if it was updated on no team. - "team_id": The ID of the team on which this software was updated. `null` if it was updated on no team. - "self_service": Whether the software is available for installation by the end user. +- "labels_include_any": Target hosts that have any label in the array. +- "labels_exclude_any": Target hosts that don't have any label in the array. #### Example @@ -1275,7 +1289,17 @@ This activity contains the following fields: "software_package": "FalconSensor-6.44.pkg", "team_name": "Workstations", "team_id": 123, - "self_service": true + "self_service": true, + "labels_include_any": [ + { + "name": "Engineering", + "id": 12 + }, + { + "name": "Product", + "id": 17 + } + ] } ``` @@ -1289,6 +1313,8 @@ This activity contains the following fields: - "team_name": Name of the team to which this software was added. `null` if it was added to no team. - "team_id": The ID of the team to which this software was added. `null` if it was added to no team. - "self_service": Whether the software was available for installation by the end user. +- "labels_include_any": Target hosts that have any label in the array. +- "labels_exclude_any": Target hosts that don't have any label in the array. #### Example @@ -1298,7 +1324,17 @@ This activity contains the following fields: "software_package": "FalconSensor-6.44.pkg", "team_name": "Workstations", "team_id": 123, - "self_service": true + "self_service": true, + "labels_include_any": [ + { + "name": "Engineering", + "id": 12 + }, + { + "name": "Product", + "id": 17 + } + ] } ``` diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index fea74bf6bc..f459d098cb 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -26,6 +26,7 @@ func (svc *Service) AddFleetMaintainedApp( appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, + labelsIncludeAny, labelsExcludeAny []string, ) (titleID uint, err error) { if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil { return 0, err @@ -36,6 +37,12 @@ func (svc *Service) AddFleetMaintainedApp( return 0, fleet.ErrNoContext } + // validate labels before we do anything else + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, labelsIncludeAny, labelsExcludeAny) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "validating software labels") + } + if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil { return 0, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error())) } @@ -119,6 +126,7 @@ func (svc *Service) AddFleetMaintainedApp( SelfService: selfService, InstallScript: installScript, UninstallScript: uninstallScript, + ValidatedLabels: validatedLabels, } // Create record in software installers table @@ -142,13 +150,16 @@ func (svc *Service) AddFleetMaintainedApp( teamName = &t.Name } + actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{ - SoftwareTitle: payload.Title, - SoftwarePackage: payload.Filename, - TeamName: teamName, - TeamID: payload.TeamID, - SelfService: payload.SelfService, - SoftwareTitleID: titleID, + SoftwareTitle: payload.Title, + SoftwarePackage: payload.Filename, + TeamName: teamName, + TeamID: payload.TeamID, + SelfService: payload.SelfService, + SoftwareTitleID: titleID, + LabelsIncludeAny: actLabelsIncl, + LabelsExcludeAny: actLabelsExcl, }); err != nil { return 0, ctxerr.Wrap(ctx, err, "creating activity for added software") } diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 82c626bda0..1773770fad 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -17,12 +17,12 @@ import ( "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/authz" + authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" - "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" @@ -37,6 +37,13 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return err } + // validate labels before we do anything else + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + if err != nil { + return ctxerr.Wrap(ctx, err, "validating software labels") + } + payload.ValidatedLabels = validatedLabels + vc, ok := viewer.FromContext(ctx) if !ok { return fleet.ErrNoContext @@ -86,13 +93,16 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. } // Create activity + actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{ - SoftwareTitle: payload.Title, - SoftwarePackage: payload.Filename, - TeamName: teamName, - TeamID: payload.TeamID, - SelfService: payload.SelfService, - SoftwareTitleID: titleID, + SoftwareTitle: payload.Title, + SoftwarePackage: payload.Filename, + TeamName: teamName, + TeamID: payload.TeamID, + SelfService: payload.SelfService, + SoftwareTitleID: titleID, + LabelsIncludeAny: actLabelsIncl, + LabelsExcludeAny: actLabelsExcl, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for added software") } @@ -100,6 +110,42 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return nil } +func ValidateSoftwareLabels(ctx context.Context, svc fleet.Service, labelsIncludeAny, labelsExcludeAny []string) (*fleet.LabelIdentsWithScope, error) { + if authctx, ok := authz_ctx.FromContext(ctx); !ok { + return nil, fleet.NewAuthRequiredError("validate software labels: missing authorization context") + } else if !authctx.Checked() { + return nil, fleet.NewAuthRequiredError("validate software labels: method requires previous authorization") + } + + var names []string + var scope fleet.LabelScope + switch { + case len(labelsIncludeAny) > 0 && len(labelsExcludeAny) > 0: + return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_any" or "labels_exclude_any" can be included.`} + case len(labelsIncludeAny) > 0: + names = labelsIncludeAny + scope = fleet.LabelScopeIncludeAny + case len(labelsExcludeAny) > 0: + names = labelsExcludeAny + scope = fleet.LabelScopeExcludeAny + } + + if len(names) == 0 { + // nothing to validate, return empty result + return &fleet.LabelIdentsWithScope{}, nil + } + + byName, err := svc.BatchValidateLabels(ctx, names) + if err != nil { + return nil, err + } + + return &fleet.LabelIdentsWithScope{ + LabelScope: scope, + ByName: byName, + }, nil +} + var packageIDRegex = regexp.MustCompile(`((("\$PACKAGE_ID")|(\$PACKAGE_ID))(?P\W|$))|(("\${PACKAGE_ID}")|(\${PACKAGE_ID}))`) func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) { @@ -141,7 +187,7 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. var teamName *string if *payload.TeamID != 0 { - t, err := svc.ds.Team(ctx, *payload.TeamID) + t, err := svc.ds.TeamWithoutExtras(ctx, *payload.TeamID) if err != nil { return nil, err } @@ -186,7 +232,8 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } if payload.SelfService == nil && payload.InstallerFile == nil && payload.PreInstallQuery == nil && - payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil { + payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil && + payload.LabelsIncludeAny == nil && payload.LabelsExcludeAny == nil { return existingInstaller, nil // no payload, noop } @@ -197,10 +244,24 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. dirty["SelfService"] = true } + shouldUpdateLabels, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "validating software labels for update") + } + if shouldUpdateLabels { + dirty["Labels"] = true + } + payload.ValidatedLabels = validatedLabels + + // activity team ID must be null if no team, not zero + var actTeamID *uint + if payload.TeamID != nil && *payload.TeamID != 0 { + actTeamID = payload.TeamID + } activity := fleet.ActivityTypeEditedSoftware{ SoftwareTitle: existingInstaller.SoftwareTitle, TeamName: teamName, - TeamID: payload.TeamID, + TeamID: actTeamID, SelfService: existingInstaller.SelfService, SoftwarePackage: &existingInstaller.Name, } @@ -343,6 +404,18 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } } + // now that the payload has been updated with any patches, we can set the + // final fields of the activity + actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels( + existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny) + if payload.ValidatedLabels != nil { + actLabelsIncl, actLabelsExcl = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) + } + activity.LabelsIncludeAny = actLabelsIncl + activity.LabelsExcludeAny = actLabelsExcl + if payload.SelfService != nil { + activity.SelfService = *payload.SelfService + } if err := svc.NewActivity(ctx, vc.User, activity); err != nil { return nil, ctxerr.Wrap(ctx, err, "creating activity for edited software") } @@ -363,6 +436,69 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return updatedInstaller, nil } +func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) { + if authctx, ok := authz_ctx.FromContext(ctx); !ok { + return false, nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context") + } else if !authctx.Checked() { + return false, nil, fleet.NewAuthRequiredError("batch validate labels: method requires previous authorization") + } + + if existingInstaller == nil { + return false, nil, errors.New("existing installer must be provided") + } + + if len(existingInstaller.LabelsIncludeAny) > 0 && len(existingInstaller.LabelsExcludeAny) > 0 { + return false, nil, errors.New("existing installer must have only one label scope") + } + + if includeAny == nil && excludeAny == nil { + // nothing to do + return false, nil, nil + } + + incoming, err := ValidateSoftwareLabels(ctx, svc, includeAny, excludeAny) + if err != nil { + return false, nil, err + } + + var prevScope fleet.LabelScope + var prevLabels []fleet.SoftwareScopeLabel + switch { + case len(existingInstaller.LabelsIncludeAny) > 0: + prevScope = fleet.LabelScopeIncludeAny + prevLabels = existingInstaller.LabelsIncludeAny + case len(existingInstaller.LabelsExcludeAny) > 0: + prevScope = fleet.LabelScopeExcludeAny + prevLabels = existingInstaller.LabelsExcludeAny + } + + prevByName := make(map[string]fleet.LabelIdent, len(prevLabels)) + for _, pl := range prevLabels { + prevByName[pl.LabelName] = fleet.LabelIdent{ + LabelID: pl.LabelID, + LabelName: pl.LabelName, + } + } + + if prevScope != incoming.LabelScope { + return true, incoming, nil + } + + if len(prevByName) != len(incoming.ByName) { + return true, incoming, nil + } + + // compare labels by name + for n, il := range incoming.ByName { + pl, ok := prevByName[n] + if !ok || pl != il { + return true, incoming, nil + } + } + + return false, nil, nil +} + func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { if teamID == nil { return fleet.NewInvalidArgumentError("team_id", "is required") @@ -441,20 +577,15 @@ func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.Sof teamName = &t.Name } - var teamID *uint - switch { - case meta.TeamID == nil: - teamID = ptr.Uint(0) - case meta.TeamID != nil: - teamID = meta.TeamID - } - + actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny) if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{ - SoftwareTitle: meta.SoftwareTitle, - SoftwarePackage: meta.Name, - TeamName: teamName, - TeamID: teamID, - SelfService: meta.SelfService, + SoftwareTitle: meta.SoftwareTitle, + SoftwarePackage: meta.Name, + TeamName: teamName, + TeamID: meta.TeamID, + SelfService: meta.SelfService, + LabelsIncludeAny: actLabelsIncl, + LabelsExcludeAny: actLabelsExcl, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for deleted software") } @@ -670,6 +801,18 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw // if we found an installer, use that if installer != nil { + // check the label scoping for this installer and host + scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt") + } + + if !scoped { + return &fleet.BadRequestError{ + Message: "Couldn't install. Host isn't member of the labels defined for this software title.", + } + } + lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID) if err != nil { return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID) @@ -1141,7 +1284,7 @@ const ( ) func (svc *Service) BatchSetSoftwareInstallers( - ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool, + ctx context.Context, tmName string, payloads []*fleet.SoftwareInstallerPayload, dryRun bool, ) (string, error) { if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil { return "", err @@ -1185,6 +1328,12 @@ func (svc *Service) BatchSetSoftwareInstallers( fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", payload.URL), ) } + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + if err != nil { + return "", err + } + payload.ValidatedLabels = validatedLabels + allScripts = append(allScripts, payload.InstallScript, payload.PostInstallScript, payload.UninstallScript) } @@ -1229,7 +1378,7 @@ func (svc *Service) softwareBatchUpload( requestUUID string, teamID *uint, userID uint, - payloads []fleet.SoftwareInstallerPayload, + payloads []*fleet.SoftwareInstallerPayload, dryRun bool, ) { var batchErr error @@ -1341,6 +1490,9 @@ func (svc *Service) softwareBatchUpload( UserID: userID, URL: p.URL, InstallDuringSetup: p.InstallDuringSetup, + LabelsIncludeAny: p.LabelsIncludeAny, + LabelsExcludeAny: p.LabelsExcludeAny, + ValidatedLabels: p.ValidatedLabels, } // set the filename before adding metadata, as it is used as fallback @@ -1497,6 +1649,17 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f } } + scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, host.ID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt") + } + + if !scoped { + return &fleet.BadRequestError{ + Message: "Couldn't install. Host isn't member of the labels defined for this software title.", + } + } + ext := filepath.Ext(installer.Name) requiredPlatform := packageExtensionToPlatform(ext) if requiredPlatform == "" { @@ -1642,3 +1805,40 @@ func UninstallSoftwareMigration( return nil } + +func activitySoftwareLabelsFromValidatedLabels(validatedLabels *fleet.LabelIdentsWithScope) (include, exclude []fleet.ActivitySoftwareLabel) { + if validatedLabels == nil || len(validatedLabels.ByName) == 0 { + return nil, nil + } + + excludeAny := validatedLabels.LabelScope == fleet.LabelScopeExcludeAny + labels := make([]fleet.ActivitySoftwareLabel, 0, len(validatedLabels.ByName)) + for _, lbl := range validatedLabels.ByName { + labels = append(labels, fleet.ActivitySoftwareLabel{ + ID: lbl.LabelID, + Name: lbl.LabelName, + }) + } + if excludeAny { + exclude = labels + } else { + include = labels + } + return include, exclude +} + +func activitySoftwareLabelsFromSoftwareScopeLabels(includeScopeLabels, excludeScopeLabels []fleet.SoftwareScopeLabel) (include, exclude []fleet.ActivitySoftwareLabel) { + for _, label := range includeScopeLabels { + include = append(include, fleet.ActivitySoftwareLabel{ + ID: label.LabelID, + Name: label.LabelName, + }) + } + for _, label := range excludeScopeLabels { + exclude = append(exclude, fleet.ActivitySoftwareLabel{ + ID: label.LabelID, + Name: label.LabelName, + }) + } + return include, exclude +} diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index 581ed06048..dcde9e7e56 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -15,7 +15,7 @@ import ( func TestPreProcessUninstallScript(t *testing.T) { t.Parallel() - var input = ` + input := ` blah$PACKAGE_IDS pkgids=$PACKAGE_ID they are $PACKAGE_ID, right $MY_SECRET? @@ -74,7 +74,6 @@ quotes and braces for ( "com.bar" )` assert.Equal(t, expected, payload.UninstallScript) - } func TestInstallUninstallAuth(t *testing.T) { @@ -93,7 +92,8 @@ func TestInstallUninstallAuth(t *testing.T) { }, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, - withScriptContents bool) (*fleet.SoftwareInstaller, error) { + withScriptContents bool, + ) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ Name: "installer.pkg", Platform: "darwin", @@ -104,14 +104,16 @@ func TestInstallUninstallAuth(t *testing.T) { return nil, nil } ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool, policyID *uint) (string, - error) { + error, + ) { return "request_id", nil } ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { return []byte("script"), nil } ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, - error) { + error, + ) { return &fleet.HostScriptResult{ ExecutionID: "execution_id", }, nil @@ -120,6 +122,10 @@ func TestInstallUninstallAuth(t *testing.T) { return nil } + ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { + return true, nil + } + testCases := []struct { name string user *fleet.User @@ -197,7 +203,6 @@ func TestUninstallSoftwareTitle(t *testing.T) { // Host scripts disabled host.ScriptsEnabled = ptr.Bool(false) require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg) - } func checkAuthErr(t *testing.T, shouldFail bool, err error) { diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index d01745f068..13a9a34b6f 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -200,6 +200,7 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { version: "1.2.3", uploaded_at: "2020-01-01T00:00:00.000Z", install_script: "sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /", + uninstall_script: "sudo rm -rf /Applications/Falcon.app", pre_install_query: "SELECT 1 FROM macos_profiles WHERE uuid='abc123';", post_install_script: "sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123", @@ -216,6 +217,8 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { last_install: null, last_uninstall: null, package_url: "", + labels_include_any: null, + labels_exclude_any: null, }; export const createMockSoftwarePackage = ( diff --git a/frontend/components/PlatformSelector/PlatformSelector.tsx b/frontend/components/PlatformSelector/PlatformSelector.tsx index 4cbd7f5686..3eb907f9b2 100644 --- a/frontend/components/PlatformSelector/PlatformSelector.tsx +++ b/frontend/components/PlatformSelector/PlatformSelector.tsx @@ -35,7 +35,7 @@ export const PlatformSelector = ({ return (
- Checks on: + Targets:
- Your policy will only be checked on the selected platform(s). + To apply the profile to new hosts, you'll have to delete it and + upload a new profile.
); diff --git a/frontend/components/PlatformSelector/_styles.scss b/frontend/components/PlatformSelector/_styles.scss index 63fe84e7e9..edd5155687 100644 --- a/frontend/components/PlatformSelector/_styles.scss +++ b/frontend/components/PlatformSelector/_styles.scss @@ -11,4 +11,8 @@ .form-field__label--disabled { color: $ui-fleet-black-50; } + + &__platform-checkbox-wrapper { + width: auto; + } } diff --git a/frontend/components/TargetLabelSelector/TargetLabelSelector.tests.tsx b/frontend/components/TargetLabelSelector/TargetLabelSelector.tests.tsx new file mode 100644 index 0000000000..6a38c3b26d --- /dev/null +++ b/frontend/components/TargetLabelSelector/TargetLabelSelector.tests.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { noop } from "lodash"; + +import TargetLabelSelector from "./TargetLabelSelector"; + +describe("TargetLabelSelector component", () => { + it("renders the custom target selector when the target type is 'Custom'", () => { + render( + + ); + + // custom target selector is rendering + expect(screen.getByRole("option", { name: "Include any" })).toBeVisible(); + + // lables are rendering + expect(screen.getByRole("checkbox", { name: "label 1" })).toBeVisible(); + expect(screen.getByRole("checkbox", { name: "label 2" })).toBeVisible(); + }); + + it("does not render the custom target selector when the target type is 'All hosts'", () => { + render( + + ); + + // custom target selector is not rendering + expect(screen.queryByRole("option", { name: "Include any" })).toBeNull(); + + // lables are not rendering + expect(screen.queryByRole("checkbox", { name: "label 1" })).toBeNull(); + expect(screen.queryByRole("checkbox", { name: "label 2" })).toBeNull(); + }); + + it("renders selected labels as checked", () => { + render( + + ); + + // lables are rendering + expect(screen.getByRole("checkbox", { name: "label 1" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "label 2" })).not.toBeChecked(); + }); +}); diff --git a/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx b/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx new file mode 100644 index 0000000000..d4fe8c6468 --- /dev/null +++ b/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx @@ -0,0 +1,210 @@ +import React, { ReactNode } from "react"; +import { Link } from "react-router"; +import classnames from "classnames"; + +import PATHS from "router/paths"; +import { IDropdownOption } from "interfaces/dropdownOption"; +import { ILabelSummary } from "interfaces/label"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import Radio from "components/forms/fields/Radio"; +import DataError from "components/DataError"; +import Spinner from "components/Spinner"; +import Checkbox from "components/forms/fields/Checkbox"; + +const baseClass = "target-label-selector"; + +export const listNamesFromSelectedLabels = (dict: Record) => { + return Object.entries(dict).reduce((acc, [labelName, isSelected]) => { + if (isSelected) { + acc.push(labelName); + } + return acc; + }, [] as string[]); +}; + +export const generateLabelKey = ( + target: string, + customTargetOption: string, + selectedLabels: Record +) => { + if (target !== "Custom") { + return {}; + } + + return { + [customTargetOption]: listNamesFromSelectedLabels(selectedLabels), + }; +}; + +interface ITargetChooserProps { + selectedTarget: string; + onSelect: (val: string) => void; +} + +const TargetChooser = ({ selectedTarget, onSelect }: ITargetChooserProps) => { + return ( +
+
Target
+ + +
+ ); +}; + +interface ILabelChooserProps { + isError: boolean; + isLoading: boolean; + labels: ILabelSummary[]; + selectedLabels: Record; + selectedCustomTarget: string; + customTargetOptions: IDropdownOption[]; + dropdownHelpText?: ReactNode; + onSelectCustomTarget: (val: string) => void; + onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void; +} + +const LabelChooser = ({ + isError, + isLoading, + labels, + dropdownHelpText, + selectedLabels, + selectedCustomTarget, + customTargetOptions, + onSelectCustomTarget, + onSelectLabel, +}: ILabelChooserProps) => { + const getHelpText = (value: string) => { + if (dropdownHelpText) return dropdownHelpText; + return customTargetOptions.find((option) => option.value === value) + ?.helpText; + }; + + const renderLabels = () => { + if (isLoading) { + return ; + } + + if (isError) { + return ; + } + + if (!labels.length) { + return ( +
+ + Add labels to target + specific hosts. + +
+ ); + } + + return labels.map((label) => { + return ( +
+ +
{label.name}
+
+ ); + }); + }; + + return ( +
+ +
+ {getHelpText(selectedCustomTarget)} +
+
{renderLabels()}
+
+ ); +}; + +interface ITargetLabelSelectorProps { + selectedTargetType: string; + selectedCustomTarget: string; + customTargetOptions: IDropdownOption[]; + selectedLabels: Record; + labels: ILabelSummary[]; + /** set this prop to show a help text. If it is encluded then it will override + * the selected options defined `helpText` + */ + dropdownHelpText?: ReactNode; + isLoadingLabels?: boolean; + isErrorLabels?: boolean; + className?: string; + onSelectTargetType: (val: string) => void; + onSelectCustomTarget: (val: string) => void; + onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void; +} + +const TargetLabelSelector = ({ + selectedTargetType, + selectedCustomTarget, + customTargetOptions, + selectedLabels, + dropdownHelpText, + className, + labels, + isLoadingLabels = false, + isErrorLabels = false, + onSelectTargetType, + onSelectCustomTarget, + onSelectLabel, +}: ITargetLabelSelectorProps) => { + const classNames = classnames(baseClass, className); + + return ( +
+ + {selectedTargetType === "Custom" && ( + + )} +
+ ); +}; + +export default TargetLabelSelector; diff --git a/frontend/components/TargetLabelSelector/_styles.scss b/frontend/components/TargetLabelSelector/_styles.scss new file mode 100644 index 0000000000..e4f304db2d --- /dev/null +++ b/frontend/components/TargetLabelSelector/_styles.scss @@ -0,0 +1,57 @@ +.target-label-selector { + font-size: $x-small; + + &__custom-label-chooser { + margin-top: $pad-medium; + } + + &__description { + margin: $pad-medium 0; + } + + &__no-labels { + display: flex; + height: 187px; + flex-direction: column; + align-items: center; + gap: $pad-small; + justify-content: center; + + span { + color: $ui-fleet-black-75; + } + } + + &__checkboxes { + display: flex; + max-height: 187px; + flex-direction: column; + border-radius: $border-radius; + border: 1px solid $ui-fleet-black-10; + overflow-y: auto; + + .loading-spinner { + margin: 69.5px auto; + } + } + + &__label { + width: 100%; + padding: $pad-small $pad-medium; + box-sizing: border-box; + display: flex; + align-items: center; + + &:not(:last-child) { + border-bottom: 1px solid $ui-fleet-black-10; + } + + .form-field--checkbox { + width: auto; + } + } + + &__label-name { + padding-left: $pad-large; + } +} diff --git a/frontend/components/TargetLabelSelector/index.ts b/frontend/components/TargetLabelSelector/index.ts new file mode 100644 index 0000000000..ea5f05f17c --- /dev/null +++ b/frontend/components/TargetLabelSelector/index.ts @@ -0,0 +1 @@ +export { default } from "./TargetLabelSelector"; diff --git a/frontend/components/forms/fields/Checkbox/Checkbox.tsx b/frontend/components/forms/fields/Checkbox/Checkbox.tsx index a34f403421..d0c375161a 100644 --- a/frontend/components/forms/fields/Checkbox/Checkbox.tsx +++ b/frontend/components/forms/fields/Checkbox/Checkbox.tsx @@ -126,6 +126,7 @@ const Checkbox = (props: ICheckboxProps) => { />
(null); + const [ + softwareDetails, + setSoftwareDetails, + ] = useState(null); + const queryShown = useRef(""); const queryImpact = useRef(undefined); const scriptExecutionId = useRef(""); @@ -131,6 +137,11 @@ const ActivityFeed = ({ case ActivityType.EditedActivityAutomations: setActivityAutomationDetails({ ...details }); break; + case ActivityType.AddedSoftware: + case ActivityType.EditedSoftware: + case ActivityType.DeletedSoftware: + setSoftwareDetails({ ...details }); + break; default: break; } @@ -245,6 +256,12 @@ const ActivityFeed = ({ onCancel={() => setActivityAutomationDetails(null)} /> )} + {softwareDetails && ( + setSoftwareDetails(null)} + /> + )}
); }; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx index 4e5263ea3d..12d34fee04 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx @@ -1160,9 +1160,7 @@ describe("Activity Feed", () => { }); render(); - expect( - screen.getByText("added software ", { exact: false }) - ).toBeInTheDocument(); + expect(screen.getByText("added", { exact: false })).toBeInTheDocument(); expect( screen.getByText("foobar.pkg", { exact: false }) ).toBeInTheDocument(); @@ -1188,9 +1186,7 @@ describe("Activity Feed", () => { }); render(); - expect( - screen.getByText("edited software", { exact: false }) - ).toBeInTheDocument(); + expect(screen.getByText("edited", { exact: false })).toBeInTheDocument(); expect( screen.getByText(" on the ", { exact: false, @@ -1213,9 +1209,7 @@ describe("Activity Feed", () => { }); render(); - expect( - screen.getByText("deleted software ", { exact: false }) - ).toBeInTheDocument(); + expect(screen.getByText("deleted", { exact: false })).toBeInTheDocument(); expect( screen.getByText("foobar.pkg", { exact: false }) ).toBeInTheDocument(); @@ -1237,9 +1231,7 @@ describe("Activity Feed", () => { }); render(); - expect( - screen.getByText("added software ", { exact: false }) - ).toBeInTheDocument(); + expect(screen.getByText("added", { exact: false })).toBeInTheDocument(); expect( screen.getByText("foobar.pkg", { exact: false }) ).toBeInTheDocument(); @@ -1258,9 +1250,7 @@ describe("Activity Feed", () => { }); render(); - expect( - screen.getByText("edited software", { exact: false }) - ).toBeInTheDocument(); + expect(screen.getByText("edited", { exact: false })).toBeInTheDocument(); expect( screen.getByText("on no team", { exact: false }) ).toBeInTheDocument(); @@ -1273,9 +1263,7 @@ describe("Activity Feed", () => { }); render(); - expect( - screen.getByText("deleted software ", { exact: false }) - ).toBeInTheDocument(); + expect(screen.getByText("deleted", { exact: false })).toBeInTheDocument(); expect( screen.getByText("foobar.pkg", { exact: false }) ).toBeInTheDocument(); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index e02f7466df..ef84fd1af0 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -892,54 +892,129 @@ const TAGGED_TEMPLATES = { ); }, - addedSoftware: (activity: IActivity) => { + addedSoftware: ( + activity: IActivity, + onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void + ) => { + const { + software_title, + software_package, + self_service, + labels_include_any, + labels_exclude_any, + } = activity.details || {}; + return ( <> {" "} - added software {activity.details?.software_title} ( - {activity.details?.software_package}) to{" "} + added {activity.details?.software_package} to{" "} {activity.details?.team_name ? ( <> - {" "} the {activity.details?.team_name} team. ) : ( "no team." - )} + )}{" "} + ); }, - editedSoftware: (activity: IActivity) => { + editedSoftware: ( + activity: IActivity, + onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void + ) => { + const { + software_title, + software_package, + self_service, + labels_include_any, + labels_exclude_any, + } = activity.details || {}; + return ( <> {" "} - edited software {activity.details?.software_title} ( - {activity.details?.software_package}) on{" "} + edited {activity.details?.software_package} on{" "} {activity.details?.team_name ? ( <> - {" "} the {activity.details?.team_name} team. ) : ( "no team." - )} + )}{" "} + ); }, - deletedSoftware: (activity: IActivity) => { + deletedSoftware: ( + activity: IActivity, + onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void + ) => { + const { + software_title, + software_package, + self_service, + labels_include_any, + labels_exclude_any, + } = activity.details || {}; + return ( <> {" "} - deleted software {activity.details?.software_title} ( - {activity.details?.software_package}) from{" "} + deleted {activity.details?.software_package} from{" "} {activity.details?.team_name ? ( <> - {" "} the {activity.details?.team_name} team. ) : ( "no team." - )} + )}{" "} + ); }, @@ -1334,13 +1409,13 @@ const getDetail = ( return TAGGED_TEMPLATES.resentConfigProfile(activity); } case ActivityType.AddedSoftware: { - return TAGGED_TEMPLATES.addedSoftware(activity); + return TAGGED_TEMPLATES.addedSoftware(activity, onDetailsClick); } case ActivityType.EditedSoftware: { - return TAGGED_TEMPLATES.editedSoftware(activity); + return TAGGED_TEMPLATES.editedSoftware(activity, onDetailsClick); } case ActivityType.DeletedSoftware: { - return TAGGED_TEMPLATES.deletedSoftware(activity); + return TAGGED_TEMPLATES.deletedSoftware(activity, onDetailsClick); } case ActivityType.InstalledSoftware: { return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/SoftwareDetailsModal.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/SoftwareDetailsModal.tsx new file mode 100644 index 0000000000..b36c3345fe --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/SoftwareDetailsModal.tsx @@ -0,0 +1,111 @@ +import React from "react"; + +import { IActivityDetails } from "interfaces/activity"; +import { ILabelSoftwareTitle } from "interfaces/label"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import DataSet from "components/DataSet"; +import TooltipWrapper from "components/TooltipWrapper"; + +const baseClass = "software-details-modal"; + +interface ITargetValueProps { + labels: ILabelSoftwareTitle[]; +} + +const TargetValue = ({ labels }: ITargetValueProps) => { + if (labels.length === 1) { + return <>labels[0].name; + } + return ( + ( + <> + {label.name} +
+ + ))} + > + {labels.length} labels +
+ ); +}; + +const generateTargetTitle = ( + labelIncludeAny?: ILabelSoftwareTitle[], + labelExcludeAny?: ILabelSoftwareTitle[] +) => { + if (labelIncludeAny && labelIncludeAny.length > 0) { + return "Targets (include any)"; + } else if (labelExcludeAny && labelExcludeAny.length > 0) { + return "Targets (exclude any)"; + } + return "Targets"; +}; + +const generateTargetValue = ( + labelIncludeAny?: ILabelSoftwareTitle[], + labelExcludeAny?: ILabelSoftwareTitle[] +) => { + // handle single label case + if (labelIncludeAny) { + return ; + } else if (labelExcludeAny) { + return ; + } + return "None"; +}; + +interface ISoftwareDetailsModalProps { + details: IActivityDetails; + onCancel: () => void; +} + +const SoftwareDetailsModal = ({ + details, + onCancel, +}: ISoftwareDetailsModalProps) => { + const { labels_include_any, labels_exclude_any } = details; + const hasTargets = labels_include_any || labels_exclude_any; + + return ( + + <> +
+ + + + {hasTargets && ( + + )} +
+
+ +
+ +
+ ); +}; + +export default SoftwareDetailsModal; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/_styles.scss b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/_styles.scss new file mode 100644 index 0000000000..023d021a98 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/_styles.scss @@ -0,0 +1,10 @@ +.software-details-modal { + &__modal-content { + display: flex; + gap: $pad-xxlarge; + } + + .react-tooltip { + min-width: 120px; + } +} diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/index.ts b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/index.ts new file mode 100644 index 0000000000..8a8e498b21 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/SoftwareDetailsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareDetailsModal"; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx index 3aa447c5d2..ac89c69425 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx @@ -11,16 +11,13 @@ import labelsAPI, { getCustomLabels } from "services/entities/labels"; import mdmAPI from "services/entities/mdm"; // @ts-ignore -import Dropdown from "components/forms/fields/Dropdown"; import Button from "components/buttons/Button"; import Card from "components/Card"; -import Checkbox from "components/forms/fields/Checkbox"; import DataError from "components/DataError"; import Icon from "components/Icon"; import Modal from "components/Modal"; -import Radio from "components/forms/fields/Radio"; import Spinner from "components/Spinner"; - +import TargetLabelSelector from "components/TargetLabelSelector"; import ProfileGraphic from "../AddProfileGraphic"; import { @@ -30,9 +27,7 @@ import { } from "../../helpers"; import { CUSTOM_TARGET_OPTIONS, - CustomTargetOption, generateLabelKey, - getDescriptionText, listNamesFromSelectedLabels, } from "./helpers"; @@ -91,118 +86,6 @@ const FileDetails = ({ details: { name, platform } }: IFileDetailsProps) => ( ); -interface ITargetChooserProps { - selectedTarget: string; - setSelectedTarget: React.Dispatch>; -} - -const TargetChooser = ({ - selectedTarget, - setSelectedTarget, -}: ITargetChooserProps) => { - return ( -
-
Target
- - -
- ); -}; - -interface ILabelChooserProps { - isError: boolean; - isLoading: boolean; - labels: ILabelSummary[]; - selectedLabels: Record; - customTargetOption: CustomTargetOption; - setSelectedLabels: React.Dispatch< - React.SetStateAction> - >; - onSelectCustomTargetOption: (val: CustomTargetOption) => void; -} - -const LabelChooser = ({ - isError, - isLoading, - labels, - selectedLabels, - customTargetOption, - setSelectedLabels, - onSelectCustomTargetOption, -}: ILabelChooserProps) => { - const updateSelectedLabels = useCallback( - ({ name, value }: { name: string; value: boolean }) => { - setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value })); - }, - [setSelectedLabels] - ); - - const renderLabels = () => { - if (isLoading) { - return ; - } - - if (isError) { - return ; - } - - if (!labels.length) { - return ( -
- No labels exist in Fleet - Add labels to target specific hosts. -
- ); - } - - return labels.map((label) => { - return ( -
- -
{label.name}
-
- ); - }); - }; - - return ( -
- -
- {getDescriptionText(customTargetOption)} -
-
{renderLabels()}
-
- ); -}; - interface IAddProfileModalProps { currentTeamId: number; isPremiumTier: boolean; @@ -223,14 +106,13 @@ const AddProfileModal = ({ name: string; platform: string; } | null>(null); - const [selectedTarget, setSelectedTarget] = useState("All hosts"); // "All hosts" | "Custom" + const [selectedTargetType, setSelectedTargetType] = useState("All hosts"); const [selectedLabels, setSelectedLabels] = useState>( {} ); - const [ - customTargetOption, - setCustomTargetOption, - ] = useState("labelsIncludeAll"); + const [selectedCustomTarget, setSelectedCustomTarget] = useState( + "labelsIncludeAll" + ); const fileRef = useRef(null); @@ -242,7 +124,6 @@ const AddProfileModal = ({ } = useQuery( ["custom_labels"], () => labelsAPI.summary().then((res) => getCustomLabels(res.labels)), - { enabled: isPremiumTier, refetchOnWindowFocus: false, @@ -268,8 +149,8 @@ const AddProfileModal = ({ setIsLoading(true); try { const labelKey = generateLabelKey( - selectedTarget, - customTargetOption, + selectedTargetType, + selectedCustomTarget, selectedLabels ); await mdmAPI.uploadProfile({ @@ -308,8 +189,16 @@ const AddProfileModal = ({ } }; - const onSelectCustomTargetOption = (val: CustomTargetOption) => { - setCustomTargetOption(val); + const onSelectTargetType = (val: string) => { + setSelectedTargetType(val); + }; + + const onSelectCustomTargetOption = (val: string) => { + setSelectedCustomTarget(val); + }; + + const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => { + setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value })); }; return ( @@ -327,23 +216,19 @@ const AddProfileModal = ({ )} {isPremiumTier && ( -
- - {selectedTarget === "Custom" && ( - - )} -
+ )}
+ string; type IValidationMessage = string | IMessageFunc; +type IFormValidationKey = keyof Omit; interface IValidation { name: string; @@ -16,7 +21,7 @@ interface IValidation { } const FORM_VALIDATION_CONFIG: Record< - "preInstallQuery", + IFormValidationKey, { validations: IValidation[] } > = { preInstallQuery: { @@ -33,6 +38,22 @@ const FORM_VALIDATION_CONFIG: Record< }, ], }, + customTarget: { + validations: [ + { + name: "requiredLabelTargets", + isValid: (formData) => { + if (formData.targetType === "All hosts") return true; + // there must be at least one label target selected + return ( + Object.keys(formData.labelTargets).find( + (key) => formData.labelTargets[key] + ) !== undefined + ); + }, + }, + ], + }, }; const getErrorMessage = ( @@ -54,7 +75,7 @@ export const generateFormValidation = ( }; Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => { - const objKey = key as keyof typeof FORM_VALIDATION_CONFIG; + const objKey = key as IFormValidationKey; const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find( (validation) => !validation.isValid(formData) ); @@ -74,3 +95,45 @@ export const generateFormValidation = ( return formValidation; }; + +export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [ + { + value: "labelsIncludeAny", + label: "Include any", + disabled: false, + }, + { + value: "labelsExcludeAny", + label: "Exclude any", + disabled: false, + }, +]; + +export const generateHelpText = (installType: string, customTarget: string) => { + if (customTarget === "labelsIncludeAny") { + return installType === "manual" ? ( + <> + Software will only be available for install on hosts that{" "} + have any of these labels: + + ) : ( + <> + Software will only be installed on hosts that have any of these + labels: + + ); + } + + // this is the case for labelsExcludeAny + return installType === "manual" ? ( + <> + Software will only be available for install on hosts that{" "} + don't have any of these labels: + + ) : ( + <> + Software will only be installed on hosts that don't have any{" "} + of these labels:{" "} + + ); +}; diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx index 657851e5b2..1bae3b9bc8 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx @@ -8,11 +8,13 @@ import { buildQueryStringFromParams } from "utilities/url"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import softwareAPI from "services/entities/software"; import teamPoliciesAPI from "services/entities/team_policies"; +import labelsAPI, { getCustomLabels } from "services/entities/labels"; import { QueryContext } from "context/query"; import { AppContext } from "context/app"; import { NotificationContext } from "context/notification"; import { getErrorReason } from "interfaces/errors"; import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform"; +import { ILabelSummary } from "interfaces/label"; import useToggleSidePanel from "hooks/useToggleSidePanel"; import BackLink from "components/BackLink"; @@ -113,7 +115,11 @@ const FleetMaintainedAppDetailsPage = ({ setShowAddFleetAppSoftwareModal, ] = useState(false); - const { data: fleetApp, isLoading, isError } = useQuery( + const { + data: fleetApp, + isLoading: isLoadingFleetApp, + isError: isErrorFleetApp, + } = useQuery( ["fleet-maintained-app", appId], () => softwareAPI.getFleetMainainedApp(appId), { @@ -123,6 +129,21 @@ const FleetMaintainedAppDetailsPage = ({ } ); + const { + data: labels, + isLoading: isLoadingLabels, + isError: isErrorLabels, + } = useQuery( + ["custom_labels"], + () => labelsAPI.summary().then((res) => getCustomLabels(res.labels)), + + { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: isPremiumTier, + staleTime: 10000, + } + ); + const onOsqueryTableSelect = (tableName: string) => { setSelectedOsqueryTable(tableName); }; @@ -221,12 +242,12 @@ const FleetMaintainedAppDetailsPage = ({ return ; } - if (isLoading) { + if (isLoadingFleetApp || isLoadingLabels) { return ; } - if (isError) { - return ; + if (isErrorFleetApp || isErrorLabels) { + return ; } if (fleetApp) { @@ -245,6 +266,7 @@ const FleetMaintainedAppDetailsPage = ({ version={fleetApp.version} /> void; onExit: () => void; } @@ -56,9 +67,24 @@ const EditSoftwareModal = ({ software: null, installScript: "", selfService: false, + targetType: "", + customTarget: "", + labelTargets: {}, }); const [uploadProgress, setUploadProgress] = useState(0); + const { + data: labels, + isLoading: isLoadingLabels, + isError: isErrorLabels, + } = useQuery( + ["custom_labels"], + () => labelsAPI.summary().then((res) => getCustomLabels(res.labels)), + { + ...DEFAULT_USE_QUERY_OPTIONS, + } + ); + // Work around to not lose Edit Software modal data when Save changes modal opens // by using CSS to hide Edit Software modal when Save changes modal is open useEffect(() => { @@ -118,6 +144,7 @@ const EditSoftwareModal = ({ try { await softwareAPI.editSoftwarePackage({ data: formData, + orignalPackage: software, softwareId, teamId, onUploadProgress: (progressEvent) => { @@ -147,7 +174,7 @@ const EditSoftwareModal = ({ if (isTimeout) { renderFlash( "error", - `Couldn’t upload. Request timeout. Please make sure your server and load balancer timeout is long enough.` + `Couldn't upload. Request timeout. Please make sure your server and load balancer timeout is long enough.` ); } else if (reason.includes("Fleet couldn't read the version from")) { renderFlash( @@ -184,18 +211,21 @@ const EditSoftwareModal = ({ postInstallScript: software.post_install_script || "", uninstallScript: software.uninstall_script || "", selfService: software.self_service || false, + targetType: getTargetType(software), + customTarget: getCustomTarget(software), + labelTargets: generateSelectedLabels(software), }); setPendingUpdates(formData); const onlySelfServiceUpdated = Object.keys(updates).length === 1 && "selfService" in updates; - if (!onlySelfServiceUpdated) { - // Open the confirm save changes modal - setShowConfirmSaveChangesModal(true); - } else { + if (onlySelfServiceUpdated) { // Proceed with saving changes (API expects only changes) onSaveSoftwareChanges(formData); + } else { + // Open the confirm save changes modal + setShowConfirmSaveChangesModal(true); } }; @@ -213,6 +243,7 @@ const EditSoftwareModal = ({ width="large" > void; - router: InjectedRouter; refetchSoftwareTitle: () => void; } @@ -256,7 +254,6 @@ const SoftwarePackageCard = ({ softwareId, teamId, onDelete, - router, refetchSoftwareTitle, }: ISoftwarePackageCardProps) => { const { @@ -393,13 +390,12 @@ const SoftwarePackageCard = ({ teamId={teamId} /> - {showEditSoftwareModal && ( + {showEditSoftwareModal && softwarePackage && ( setShowEditSoftwareModal(false)} - router={router} refetchSoftwareTitle={refetchSoftwareTitle} /> )} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index cc55f31e28..5c312a6317 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -148,7 +148,6 @@ const SoftwareTitleDetailsPage = ({ softwareId={softwareId} teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID} onDelete={onDeleteInstaller} - router={router} refetchSoftwareTitle={refetchSoftwareTitle} /> ); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts index 8e452a1b59..d5691b09c5 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts @@ -9,6 +9,8 @@ describe("SoftwareTitleDetailsPage helpers", () => { name: "Test Software", versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }], software_package: { + labels_include_any: null, + labels_exclude_any: null, name: "TestPackage.pkg", version: "1.0.0", self_service: true, @@ -21,6 +23,7 @@ describe("SoftwareTitleDetailsPage helpers", () => { failed_uninstall: 1, }, install_script: "echo foo", + uninstall_script: "echo bar", icon_url: "https://example.com/icon.png", automatic_install_policies: [], last_install: null, diff --git a/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx index 58442aa3f5..d728b72805 100644 --- a/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/PackageForm/PackageForm.tsx @@ -6,15 +6,23 @@ import { NotificationContext } from "context/notification"; import { getFileDetails } from "utilities/file/fileUtils"; import getDefaultInstallScript from "utilities/software_install_scripts"; import getDefaultUninstallScript from "utilities/software_uninstall_scripts"; +import { ILabelSummary } from "interfaces/label"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; import FileUploader from "components/FileUploader"; import TooltipWrapper from "components/TooltipWrapper"; +import TargetLabelSelector from "components/TargetLabelSelector"; import PackageAdvancedOptions from "../PackageAdvancedOptions"; -import { generateFormValidation } from "./helpers"; +import { + CUSTOM_TARGET_OPTIONS, + generateFormValidation, + generateSelectedLabels, + getCustomTarget, + getTargetType, +} from "./helpers"; export const baseClass = "package-form"; @@ -25,18 +33,20 @@ export interface IPackageFormData { postInstallScript?: string; uninstallScript?: string; selfService: boolean; + targetType: string; + customTarget: string; + labelTargets: Record; } export interface IFormValidation { isValid: boolean; software: { isValid: boolean }; preInstallQuery?: { isValid: boolean; message?: string }; - postInstallScript?: { isValid: boolean; message?: string }; - uninstallScript?: { isValid: boolean; message?: string }; - selfService?: { isValid: boolean }; + customTarget?: { isValid: boolean }; } interface IPackageFormProps { + labels: ILabelSummary[]; showSchemaButton?: boolean; onCancel: () => void; onSubmit: (formData: IPackageFormData) => void; @@ -54,6 +64,7 @@ interface IPackageFormProps { const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb,.rpm"; const PackageForm = ({ + labels, showSchemaButton = false, onClickShowSchema, onCancel, @@ -69,15 +80,17 @@ const PackageForm = ({ }: IPackageFormProps) => { const { renderFlash } = useContext(NotificationContext); - const initialFormData = { + const [formData, setFormData] = useState({ software: defaultSoftware || null, installScript: defaultInstallScript || "", preInstallQuery: defaultPreInstallQuery || "", postInstallScript: defaultPostInstallScript || "", uninstallScript: defaultUninstallScript || "", selfService: defaultSelfService || false, - }; - const [formData, setFormData] = useState(initialFormData); + targetType: getTargetType(defaultSoftware), + customTarget: getCustomTarget(defaultSoftware), + labelTargets: generateSelectedLabels(defaultSoftware), + }); const [formValidation, setFormValidation] = useState({ isValid: false, software: { isValid: false }, @@ -156,6 +169,27 @@ const PackageForm = ({ setFormValidation(generateFormValidation(newData)); }; + const onSelectTargetType = (value: string) => { + const newData = { ...formData, targetType: value }; + setFormData(newData); + setFormValidation(generateFormValidation(newData)); + }; + + const onSelectCustomTarget = (value: string) => { + const newData = { ...formData, customTarget: value }; + setFormData(newData); + setFormValidation(generateFormValidation(newData)); + }; + + const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => { + const newData = { + ...formData, + labelTargets: { ...formData.labelTargets, [name]: value }, + }; + setFormData(newData); + setFormValidation(generateFormValidation(newData)); + }; + const isSubmitDisabled = !formValidation.isValid; const classNames = classnames(baseClass, className); @@ -176,6 +210,17 @@ const PackageForm = ({ formData.software ? getFileDetails(formData.software) : undefined } /> + ; - type IMessageFunc = (formData: IPackageFormData) => string; type IValidationMessage = string | IMessageFunc; +type IFormValidationKey = keyof Omit; interface IValidation { name: string; @@ -17,11 +16,11 @@ interface IValidation { message?: IValidationMessage; } -/** configuration defines validations for each filed in the form. It defines rules +/** configuration defines validations for each field in the form. It defines rules * to determine if a field is valid, and rules for generating an error message. */ const FORM_VALIDATION_CONFIG: Record< - IPackageFormValidatorKey, + IFormValidationKey, { validations: IValidation[] } > = { software: { @@ -46,13 +45,21 @@ const FORM_VALIDATION_CONFIG: Record< }, ], }, - postInstallScript: { - // no validations related to postInstallScript - validations: [], - }, - selfService: { - // no validations related to self service - validations: [], + customTarget: { + validations: [ + { + name: "requiredLabelTargets", + isValid: (formData) => { + if (formData.targetType === "All hosts") return true; + // there must be at least one label target selected + return ( + Object.keys(formData.labelTargets).find( + (key) => formData.labelTargets[key] + ) !== undefined + ); + }, + }, + ], }, }; @@ -97,3 +104,56 @@ export const generateFormValidation = (formData: IPackageFormData) => { }; export default generateFormValidation; + +export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [ + { + value: "labelsIncludeAny", + label: "Include any", + disabled: false, + }, + { + value: "labelsExcludeAny", + label: "Exclude any", + disabled: false, + }, +]; + +export const getTargetType = (softwarePackage: ISoftwarePackage) => { + if (!softwarePackage) return "All hosts"; + + return !softwarePackage.labels_include_any && + !softwarePackage.labels_exclude_any + ? "All hosts" + : "Custom"; +}; + +export const getCustomTarget = (softwarePackage: ISoftwarePackage) => { + if (!softwarePackage) return "labelsIncludeAny"; + + return softwarePackage.labels_include_any + ? "labelsIncludeAny" + : "labelsExcludeAny"; +}; + +export const generateSelectedLabels = (softwarePackage: ISoftwarePackage) => { + if ( + !softwarePackage || + (!softwarePackage.labels_include_any && !softwarePackage.labels_exclude_any) + ) { + return {}; + } + + const customTypeKey = softwarePackage.labels_include_any + ? "labels_include_any" + : "labels_exclude_any"; + + return ( + softwarePackage[customTypeKey]?.reduce>( + (acc, label) => { + acc[label.name] = true; + return acc; + }, + {} + ) ?? {} + ); +}; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 341caa4e7f..7c50898133 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -99,7 +99,7 @@ import { MANAGE_HOSTS_PAGE_FILTER_KEYS, MANAGE_HOSTS_PAGE_LABEL_INCOMPATIBLE_QUERY_PARAMS, } from "./HostsPageConfig"; -import { isAcceptableStatus } from "./helpers"; +import { getDeleteLabelErrorMessages, isAcceptableStatus } from "./helpers"; import DeleteSecretModal from "../../../components/EnrollSecrets/DeleteSecretModal"; import SecretEditorModal from "../../../components/EnrollSecrets/SecretEditorModal"; @@ -1061,13 +1061,7 @@ const ManageHostsPage = ({ ); renderFlash("success", "Successfully deleted label."); } catch (error) { - console.error(error); - renderFlash( - "error", - getErrorReason(error).includes("built-in") - ? "Built-in labels can’t be modified or deleted." - : "Could not delete label. Please try again." - ); + renderFlash("error", getDeleteLabelErrorMessages(error)); } finally { setIsUpdatingLabel(false); } diff --git a/frontend/pages/hosts/ManageHostsPage/components/DeleteLabelModal/DeleteLabelModal.tsx b/frontend/pages/hosts/ManageHostsPage/components/DeleteLabelModal/DeleteLabelModal.tsx index 29086ee6e4..8780026f1d 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/DeleteLabelModal/DeleteLabelModal.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/DeleteLabelModal/DeleteLabelModal.tsx @@ -24,7 +24,14 @@ const DeleteLabelModal = ({ className={baseClass} > <> -

Are you sure you wish to delete this label?

+

+ If a configuration profile uses this label as a custom target, the + profile will break: it won't be applied to new hosts. +

+

+ To apply the profile to new hosts, you'll have to delete it and + upload a new profile. +